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.

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:
repo#number… menu: Open in Linear review, Open the issue in Linear, Copy review link, Copy the issue ID, Copy branch name, Open on GitHubClicking 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.
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.
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
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.open <url> when you click the row or a menu item. It opens only linear.review, linear.app and github.com URLs.gh auth status). The mod reads the PR, its checks and its threads through gh.open.claude plugin validate .
claude plugin test .
claude --plugin-dir .
MIT. The icons for PR states, failing checks and comments come from Octicons (MIT); see NOTICE.
hooks/register.tsx 485 lines1import { 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}
485hooks/icons.ts 16 lines1// 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
16hooks/click-area.tsx 24 lines1import 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
24types/index.d.ts 39 lines1export 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