SLOPSHOPPER

linear-review-link

Shows the current branch's pull request above the prompt, with its failing checks, unresolved threads and linked Linear issue, and opens it on linear.review.

newbandprocesstimer
v0.1.0MITupdated 2026-10-15pekala/linear-review-link
A shopper browsing a rack in a slop shop
README

linear-review-link

A Claude Code mod that shows the current branch's pull request in a row above the prompt and opens it on linear.review instead of GitHub.

In the Claude desktop app the row shows:

  • the PR's state icon, title and repo#number
  • failing checks, with a tooltip naming them
  • unresolved review threads, with a tooltip listing each one's file, author and first line
  • the Linear issue the PR is linked to, read from the comment Linear's GitHub integration posts on the PR
  • a … menu: Open in Linear review, Open the issue in Linear, Copy review link, Copy the issue ID, Copy branch name, Open on GitHub

Clicking the PR opens it on linear.review. In a terminal session the row is one line of text with the title as a link.

The row refreshes when the session starts, after each of Claude's turns, and every 2 minutes. It shows nothing when the branch has no PR.

Install

In a Claude Code terminal session:

/plugin install linear-review-link --marketplace pekala/linear-review-link

Answer y to add the marketplace, then pick the user scope. Installed at the user scope, the mod also loads in the desktop app's Code sessions.

From a bundle

Each release has a linear-review-link-<version>.zip. Unzip it into a folder you keep, since Claude Code reads the plugin from that folder:

mkdir -p ~/plugins/linear-review-link
unzip linear-review-link-0.1.0.zip -d ~/plugins/linear-review-link

Then, in a Claude Code terminal session:

/plugin install linear-review-link --marketplace ~/plugins/linear-review-link

What it does on your machine

  • Runs gh pr view and gh api graphql in the session's folder, with your gh login, to read the PR, its checks, its review threads and its comments. This happens when a session starts, after each of Claude's turns, and every 2 minutes. These calls only read.
  • Runs open <url> when you click the row or a menu item. It opens only linear.review, linear.app and github.com URLs.
  • Copies text to your clipboard when you pick a Copy item in the menu.
  • Writes no files and sends nothing anywhere else.

Requirements

  • The GitHub CLI, signed in (gh auth status). The mod reads the PR, its checks and its threads through gh.
  • macOS. Links open in your default browser through open.
  • A Claude Code build with function hooks (mods). The API is early access; this mod was built against Claude Code 2.1.295.

Develop

claude plugin validate .
claude plugin test .
claude --plugin-dir .

License

MIT. The icons for PR states, failing checks and comments come from Octicons (MIT); see NOTICE.

Source 4 files
hooks/register.tsx 485 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, ProcessRunResult, Register, RenderSurface } from 'claude-code'
3
4import type { Checks, LinkedIssue, PullRequest, ReviewThread } from '../types'
5
6import { ICONS } from './icons'
7import type { IconName } from './icons'
8
9const pr = atom({ plugin: 'linear-review-link', key: 'pr' } as const, null)
10const isHovered = atom({ plugin: 'linear-review-link', key: 'isHovered' } as const, false)
11const menuEpoch = atom({ plugin: 'linear-review-link', key: 'menuEpoch' } as const, 0)
12
13// The desktop app's PATH may not include Homebrew, so fall back to its usual paths.
14const GH_PATHS = ['gh', '/opt/homebrew/bin/gh', '/usr/local/bin/gh']
15const POLL_MS = 2 * 60_000
16
17// Unresolved review threads (what the app's own PR row counts), and the comments the
18// Linear integration's link-back is among.
19const DETAILS_QUERY = `query($owner: String!, $name: String!, $number: Int!) {
20  repository(owner: $owner, name: $name) {
21    pullRequest(number: $number) {
22      reviewThreads(first: 100) {
23        nodes { isResolved path comments(first: 1) { nodes { author { login } body } } }
24      }
25      comments(first: 100) { nodes { body } }
26    }
27  }
28}`
29
30// The Linear integration comments on each PR it links: one <details> per issue, the issue's
31// link in its <summary>, its description (which may mention other issues) below.
32const LINKBACK_MARK = '<!-- linear-linkback -->'
33const SUMMARY = /<summary>([\s\S]*?)<\/summary>/g
34const ISSUE_URL = /https:\/\/linear\.app\/[^/\s"]+\/issue\/([A-Z][A-Z0-9]*-\d+)[^\s"]*/
35
36export function linkedIssues(commentBodies: readonly string[]): LinkedIssue[] {
37  const issues = commentBodies
38    .filter(body => body.includes(LINKBACK_MARK))
39    .flatMap(body => [...body.matchAll(SUMMARY)].map(summary => summary[1] ?? ''))
40    .flatMap(summary => {
41      const match = summary.match(ISSUE_URL)
42      if (match?.[1] === undefined) return []
43      // The summary's link reads "ENG-123 Fix the thing": the issue's title follows its id.
44      const text = summary.replace(/<[^>]*>/g, '').trim()
45      const title = text.startsWith(match[1]) ? text.slice(match[1].length).trim() : text
46      return [{ id: match[1], url: match[0], title }]
47    })
48  return issues.filter((issue, i) => issues.findIndex(other => other.id === issue.id) === i)
49}
50
51export function repoName(url: string): string | null {
52  return url.match(/\/\/[^/]+\/[^/]+\/([^/]+)\/pull\//)?.[1] ?? null
53}
54
55export function toLinearReview(url: string): string {
56  return url.replace(/^https:\/\/github\.com\//, 'https://linear.review/')
57}
58
59type CheckItem = {
60  __typename?: string
61  name?: string
62  context?: string
63  status?: string
64  conclusion?: string
65  state?: string
66}
67
68const FAILED = new Set(['FAILURE', 'ERROR', 'TIMED_OUT', 'CANCELLED', 'ACTION_REQUIRED', 'STARTUP_FAILURE'])
69const PENDING = new Set(['PENDING', 'EXPECTED', ''])
70
71export function summarizeChecks(items: readonly CheckItem[]): Checks | null {
72  if (items.length === 0) return null
73  const checks = { passed: 0, failing: 0, pending: 0, failingNames: [] as string[] }
74  for (const item of items) {
75    // A StatusContext has a state; a CheckRun has a conclusion once it completes.
76    const result =
77      item.__typename === 'StatusContext'
78        ? (item.state ?? '')
79        : item.status === 'COMPLETED'
80          ? (item.conclusion ?? '')
81          : 'PENDING'
82    if (FAILED.has(result)) {
83      checks.failing += 1
84      checks.failingNames.push(item.name ?? item.context ?? 'Unnamed check')
85    } else if (PENDING.has(result)) checks.pending += 1
86    else checks.passed += 1
87  }
88  return checks
89}
90
91// Linear's palette.
92const GREEN = '#4cb782'
93const RED = '#eb5757'
94const INDIGO = '#5e6ad2'
95const MUTED = '#8a8f98'
96
97// The theme's subtle background, light or dark: what user messages sit on.
98const HOVER_BACKGROUND = 'userMessageBackground'
99
100// The title's hover background, in px: room inside it on each side, its height, and the
101// room outside it before the icon and the ref.
102const TITLE_PADDING = 10
103const TITLE_HEIGHT = 26
104const TITLE_GAP = 5
105
106// `color` is a theme key for text; `hex` is for the SVG, which can't read the theme.
107type Look = { label: string; color: string; hex: string; icon: IconName }
108
109const STATE_LOOKS = {
110  open: { label: 'Open', color: 'success', hex: GREEN, icon: 'open' },
111  draft: { label: 'Draft', color: 'inactive', hex: MUTED, icon: 'draft' },
112  merged: { label: 'Merged', color: 'merged', hex: INDIGO, icon: 'merged' },
113  closed: { label: 'Closed', color: 'error', hex: RED, icon: 'closed' },
114} as const satisfies Record<string, Look>
115
116export function stateLook({ state, isDraft }: PullRequest): Look {
117  if (state === 'MERGED') return STATE_LOOKS.merged
118  if (state === 'CLOSED') return STATE_LOOKS.closed
119  return isDraft ? STATE_LOOKS.draft : STATE_LOOKS.open
120}
121
122// An icon `size` px tall with `gap` px of empty room on its right: spacing before the
123// text beside it that is finer than a Box gap, which is a whole cell.
124function iconSvg(name: IconName, hex: string, size: number, gap: number): { source: string; width: number } {
125  const { viewBox, body } = ICONS[name]
126  const [x = 0, y = 0, width = 16, height = 16] = viewBox.split(' ').map(Number)
127  const extra = (gap * height) / size
128  return {
129    source: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${x} ${y} ${width + extra} ${height}" fill="${hex}">${body}</svg>`,
130    width: (size * width) / height + gap,
131  }
132}
133
134// Text drawn as an SVG in the system's monospace font: a Text has no font of its own, and a
135// Code draws a background. `left` px of room come before it. Sized for SF Mono, whose
136// characters are 0.6em wide.
137const MONO_SIZE = 13
138
139export function monoSvg(text: string, hex: string, left: number): { source: string; width: number; height: number } {
140  const width = Math.ceil(left + text.length * MONO_SIZE * 0.6 + 1)
141  const height = Math.ceil(MONO_SIZE * 1.4)
142  const baseline = Math.round(MONO_SIZE * 1.05)
143  return {
144    source:
145      `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}">` +
146      `<text x="${left}" y="${baseline}" font-family="ui-monospace, SFMono-Regular, Menlo, monospace" ` +
147      `font-size="${MONO_SIZE}" fill="${hex}">${escapeXml(text)}</text></svg>`,
148    width,
149    height,
150  }
151}
152
153// Empty room of an exact size, finer than a Box's padding, which is a whole cell.
154function spacerSvg(): string {
155  return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"></svg>'
156}
157
158async function runGh($: EngineInterface, args: readonly string[]): Promise<ProcessRunResult | null> {
159  for (const gh of GH_PATHS) {
160    try {
161      return await $.process.run([gh, ...args])
162    } catch {
163      // gh is not at this path
164    }
165  }
166  return null
167}
168
169type Details = { openThreads: number | null; threads: ReviewThread[]; issues: LinkedIssue[] }
170
171type ThreadNode = {
172  isResolved: boolean
173  path: string | null
174  comments: { nodes: { author: { login: string } | null; body: string }[] }
175}
176
177// A comment's markdown as one line of plain text: no HTML, images, link targets or emphasis.
178export function plainText(markdown: string): string {
179  return markdown
180    .replace(/<!--[\s\S]*?-->/g, '')
181    .replace(/!\[[^\]]*\]\([^)]*\)/g, '')
182    .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
183    .replace(/<[^>]+>/g, '')
184    .replace(/[*_`#>]+/g, '')
185    .replace(/\s+/g, ' ')
186    .trim()
187}
188
189export function unresolvedThreads(nodes: readonly ThreadNode[]): ReviewThread[] {
190  return nodes
191    .filter(thread => !thread.isResolved)
192    .map(thread => {
193      const [first] = thread.comments.nodes
194      return { path: thread.path, author: first?.author?.login ?? null, excerpt: plainText(first?.body ?? '') }
195    })
196}
197
198async function fetchDetails($: EngineInterface, url: string, number: number): Promise<Details> {
199  const none = { openThreads: null, threads: [], issues: [] }
200  const [, owner, name] = url.match(/github\.com\/([^/]+)\/([^/]+)\//) ?? []
201  if (owner === undefined || name === undefined) return none
202  const ran = await runGh($, [
203    'api', 'graphql',
204    '-f', `query=${DETAILS_QUERY}`,
205    '-F', `owner=${owner}`, '-F', `name=${name}`, '-F', `number=${number}`,
206  ])
207  if (ran === null || ran.exitCode !== 0) return none
208  try {
209    const found = JSON.parse(ran.stdout).data.repository.pullRequest
210    const threads = unresolvedThreads(found.reviewThreads.nodes)
211    const comments: { body: string }[] = found.comments.nodes
212    return {
213      openThreads: threads.length,
214      threads,
215      issues: linkedIssues(comments.map(comment => comment.body)),
216    }
217  } catch {
218    return none
219  }
220}
221
222async function findPullRequest($: EngineInterface): Promise<PullRequest | null> {
223  const ran = await runGh($, ['pr', 'view', '--json', 'number,title,state,isDraft,url,headRefName,statusCheckRollup'])
224  if (ran === null || ran.exitCode !== 0) return null // no PR for this branch, or not a GitHub repo
225  let found
226  try {
227    found = JSON.parse(ran.stdout)
228  } catch {
229    return null
230  }
231  const { number, title, state, isDraft, url, headRefName, statusCheckRollup } = found
232  return {
233    number,
234    title,
235    state,
236    isDraft,
237    url: toLinearReview(url),
238    githubUrl: url,
239    branch: headRefName,
240    checks: summarizeChecks(statusCheckRollup ?? []),
241    repo: repoName(url),
242    ...(await fetchDetails($, url, number)),
243  }
244}
245
246let isRefreshing = false
247
248async function refresh($: EngineInterface): Promise<void> {
249  if (isRefreshing) return
250  isRefreshing = true
251  try {
252    const found = await findPullRequest($)
253    await update($, pr, () => found)
254  } finally {
255    isRefreshing = false
256  }
257}
258
259// Pages open with macOS `open`: a desktop Link can't wrap icons, and its styling is the
260// surface's. Only these hosts are opened.
261const OPENABLE = ['https://linear.review/', 'https://linear.app/', 'https://github.com/']
262
263async function openInBrowser($: EngineInterface, url: string): Promise<void> {
264  if (!OPENABLE.some(prefix => url.startsWith(prefix))) return
265  await $.process.run(['open', url])
266}
267
268// What the "…" menu offers, in order. The desktop's Select always shows one option as chosen,
269// so the first is a "…" that stands for none: the menu rests on it, and picking it does nothing.
270const MENU_REST = '…'
271
272export function menuOptions(current: PullRequest): { value: string; label: string }[] {
273  return [
274    { value: MENU_REST, label: '…' },
275    { value: 'open-review', label: 'Open in Linear review' },
276    ...current.issues.map(issue => ({ value: `open-issue:${issue.id}`, label: `Open ${issue.id} in Linear` })),
277    { value: 'copy-review-link', label: 'Copy review link' },
278    ...current.issues.map(issue => ({ value: `copy-issue:${issue.id}`, label: `Copy ${issue.id}` })),
279    { value: 'copy-branch', label: 'Copy branch name' },
280    { value: 'open-github', label: 'Open on GitHub' },
281  ]
282}
283
284export function issueLabel(current: PullRequest): string {
285  const [first] = current.issues
286  if (first === undefined) return 'Linear'
287  return current.issues.length > 1 ? `${first.id} +${current.issues.length - 1}` : first.id
288}
289
290const MAX_TOOLTIP_LINES = 8
291
292function bullets(lines: readonly string[]): string[] {
293  const shown = lines.slice(0, MAX_TOOLTIP_LINES).map(line => `• ${line}`)
294  return lines.length > MAX_TOOLTIP_LINES ? [...shown, `+${lines.length - MAX_TOOLTIP_LINES} more`] : shown
295}
296
297export function checksTooltip(checks: Checks): string {
298  const heading = checks.failing === 1 ? '1 failing check' : `${checks.failing} failing checks`
299  const others = [`${checks.passed} passed`, ...(checks.pending > 0 ? [`${checks.pending} pending`] : [])].join(', ')
300  return [heading, ...bullets(checks.failingNames), others].join('\n')
301}
302
303export function threadsTooltip(threads: readonly ReviewThread[]): string {
304  const heading = threads.length === 1 ? '1 unresolved review thread' : `${threads.length} unresolved review threads`
305  const lines = threads.map(({ path, author, excerpt }) => {
306    const where = path ?? 'Conversation'
307    const short = excerpt.length > 60 ? `${excerpt.slice(0, 59)}…` : excerpt
308    return `${where}${author === null ? '' : ` · @${author}`}${short === '' ? '' : `: ${short}`}`
309  })
310  return [heading, ...bullets(lines)].join('\n')
311}
312
313export function issuesTooltip(issues: readonly LinkedIssue[]): string {
314  if (issues.length === 0) return 'Opens in Linear'
315  return [issues.length === 1 ? 'Linked issue' : 'Linked issues', ...bullets(issues.map(i => `${i.id} ${i.title}`))].join('\n')
316}
317
318function escapeXml(text: string): string {
319  return text.replace(/[&<>"']/g, char => `&#${char.charCodeAt(0)};`)
320}
321
322// An icon whose hover shows `tooltip`: the SVG's own <title>, which only an interactive
323// Svg (a sandboxed frame) shows.
324function tooltipSvg(name: IconName, hex: string, size: number, gap: number, tooltip: string) {
325  const { source, width } = iconSvg(name, hex, size, gap)
326  const titled = source.replace('>', `><title>${escapeXml(tooltip)}</title>`)
327  return { source: titled, width }
328}
329
330async function runMenuAction($: EngineInterface, action: string, surface: RenderSurface): Promise<void> {
331  const current = await read($, pr)
332  // A new epoch draws the menu again, resting on "…".
333  await update($, menuEpoch, n => n + 1)
334  if (current === null) return
335  const issue = current.issues.find(({ id }) => action.endsWith(`:${id}`))
336  if (action === 'open-review') await openInBrowser($, current.url)
337  if (action === 'open-github') await openInBrowser($, current.githubUrl)
338  if (action === 'copy-review-link') await $.ui.copy({ text: current.url, surface })
339  if (action === 'copy-branch') await $.ui.copy({ text: current.branch, surface })
340  if (issue !== undefined && action.startsWith('open-issue:')) await openInBrowser($, issue.url)
341  if (issue !== undefined && action.startsWith('copy-issue:')) await $.ui.copy({ text: issue.id, surface })
342}
343
344async function onRowMessage($: EngineInterface, data: unknown): Promise<void> {
345  const message = (data ?? {}) as { type?: string; isHovered?: boolean }
346  if (message.type === 'hover') await update($, isHovered, () => message.isHovered === true)
347  if (message.type !== 'open') return
348  const current = await read($, pr)
349  if (current !== null) await openInBrowser($, current.url)
350}
351
352export const register: Register = on => {
353  on('session.start', ($, e, next) => {
354    $.clock.after(0, () => void refresh($))
355    $.clock.every(POLL_MS, () => void refresh($))
356    return next(e)
357  })
358
359  // A turn may have opened a PR, pushed new commits or switched branches.
360  on('turn.complete', async ($, e, next) => {
361    const result = await next(e)
362    $.clock.after(0, () => void refresh($))
363    return result
364  })
365
366  // The region laid over the desktop row: the pointer entering or leaving it, or a click.
367  on('ui.message', async ($, e, next) => {
368    if (e.element !== 'linear-review-click') return next(e)
369    await onRowMessage($, e.data)
370    return {}
371  })
372
373  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
374    const current = await read($, pr)
375    if (current === null || e.props.hasSurvey) return next(e)
376
377    const state = stateLook(current)
378    const failing = current.checks?.failing ?? 0
379    const { openThreads } = current
380    const threadsLabel = openThreads === 1 ? '1 unresolved thread' : `${openThreads} unresolved threads`
381
382    // The terminal has no Svg, and its links are OSC 8, so it gets glyphs and a plain link.
383    if (e.surface === 'terminal') {
384      const { Box, Text, Link } = $.ui.resolve(e)
385      return (
386        <Box flexDirection="row" justifyContent="space-between" gap={2} width={e.props.bodyColumns}>
387          <Box flexDirection="row" gap={1} flexShrink={1}>
388            <Text color={state.color}>●</Text>
389            <Box flexShrink={1}>
390              <Text wrap="truncate-end">
391                <Link href={current.url}>{current.title}</Link>
392              </Text>
393            </Box>
394            <Text dimColor>
395              {current.repo ?? ''}#{current.number}
396            </Text>
397          </Box>
398          <Box flexDirection="row" gap={2} flexShrink={0}>
399            {failing > 0 && <Text color="error">✗ {failing}</Text>}
400            {openThreads !== null && openThreads > 0 && <Text dimColor>{threadsLabel}</Text>}
401            {current.issues.length > 0 && <Text dimColor>{issueLabel(current)}</Text>}
402          </Box>
403        </Box>
404      )
405    }
406
407    // The band is raised on the terminal and desktop only.
408    if (e.surface !== 'desktop') return next(e)
409
410    // Laid out like the app's own PR row: the PR on the left, the rest on the right, with
411    // counts drawn as Linear's PR view draws them (a colored icon, a muted number). An
412    // invisible region over the PR part makes it one click target and reports the hover,
413    // which lays the theme's subtle background (the one user messages sit on) behind the
414    // title. The counts and the menu sit outside the region, so their tooltips and clicks
415    // reach them.
416    const hovered = await read($, isHovered)
417    const epoch = await read($, menuEpoch)
418    const { Box, Text, Svg, Client, Select } = $.ui.resolve(e)
419    const icon = (name: IconName, hex: string, alt: string, size = 14, gap = 0) => {
420      const { source, width } = iconSvg(name, hex, size, gap)
421      return <Svg source={source} alt={alt} width={width} height={size} />
422    }
423    const tipped = (name: IconName, hex: string, alt: string, tooltip: string, size = 14, gap = 0) => {
424      const { source, width } = tooltipSvg(name, hex, size, gap, tooltip)
425      return <Svg source={source} alt={alt} width={width} height={size} isInteractive />
426    }
427    const ref = `${current.repo ?? ''}#${current.number}`
428    const mono = monoSvg(ref, MUTED, TITLE_GAP)
429    // The title's background is the hover state: room inside it on every side, and a little
430    // room outside it before the icon and the ref.
431    const pad = <Svg source={spacerSvg()} alt="" width={TITLE_PADDING} height={TITLE_HEIGHT} />
432
433    return (
434      <Box key="row" flexDirection="row" alignItems="center" gap={2} width="100%">
435        <Box key="pr" position="relative" flexDirection="row" alignItems="center" flexGrow={1} flexShrink={1}>
436          {icon(state.icon, state.hex, `${state.label} pull request`, 14, TITLE_GAP)}
437          <Box
438            key="title"
439            flexDirection="row"
440            alignItems="center"
441            flexShrink={1}
442            backgroundColor={hovered ? HOVER_BACKGROUND : undefined}
443          >
444            {pad}
445            <Box flexShrink={1}>
446              <Text wrap="truncate-end">{current.title}</Text>
447            </Box>
448            {pad}
449          </Box>
450          <Box key="ref" flexShrink={0}>
451            <Svg source={mono.source} alt={ref} width={mono.width} height={mono.height} />
452          </Box>
453          <Box position="absolute" top={0} left={0} right={0} bottom={0}>
454            <Client key="linear-review-click" module="./click-area.tsx" width="100%" height="100%" />
455          </Box>
456        </Box>
457        <Box key="meta" flexDirection="row" alignItems="center" gap={2} flexShrink={0}>
458          {current.checks !== null && failing > 0 && (
459            <Box key="failing" flexDirection="row" alignItems="center">
460              {tipped('cross', RED, 'Failing checks', checksTooltip(current.checks), 12, 4)}
461              <Text dimColor>{failing}</Text>
462            </Box>
463          )}
464          {openThreads !== null && openThreads > 0 && (
465            <Box key="threads" flexDirection="row" alignItems="center">
466              {tipped('comment', MUTED, threadsLabel, threadsTooltip(current.threads), 14, 4)}
467              <Text dimColor>{openThreads}</Text>
468            </Box>
469          )}
470          <Box key="issue" flexDirection="row" alignItems="center">
471            {tipped('linear', MUTED, 'Linear', issuesTooltip(current.issues), 13, current.issues.length > 0 ? 5 : 0)}
472            {current.issues.length > 0 && <Text dimColor>{issueLabel(current)}</Text>}
473          </Box>
474        </Box>
475        <Select
476          key={`menu-${epoch}`}
477          value={MENU_REST}
478          options={menuOptions(current)}
479          onSelect={(value, picked) => void runMenuAction($, value, picked.surface)}
480        />
481      </Box>
482    )
483  })
484}
485
hooks/icons.ts 16 lines
1// SVG markup for each icon, filled with the color it is drawn in.
2// PR states, the failed-check cross and the comment: GitHub's Octicons 19.38.0
3// (MIT, https://github.com/primer/octicons). The Linear logo: Linear's brand mark.
4
5export const ICONS = {
6  open: { viewBox: '0 0 16 16', body: '<path d="M1.5 3.25a2.25 2.25 0 1 1 3 2.122v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.25 2.25 0 0 1 1.5 3.25Zm5.677-.177L9.573.677A.25.25 0 0 1 10 .854V2.5h1A2.5 2.5 0 0 1 13.5 5v5.628a2.251 2.251 0 1 1-1.5 0V5a1 1 0 0 0-1-1h-1v1.646a.25.25 0 0 1-.427.177L7.177 3.427a.25.25 0 0 1 0-.354ZM3.75 2.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm0 9.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm8.25.75a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Z"/>' },
7  merged: { viewBox: '0 0 16 16', body: '<path d="M5.45 5.154A4.25 4.25 0 0 0 9.25 7.5h1.378a2.251 2.251 0 1 1 0 1.5H9.25A5.734 5.734 0 0 1 5 7.123v3.505a2.25 2.25 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.95-.218ZM4.25 13.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm8.5-4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5ZM5 3.25a.75.75 0 1 0 0 .005V3.25Z"/>' },
8  closed: { viewBox: '0 0 16 16', body: '<path d="M3.25 1A2.25 2.25 0 0 1 4 5.372v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.251 2.251 0 0 1 3.25 1Zm9.5 5.5a.75.75 0 0 1 .75.75v3.378a2.251 2.251 0 1 1-1.5 0V7.25a.75.75 0 0 1 .75-.75Zm-2.03-5.273a.75.75 0 0 1 1.06 0l.97.97.97-.97a.748.748 0 0 1 1.265.332.75.75 0 0 1-.205.729l-.97.97.97.97a.751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018l-.97-.97-.97.97a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734l.97-.97-.97-.97a.75.75 0 0 1 0-1.06ZM2.5 3.25a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0ZM3.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm9.5 0a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z"/>' },
9  draft: { viewBox: '0 0 16 16', body: '<path d="M3.25 1A2.25 2.25 0 0 1 4 5.372v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.251 2.251 0 0 1 3.25 1Zm9.5 14a2.25 2.25 0 1 1 0-4.5 2.25 2.25 0 0 1 0 4.5ZM2.5 3.25a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0ZM3.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm9.5 0a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM14 7.5a1.25 1.25 0 1 1-2.5 0 1.25 1.25 0 0 1 2.5 0Zm0-4.25a1.25 1.25 0 1 1-2.5 0 1.25 1.25 0 0 1 2.5 0Z"/>' },
10  cross: { viewBox: '0 0 16 16', body: '<path d="M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.749.749 0 0 1 1.275.326.749.749 0 0 1-.215.734L9.06 8l3.22 3.22a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L8 9.06l-3.22 3.22a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06Z"/>' },
11  comment: { viewBox: '0 0 16 16', body: '<path d="M1 2.75C1 1.784 1.784 1 2.75 1h10.5c.966 0 1.75.784 1.75 1.75v7.5A1.75 1.75 0 0 1 13.25 12H9.06l-2.573 2.573A1.458 1.458 0 0 1 4 13.543V12H2.75A1.75 1.75 0 0 1 1 10.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h2a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h4.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z"/>' },
12  linear: { viewBox: '0 0 48 48', body: '<path d="M37.3833 39.6083C37.5686 39.7936 37.866 39.8049 38.0605 39.6291C38.3259 39.3892 38.5867 39.1414 38.8425 38.8856C47.0518 30.6763 47.0518 17.3663 38.8425 9.15699C30.6332 0.947669 17.3232 0.947669 9.11388 9.15699C8.85807 9.4128 8.61024 9.67357 8.37037 9.93897C8.19462 10.1334 8.20585 10.4309 8.39119 10.6162L37.3833 39.6083Z"/><path d="M34.6753 42.1232C34.951 41.9601 34.9928 41.5817 34.7663 41.3552L6.64432 13.2332C6.41779 13.0066 6.03942 13.0485 5.87624 13.3242C5.4889 13.9786 5.14011 14.6495 4.82985 15.3336C4.74507 15.5206 4.78727 15.74 4.93243 15.8851L32.1144 43.067C32.2595 43.2122 32.4789 43.2544 32.6659 43.1696C33.35 42.8594 34.0209 42.5106 34.6753 42.1232Z"/><path d="M28.2357 44.6093C28.6164 44.531 28.7471 44.0636 28.4722 43.7887L4.21072 19.5272C3.93591 19.2524 3.4685 19.3831 3.39015 19.7638C3.2071 20.6531 3.08205 21.552 3.01501 22.4544C3.00437 22.5976 3.05768 22.738 3.15924 22.8396L25.1599 44.8402C25.2615 44.9418 25.4018 44.9951 25.5451 44.9845C26.4475 44.9174 27.3464 44.7924 28.2357 44.6093Z"/><path d="M19.2493 44.5067C19.7204 44.6149 20.0112 44.0554 19.6694 43.7136L4.28592 28.3301C3.9441 27.9883 3.38454 28.2791 3.49282 28.7502C4.34654 32.4646 6.22023 35.9919 9.11388 38.8856C12.0075 41.7792 15.5349 43.6529 19.2493 44.5067Z"/>' },
13} as const
14
15export type IconName = keyof typeof ICONS
16
hooks/click-area.tsx 24 lines
1import type { ClientModule } from 'claude-code'
2
3// An invisible region laid over the row. It tells the hooks module when the pointer enters
4// and leaves (the row's hover state), and when a left click is pressed and released inside.
5let isPressed = false
6
7const ClickArea: ClientModule = (_props, surface) => {
8  surface.onPointer(event => {
9    if (event.type === 'enter') surface.post({ type: 'hover', isHovered: true })
10    if (event.type === 'leave') surface.post({ type: 'hover', isHovered: false })
11    if (event.button !== 'left') return
12    if (event.type === 'down') isPressed = true
13    if (event.type !== 'up' || !isPressed) return
14    isPressed = false
15    const isInside = event.x >= 0 && event.y >= 0 && event.x < surface.columns && event.y < surface.rows
16    if (isInside) surface.post({ type: 'open' })
17  })
18
19  const { Text } = surface.elements
20  return <Text> </Text>
21}
22
23export default ClickArea
24
types/index.d.ts 39 lines
1export type Checks = { passed: number; failing: number; pending: number; failingNames: string[] }
2
3export type LinkedIssue = { id: string; url: string; title: string }
4
5// An unresolved review thread: where it is, who opened it, and how it starts.
6export type ReviewThread = { path: string | null; author: string | null; excerpt: string }
7
8export type PullRequest = {
9  number: number
10  title: string
11  state: string
12  isDraft: boolean
13  // the PR on linear.review
14  url: string
15  githubUrl: string
16  branch: string
17  // null when the PR has no checks
18  checks: Checks | null
19  // the repository's name, `app` for acme/app
20  repo: string | null
21  // unresolved review threads; null when GitHub didn't answer
22  openThreads: number | null
23  threads: ReviewThread[]
24  // Linear issues the PR is linked to
25  issues: LinkedIssue[]
26}
27
28declare module 'claude-code' {
29  interface PluginState {
30    'linear-review-link': {
31      pr: PullRequest | null
32      // the pointer is over the desktop row
33      isHovered: boolean
34      // bumped after each menu pick, so the menu draws again resting on "…"
35      menuEpoch: number
36    }
37  }
38}
39