feat: add Linear integration (#1007)

This commit is contained in:
Jinwoo Hong 2026-04-23 17:54:32 -04:00 committed by GitHub
parent 529f49b224
commit e1270486cd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 2006 additions and 34 deletions

View File

@ -44,6 +44,7 @@
"@dnd-kit/utilities": "^3.2.2",
"@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0",
"@linear/sdk": "^82.1.0",
"@monaco-editor/react": "^4.7.0",
"@parcel/watcher": "^2.5.6",
"@tanstack/react-virtual": "^3.13.23",

View File

@ -28,6 +28,9 @@ importers:
'@electron-toolkit/utils':
specifier: ^4.0.0
version: 4.0.0(electron@41.2.2)
'@linear/sdk':
specifier: ^82.1.0
version: 82.1.0(graphql@16.13.1)
'@monaco-editor/react':
specifier: ^4.7.0
version: 4.7.0(monaco-editor@0.55.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@ -868,6 +871,11 @@ packages:
'@floating-ui/utils@0.2.11':
resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==}
'@graphql-typed-document-node/core@3.2.0':
resolution: {integrity: sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==}
peerDependencies:
graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0
'@hono/node-server@1.19.11':
resolution: {integrity: sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==}
engines: {node: '>=18.14.1'}
@ -945,6 +953,10 @@ packages:
'@kwsites/promise-deferred@1.1.1':
resolution: {integrity: sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==}
'@linear/sdk@82.1.0':
resolution: {integrity: sha512-Ok7o+LqXaenx6Um58NQqjQoQanDsCgAIe9yNgpVbqRSh5APz3Ds1kZUz2vWmSNTNATFZm1zDQtEktTMga2X7UQ==}
engines: {node: '>=18.x'}
'@malept/cross-spawn-promise@2.0.0':
resolution: {integrity: sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==}
engines: {node: '>= 12.13.0'}
@ -6753,6 +6765,10 @@ snapshots:
'@floating-ui/utils@0.2.11': {}
'@graphql-typed-document-node/core@3.2.0(graphql@16.13.1)':
dependencies:
graphql: 16.13.1
'@hono/node-server@1.19.11(hono@4.12.8)':
dependencies:
hono: 4.12.8
@ -6833,6 +6849,12 @@ snapshots:
'@kwsites/promise-deferred@1.1.1': {}
'@linear/sdk@82.1.0(graphql@16.13.1)':
dependencies:
'@graphql-typed-document-node/core': 3.2.0(graphql@16.13.1)
transitivePeerDependencies:
- graphql
'@malept/cross-spawn-promise@2.0.0':
dependencies:
cross-spawn: 7.0.6

55
src/main/ipc/linear.ts Normal file
View File

@ -0,0 +1,55 @@
import { ipcMain } from 'electron'
import { connect, disconnect, getStatus } from '../linear/client'
import { _resetPreflightCache } from './preflight'
import { getIssue, searchIssues, listIssues } from '../linear/issues'
import type { LinearListFilter } from '../linear/issues'
const VALID_FILTERS = new Set<LinearListFilter>(['assigned', 'created', 'all', 'completed'])
export function registerLinearHandlers(): void {
ipcMain.handle('linear:connect', async (_event, args: { apiKey: string }) => {
if (typeof args?.apiKey !== 'string' || !args.apiKey.trim()) {
return { ok: false, error: 'Invalid API key' }
}
const result = await connect(args.apiKey.trim())
if (result.ok) {
_resetPreflightCache()
}
return result
})
ipcMain.handle('linear:disconnect', async () => {
disconnect()
_resetPreflightCache()
})
ipcMain.handle('linear:status', async () => {
return getStatus()
})
ipcMain.handle('linear:searchIssues', async (_event, args: { query: string; limit?: number }) => {
if (typeof args?.query !== 'string') {
return []
}
const limit = Math.min(Math.max(1, args.limit ?? 20), 50)
return searchIssues(args.query, limit)
})
ipcMain.handle(
'linear:listIssues',
async (_event, args?: { filter?: LinearListFilter; limit?: number }) => {
const filter = VALID_FILTERS.has(args?.filter as LinearListFilter)
? (args!.filter as LinearListFilter)
: undefined
const limit = Math.min(Math.max(1, args?.limit ?? 20), 50)
return listIssues(filter, limit)
}
)
ipcMain.handle('linear:getIssue', async (_event, args: { id: string }) => {
if (typeof args?.id !== 'string' || !args.id.trim()) {
return null
}
return getIssue(args.id.trim())
})
}

View File

@ -30,6 +30,10 @@ vi.mock('../startup/hydrate-shell-path', () => ({
mergePathSegments: mergePathSegmentsMock
}))
vi.mock('../linear/client', () => ({
loadToken: vi.fn().mockReturnValue(null)
}))
import {
_resetPreflightCache,
detectInstalledAgents,
@ -68,7 +72,8 @@ describe('preflight', () => {
expect(status).toEqual({
git: { installed: true },
gh: { installed: true, authenticated: true }
gh: { installed: true, authenticated: true },
linear: { connected: false }
})
expect(execFileAsyncMock).toHaveBeenNthCalledWith(3, 'gh', ['auth', 'status'], {
encoding: 'utf-8'
@ -126,7 +131,8 @@ describe('preflight', () => {
expect(status).toEqual({
git: { installed: true },
gh: { installed: true, authenticated: true }
gh: { installed: true, authenticated: true },
linear: { connected: false }
})
})
@ -146,11 +152,13 @@ describe('preflight', () => {
expect(firstStatus).toEqual({
git: { installed: true },
gh: { installed: true, authenticated: false }
gh: { installed: true, authenticated: false },
linear: { connected: false }
})
expect(refreshedStatus).toEqual({
git: { installed: true },
gh: { installed: true, authenticated: true }
gh: { installed: true, authenticated: true },
linear: { connected: false }
})
})

View File

@ -4,12 +4,14 @@ import { promisify } from 'util'
import path from 'path'
import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config'
import { hydrateShellPath, mergePathSegments } from '../startup/hydrate-shell-path'
import { loadToken } from '../linear/client'
const execFileAsync = promisify(execFile)
export type PreflightStatus = {
git: { installed: boolean }
gh: { installed: boolean; authenticated: boolean }
linear: { connected: boolean }
}
// Why: cache the result so repeated Landing mounts don't re-spawn processes.
@ -118,9 +120,16 @@ export async function runPreflightCheck(force = false): Promise<PreflightStatus>
const ghAuthenticated = ghInstalled ? await isGhAuthenticated() : false
// Why: the Linear preflight check reads the encrypted token file rather
// than calling the Linear API. Actual API validation happens lazily on
// first use or on linear:connect — this avoids a network round-trip on
// every preflight check.
const linearConnected = loadToken() !== null
cached = {
git: { installed: gitInstalled },
gh: { installed: ghInstalled, authenticated: ghAuthenticated }
gh: { installed: ghInstalled, authenticated: ghAuthenticated },
linear: { connected: linearConnected }
}
return cached

View File

@ -24,6 +24,7 @@ const {
setTrustedBrowserRendererWebContentsIdMock,
registerFilesystemWatcherHandlersMock,
registerAppHandlersMock,
registerLinearHandlersMock,
registerExportHandlersMock
} = vi.hoisted(() => ({
registerCliHandlersMock: vi.fn(),
@ -49,6 +50,7 @@ const {
setTrustedBrowserRendererWebContentsIdMock: vi.fn(),
registerFilesystemWatcherHandlersMock: vi.fn(),
registerAppHandlersMock: vi.fn(),
registerLinearHandlersMock: vi.fn(),
registerExportHandlersMock: vi.fn()
}))
@ -139,6 +141,10 @@ vi.mock('./app', () => ({
registerAppHandlers: registerAppHandlersMock
}))
vi.mock('./linear', () => ({
registerLinearHandlers: registerLinearHandlersMock
}))
import { registerCoreHandlers } from './register-core-handlers'
describe('registerCoreHandlers', () => {
@ -166,6 +172,7 @@ describe('registerCoreHandlers', () => {
setTrustedBrowserRendererWebContentsIdMock.mockReset()
registerFilesystemWatcherHandlersMock.mockReset()
registerAppHandlersMock.mockReset()
registerLinearHandlersMock.mockReset()
registerExportHandlersMock.mockReset()
})
@ -193,6 +200,7 @@ describe('registerCoreHandlers', () => {
expect(registerCodexAccountHandlersMock).toHaveBeenCalledWith(codexAccounts)
expect(registerRateLimitHandlersMock).toHaveBeenCalledWith(rateLimits)
expect(registerGitHubHandlersMock).toHaveBeenCalledWith(store, stats)
expect(registerLinearHandlersMock).toHaveBeenCalled()
expect(registerFeedbackHandlersMock).toHaveBeenCalled()
expect(registerStatsHandlersMock).toHaveBeenCalledWith(stats)
expect(registerNotificationHandlersMock).toHaveBeenCalledWith(store)

View File

@ -9,6 +9,7 @@ import { registerFilesystemWatcherHandlers } from './filesystem-watcher'
import { registerClaudeUsageHandlers } from './claude-usage'
import { registerCodexUsageHandlers } from './codex-usage'
import { registerGitHubHandlers } from './github'
import { registerLinearHandlers } from './linear'
import { registerFeedbackHandlers } from './feedback'
import { registerExportHandlers } from './export'
import { registerStatsHandlers } from './stats'
@ -64,6 +65,7 @@ export function registerCoreHandlers(
registerCodexAccountHandlers(codexAccounts)
registerRateLimitHandlers(rateLimits)
registerGitHubHandlers(store, stats)
registerLinearHandlers()
registerFeedbackHandlers()
registerExportHandlers()
registerStatsHandlers(stats)

View File

@ -175,6 +175,7 @@ describe('mergeWorktree', () => {
comment: 'WIP',
linkedIssue: 42,
linkedPR: 10,
linkedLinearIssue: null,
isArchived: true,
isUnread: true,
isPinned: true,
@ -194,6 +195,7 @@ describe('mergeWorktree', () => {
comment: 'WIP',
linkedIssue: 42,
linkedPR: 10,
linkedLinearIssue: null,
isArchived: true,
isUnread: true,
isPinned: true,

View File

@ -165,6 +165,7 @@ export function mergeWorktree(
comment: meta?.comment || '',
linkedIssue: meta?.linkedIssue ?? null,
linkedPR: meta?.linkedPR ?? null,
linkedLinearIssue: meta?.linkedLinearIssue ?? null,
isArchived: meta?.isArchived ?? false,
isUnread: meta?.isUnread ?? false,
isPinned: meta?.isPinned ?? false,

169
src/main/linear/client.ts Normal file
View File

@ -0,0 +1,169 @@
import { safeStorage } from 'electron'
import { LinearClient, AuthenticationLinearError } from '@linear/sdk'
import { readFileSync, writeFileSync, unlinkSync, mkdirSync, existsSync } from 'fs'
import { join } from 'path'
import { homedir } from 'os'
import type { LinearViewer, LinearConnectionStatus } from '../../shared/types'
// ── Concurrency limiter — max 4 parallel Linear API calls ────────────
const MAX_CONCURRENT = 4
let running = 0
const queue: (() => void)[] = []
export function acquire(): Promise<void> {
if (running < MAX_CONCURRENT) {
running++
return Promise.resolve()
}
return new Promise((resolve) =>
queue.push(() => {
running++
resolve()
})
)
}
export function release(): void {
running--
const next = queue.shift()
if (next) {
next()
}
}
// ── Token storage ────────────────────────────────────────────────────
function getTokenPath(): string {
return join(homedir(), '.orca', 'linear-token.enc')
}
let cachedToken: string | null = null
let cachedViewer: LinearViewer | null = null
export function saveToken(apiKey: string): void {
const dir = join(homedir(), '.orca')
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
}
const tokenPath = getTokenPath()
// Why: safeStorage uses the OS keychain (macOS Keychain, Windows DPAPI,
// Linux libsecret) to encrypt. If the keychain is unavailable (e.g. headless
// Linux without a keyring), fall back to plaintext with a warning — the user
// explicitly chose to store a personal API key on this machine.
if (safeStorage.isEncryptionAvailable()) {
const encrypted = safeStorage.encryptString(apiKey)
writeFileSync(tokenPath, encrypted, { mode: 0o600 })
} else {
console.warn('[linear] safeStorage encryption unavailable — storing token in plaintext')
writeFileSync(tokenPath, apiKey, { encoding: 'utf-8', mode: 0o600 })
}
cachedToken = apiKey
}
export function loadToken(): string | null {
if (cachedToken !== null) {
return cachedToken
}
const tokenPath = getTokenPath()
if (!existsSync(tokenPath)) {
return null
}
try {
const raw = readFileSync(tokenPath)
cachedToken = safeStorage.isEncryptionAvailable()
? safeStorage.decryptString(raw)
: raw.toString('utf-8')
return cachedToken
} catch {
return null
}
}
export function clearToken(): void {
cachedToken = null
cachedViewer = null
const tokenPath = getTokenPath()
try {
unlinkSync(tokenPath)
} catch {
// File may not exist — safe to ignore.
}
}
// ── Client factory ───────────────────────────────────────────────────
export function getClient(): LinearClient | null {
const token = loadToken()
if (!token) {
return null
}
return new LinearClient({ apiKey: token })
}
// ── Auth error detection ─────────────────────────────────────────────
// Why: 401 errors must trigger token clearing and a re-auth prompt in the
// renderer (design §Error Propagation). All other errors are swallowed
// with console.warn to match GitHub client's graceful degradation.
export function isAuthError(error: unknown): boolean {
return error instanceof AuthenticationLinearError
}
// ── Connect / disconnect / status ────────────────────────────────────
export async function connect(
apiKey: string
): Promise<{ ok: true; viewer: LinearViewer } | { ok: false; error: string }> {
try {
const client = new LinearClient({ apiKey })
const me = await client.viewer
const org = await me.organization
const viewer: LinearViewer = {
displayName: me.displayName,
email: me.email ?? null,
organizationName: org.name
}
saveToken(apiKey)
cachedViewer = viewer
return { ok: true, viewer }
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to validate API key'
return { ok: false, error: message }
}
}
export function disconnect(): void {
clearToken()
}
export async function getStatus(): Promise<LinearConnectionStatus> {
const token = loadToken()
if (!token) {
return { connected: false, viewer: null }
}
if (cachedViewer) {
return { connected: true, viewer: cachedViewer }
}
// Lazily fetch viewer info on first status check after app restart.
try {
const client = new LinearClient({ apiKey: token })
const me = await client.viewer
const org = await me.organization
cachedViewer = {
displayName: me.displayName,
email: me.email ?? null,
organizationName: org.name
}
return { connected: true, viewer: cachedViewer }
} catch (error) {
if (isAuthError(error)) {
clearToken()
}
return { connected: false, viewer: null }
}
}
export function initLinearToken(): void {
loadToken()
}

114
src/main/linear/issues.ts Normal file
View File

@ -0,0 +1,114 @@
import type { LinearIssue } from '../../shared/types'
import { acquire, release, getClient, isAuthError, clearToken } from './client'
import { mapLinearIssue } from './mappers'
export async function getIssue(id: string): Promise<LinearIssue | null> {
const client = getClient()
if (!client) {
return null
}
await acquire()
try {
const issue = await client.issue(id)
return await mapLinearIssue(issue)
} catch (error) {
if (isAuthError(error)) {
clearToken()
throw error
}
console.warn('[linear] getIssue failed:', error)
return null
} finally {
release()
}
}
export async function searchIssues(query: string, limit = 20): Promise<LinearIssue[]> {
const client = getClient()
if (!client) {
return []
}
await acquire()
try {
const result = await client.searchIssues(query, { first: limit })
return await Promise.all(result.nodes.map(mapLinearIssue))
} catch (error) {
if (isAuthError(error)) {
clearToken()
throw error
}
console.warn('[linear] searchIssues failed:', error)
return []
} finally {
release()
}
}
export type LinearListFilter = 'assigned' | 'created' | 'all' | 'completed'
const ACTIVE_STATE_FILTER = { state: { type: { nin: ['completed', 'canceled'] } } }
const COMPLETED_STATE_FILTER = { state: { type: { in: ['completed', 'canceled'] } } }
export async function listIssues(
filter: LinearListFilter = 'assigned',
limit = 20
): Promise<LinearIssue[]> {
const client = getClient()
if (!client) {
return []
}
await acquire()
try {
const orderBy = 'updatedAt' as never
if (filter === 'assigned') {
const viewer = await client.viewer
const connection = await viewer.assignedIssues({
first: limit,
orderBy,
filter: ACTIVE_STATE_FILTER
})
return await Promise.all(connection.nodes.map(mapLinearIssue))
}
if (filter === 'created') {
const viewer = await client.viewer
const connection = await viewer.createdIssues({
first: limit,
orderBy,
filter: ACTIVE_STATE_FILTER
})
return await Promise.all(connection.nodes.map(mapLinearIssue))
}
if (filter === 'completed') {
const viewer = await client.viewer
const connection = await viewer.assignedIssues({
first: limit,
orderBy,
filter: COMPLETED_STATE_FILTER
})
return await Promise.all(connection.nodes.map(mapLinearIssue))
}
// 'all' — all active issues across the workspace
const connection = await client.issues({
first: limit,
orderBy,
filter: ACTIVE_STATE_FILTER
})
return await Promise.all(connection.nodes.map(mapLinearIssue))
} catch (error) {
if (isAuthError(error)) {
clearToken()
throw error
}
console.warn('[linear] listIssues failed:', error)
return []
} finally {
release()
}
}

View File

@ -0,0 +1,48 @@
import type { Issue, IssueSearchResult } from '@linear/sdk'
import type { LinearIssue } from '../../shared/types'
// Why: the @linear/sdk uses lazy-loading for related entities — state, team,
// and assignee are fetched on property access and return promises. This mapper
// awaits them all so callers receive a plain serializable object safe for IPC
// transfer. Labels use the labels() method on Issue but IssueSearchResult only
// has labelIds (string UUIDs), so we conditionally resolve label names.
export async function mapLinearIssue(issue: Issue | IssueSearchResult): Promise<LinearIssue> {
const [state, team, assignee] = await Promise.all([issue.state, issue.team, issue.assignee])
// Why: IssueSearchResult does not expose the labels() relation method — only
// the raw labelIds array. For Issue instances we resolve actual label names;
// for search results we fall back to empty (label names are a nice-to-have
// in the UI, not critical for identification).
let labelNames: string[] = []
if ('labels' in issue && typeof issue.labels === 'function') {
try {
const labelsConnection = await (issue as Issue).labels()
labelNames = labelsConnection.nodes.map((l) => l.name)
} catch {
// Swallow — labels are non-critical display data.
}
}
return {
id: issue.id,
identifier: issue.identifier,
title: issue.title,
description: issue.description ?? undefined,
url: issue.url,
state: {
name: state?.name ?? '',
type: state?.type ?? '',
color: state?.color ?? ''
},
team: {
name: team?.name ?? '',
key: team?.key ?? ''
},
labels: labelNames,
assignee: assignee
? { displayName: assignee.displayName, avatarUrl: assignee.avatarUrl ?? undefined }
: undefined,
priority: issue.priority,
updatedAt: issue.updatedAt.toISOString()
}
}

View File

@ -422,6 +422,7 @@ function getDefaultWorktreeMeta(): WorktreeMeta {
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,

View File

@ -111,6 +111,7 @@ const store = {
comment: '',
linkedIssue: 123,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
@ -873,6 +874,7 @@ describe('OrcaRuntimeService', () => {
comment: meta.comment ?? existingMeta?.comment ?? '',
linkedIssue: meta.linkedIssue ?? existingMeta?.linkedIssue ?? null,
linkedPR: meta.linkedPR ?? existingMeta?.linkedPR ?? null,
linkedLinearIssue: meta.linkedLinearIssue ?? existingMeta?.linkedLinearIssue ?? null,
isArchived: meta.isArchived ?? existingMeta?.isArchived ?? false,
isUnread: meta.isUnread ?? existingMeta?.isUnread ?? false,
isPinned: meta.isPinned ?? existingMeta?.isPinned ?? false,

View File

@ -73,6 +73,7 @@ describe('OrcaRuntimeRpcServer', () => {
comment: '',
linkedIssue: 123,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: overrides?.isUnread ?? false,
isPinned: false,

View File

@ -21,6 +21,9 @@ import type {
GitHubWorkItemDetails,
GitHubViewer,
IssueInfo,
LinearViewer,
LinearConnectionStatus,
LinearIssue,
NotificationDispatchRequest,
NotificationDispatchResult,
OpenCodeStatusEvent,
@ -163,6 +166,7 @@ export type DetectedBrowserInfo = {
export type PreflightStatus = {
git: { installed: boolean }
gh: { installed: boolean; authenticated: boolean }
linear: { connected: boolean }
}
export type RefreshAgentsResult = {
@ -409,6 +413,19 @@ export type PreloadApi = {
checkOrcaStarred: () => Promise<boolean | null>
starOrca: () => Promise<boolean>
}
linear: {
connect: (args: {
apiKey: string
}) => Promise<{ ok: true; viewer: LinearViewer } | { ok: false; error: string }>
disconnect: () => Promise<void>
status: () => Promise<LinearConnectionStatus>
searchIssues: (args: { query: string; limit?: number }) => Promise<LinearIssue[]>
listIssues: (args?: {
filter?: 'assigned' | 'created' | 'all' | 'completed'
limit?: number
}) => Promise<LinearIssue[]>
getIssue: (args: { id: string }) => Promise<LinearIssue | null>
}
starNag: {
onShow: (callback: () => void) => () => void
dismiss: () => Promise<void>

View File

@ -425,6 +425,28 @@ const api = {
starOrca: (): Promise<boolean> => ipcRenderer.invoke('gh:starOrca')
},
linear: {
connect: (args: {
apiKey: string
}): Promise<{ ok: true; viewer: unknown } | { ok: false; error: string }> =>
ipcRenderer.invoke('linear:connect', args),
disconnect: (): Promise<void> => ipcRenderer.invoke('linear:disconnect'),
status: (): Promise<unknown> => ipcRenderer.invoke('linear:status'),
searchIssues: (args: { query: string; limit?: number }): Promise<unknown[]> =>
ipcRenderer.invoke('linear:searchIssues', args),
listIssues: (args?: {
filter?: 'assigned' | 'created' | 'all' | 'completed'
limit?: number
}): Promise<unknown[]> => ipcRenderer.invoke('linear:listIssues', args),
getIssue: (args: { id: string }): Promise<unknown> =>
ipcRenderer.invoke('linear:getIssue', args)
},
starNag: {
onShow: (callback: () => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent): void => callback()
@ -468,6 +490,7 @@ const api = {
}): Promise<{
git: { installed: boolean }
gh: { installed: boolean; authenticated: boolean }
linear: { connected: boolean }
}> => ipcRenderer.invoke('preflight:check', args),
detectAgents: (): Promise<string[]> => ipcRenderer.invoke('preflight:detectAgents'),
refreshAgents: (): Promise<{

View File

@ -0,0 +1,258 @@
import React, { useEffect, useRef, useState } from 'react'
import { ArrowRight, ExternalLink, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Sheet, SheetContent, SheetDescription, SheetTitle } from '@/components/ui/sheet'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { VisuallyHidden } from 'radix-ui'
import CommentMarkdown from '@/components/sidebar/CommentMarkdown'
import { cn } from '@/lib/utils'
import type { LinearIssue } from '../../../shared/types'
function LinearIcon({ className }: { className?: string }): React.JSX.Element {
return (
<svg viewBox="0 0 24 24" aria-hidden className={className} fill="currentColor">
<path d="M2.886 4.18A11.982 11.982 0 0 1 11.99 0C18.624 0 24 5.376 24 12.009c0 3.64-1.62 6.903-4.18 9.105L2.887 4.18ZM1.817 5.626l16.556 16.556c-.524.33-1.075.62-1.65.866L.951 7.277c.247-.575.537-1.126.866-1.65ZM.322 9.163l14.515 14.515c-.71.172-1.443.282-2.195.322L0 11.358a12 12 0 0 1 .322-2.195Zm-.17 4.862 9.823 9.824a12.02 12.02 0 0 1-9.824-9.824Z" />
</svg>
)
}
const PRIORITY_LABELS: Record<number, string> = {
0: 'No priority',
1: 'Urgent',
2: 'High',
3: 'Medium',
4: 'Low'
}
function formatRelativeTime(input: string): string {
const date = new Date(input)
if (Number.isNaN(date.getTime())) {
return 'recently'
}
const diffMs = date.getTime() - Date.now()
const diffMinutes = Math.round(diffMs / 60_000)
const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
if (Math.abs(diffMinutes) < 60) {
return formatter.format(diffMinutes, 'minute')
}
const diffHours = Math.round(diffMinutes / 60)
if (Math.abs(diffHours) < 24) {
return formatter.format(diffHours, 'hour')
}
const diffDays = Math.round(diffHours / 24)
return formatter.format(diffDays, 'day')
}
function getStateTone(stateType: string): string {
switch (stateType) {
case 'completed':
return 'border-purple-500/30 bg-purple-500/10 text-purple-600 dark:text-purple-300'
case 'canceled':
case 'cancelled':
return 'border-slate-500/30 bg-slate-500/10 text-slate-600 dark:text-slate-300'
case 'started':
case 'unstarted':
return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300'
case 'backlog':
return 'border-slate-500/30 bg-slate-500/10 text-slate-600 dark:text-slate-300'
default:
return 'border-border/50 bg-muted/30 text-muted-foreground'
}
}
type LinearItemDrawerProps = {
issue: LinearIssue | null
onUse: (issue: LinearIssue) => void
onClose: () => void
}
export default function LinearItemDrawer({
issue,
onUse,
onClose
}: LinearItemDrawerProps): React.JSX.Element {
const [fullIssue, setFullIssue] = useState<LinearIssue | null>(null)
const requestIdRef = useRef(0)
// Why: the list view may not include the full description. Re-fetch
// the issue by ID to get the complete body for the drawer.
useEffect(() => {
if (!issue) {
setFullIssue(null)
return
}
requestIdRef.current += 1
const requestId = requestIdRef.current
setFullIssue(issue)
window.api.linear
.getIssue({ id: issue.id })
.then((result) => {
if (requestId !== requestIdRef.current) {
return
}
if (result) {
setFullIssue(result as LinearIssue)
}
})
.catch(() => {})
}, [issue])
// Why: same pointer-events fix as GitHubItemDrawer — Radix may leave
// pointer-events: none on body when overlays transition.
useEffect(() => {
if (!issue) {
return
}
let cancelled = false
let count = 0
const tick = (): void => {
if (cancelled) {
return
}
if (document.body.style.pointerEvents === 'none') {
document.body.style.pointerEvents = ''
}
if (count++ < 5) {
requestAnimationFrame(tick)
}
}
tick()
return () => {
cancelled = true
}
}, [issue])
const displayed = fullIssue ?? issue
return (
<Sheet open={issue !== null} onOpenChange={(open) => !open && onClose()}>
<SheetContent
side="right"
showCloseButton={false}
className="w-full p-0 sm:max-w-[640px]"
onOpenAutoFocus={(event) => {
event.preventDefault()
}}
>
<VisuallyHidden.Root asChild>
<SheetTitle>{displayed?.title ?? 'Linear issue'}</SheetTitle>
</VisuallyHidden.Root>
<VisuallyHidden.Root asChild>
<SheetDescription>Read-only preview of the selected Linear issue.</SheetDescription>
</VisuallyHidden.Root>
{displayed && (
<div className="flex h-full min-h-0 flex-col">
{/* Header */}
<div className="flex-none border-b border-border/60 px-4 py-3">
<div className="flex items-start gap-2">
<LinearIcon className="mt-1 size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span
className={cn(
'rounded-full border px-2 py-0.5 text-[11px] font-medium',
getStateTone(displayed.state.type)
)}
>
{displayed.state.name}
</span>
<span className="font-mono text-[12px] text-muted-foreground">
{displayed.identifier}
</span>
</div>
<h2 className="mt-1 text-[15px] font-semibold leading-tight text-foreground">
{displayed.title}
</h2>
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-muted-foreground">
{displayed.assignee && <span>{displayed.assignee.displayName}</span>}
<span>· {displayed.team.name}</span>
<span>· {formatRelativeTime(displayed.updatedAt)}</span>
{displayed.priority > 0 && (
<span>
· {PRIORITY_LABELS[displayed.priority] ?? `P${displayed.priority}`}
</span>
)}
</div>
{displayed.labels.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{displayed.labels.map((label) => (
<span
key={label}
className="rounded-full border border-border/50 bg-background/60 px-2 py-0.5 text-[10px] text-muted-foreground"
>
{label}
</span>
))}
</div>
)}
</div>
<div className="flex shrink-0 items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={() => window.api.shell.openUrl(displayed.url)}
aria-label="Open on Linear"
>
<ExternalLink className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
Open on Linear
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={onClose}
aria-label="Close preview"
>
<X className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
Close · Esc
</TooltipContent>
</Tooltip>
</div>
</div>
</div>
{/* Body */}
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-sleek">
<div className="px-4 py-4">
{displayed.description?.trim() ? (
<CommentMarkdown
content={displayed.description}
className="text-[14px] leading-relaxed"
/>
) : (
<span className="italic text-muted-foreground">No description provided.</span>
)}
</div>
</div>
{/* Footer */}
<div className="flex-none border-t border-border/60 bg-background/40 px-4 py-3">
<Button
onClick={() => onUse(displayed)}
className="w-full justify-center gap-2"
aria-label="Start workspace from issue"
>
Start workspace from issue
<ArrowRight className="size-4" />
</Button>
</div>
</div>
)}
</SheetContent>
</Sheet>
)
}

View File

@ -11,6 +11,7 @@ import {
Github,
GitPullRequest,
LoaderCircle,
Lock,
Plus,
RefreshCw,
Search,
@ -40,12 +41,13 @@ import RepoMultiCombobox from '@/components/ui/repo-multi-combobox'
import RepoDotLabel from '@/components/repo/RepoDotLabel'
import { stripRepoQualifiers } from '../../../shared/task-query'
import GitHubItemDrawer from '@/components/GitHubItemDrawer'
import LinearItemDrawer from '@/components/LinearItemDrawer'
import { cn } from '@/lib/utils'
import { getLinkedWorkItemSuggestedName, getTaskPresetQuery } from '@/lib/new-workspace'
import type { LinkedWorkItemSummary } from '@/lib/new-workspace'
import { launchWorkItemDirect } from '@/lib/launch-work-item-direct'
import { isGitRepoKind } from '../../../shared/repo-kind'
import type { GitHubWorkItem, TaskViewPresetId } from '../../../shared/types'
import type { GitHubWorkItem, LinearIssue, TaskViewPresetId } from '../../../shared/types'
import { shouldSuppressEnterSubmit } from '@/lib/new-workspace-enter-guard'
type TaskSource = 'github' | 'linear'
@ -92,6 +94,16 @@ const TASK_QUERY_PRESETS: TaskQueryPreset[] = [
{ id: 'my-prs', label: 'My PRs', query: getTaskPresetQuery('my-prs') }
]
type LinearPresetId = 'assigned' | 'created' | 'all' | 'completed'
type LinearPreset = { id: LinearPresetId; label: string }
const LINEAR_PRESETS: LinearPreset[] = [
{ id: 'all', label: 'All' },
{ id: 'assigned', label: 'My Issues' },
{ id: 'created', label: 'Created' },
{ id: 'completed', label: 'Completed' }
]
const TASK_SEARCH_DEBOUNCE_MS = 300
const WORK_ITEM_LIMIT = 36
@ -142,6 +154,20 @@ function getTaskStatusTone(item: GitHubWorkItem): string {
return 'border-cyan-500/30 bg-cyan-500/10 text-cyan-700 dark:text-cyan-200'
}
// Why: Linear encodes priority as an integer (04). Map to human-readable
// labels so the table column is scannable without memorising the scale.
const LINEAR_PRIORITY_LABELS: Record<number, string> = {
0: 'None',
1: 'Urgent',
2: 'High',
3: 'Medium',
4: 'Low'
}
function getLinearPriorityLabel(priority: number): string {
return LINEAR_PRIORITY_LABELS[priority] ?? 'None'
}
export default function TaskPage(): React.JSX.Element {
const settings = useAppStore((s) => s.settings)
const pageData = useAppStore((s) => s.taskPageData)
@ -153,6 +179,12 @@ export default function TaskPage(): React.JSX.Element {
const updateSettings = useAppStore((s) => s.updateSettings)
const fetchWorkItemsAcrossRepos = useAppStore((s) => s.fetchWorkItemsAcrossRepos)
const getCachedWorkItems = useAppStore((s) => s.getCachedWorkItems)
const linearStatus = useAppStore((s) => s.linearStatus)
const linearStatusChecked = useAppStore((s) => s.linearStatusChecked)
const connectLinear = useAppStore((s) => s.connectLinear)
const searchLinearIssues = useAppStore((s) => s.searchLinearIssues)
const listLinearIssues = useAppStore((s) => s.listLinearIssues)
const checkLinearConnection = useAppStore((s) => s.checkLinearConnection)
// Why: in workspace view (a worktree is active) App.tsx hides its
// full-width titlebar, so this page renders its own 42px titlebar strip to
// keep the top band continuous with the sidebar header and tab rows. When
@ -248,7 +280,17 @@ export default function TaskPage(): React.JSX.Element {
const defaultTaskViewPreset = settings?.defaultTaskViewPreset ?? 'all'
const initialTaskQuery = getTaskPresetQuery(defaultTaskViewPreset)
const [taskSource, setTaskSource] = useState<TaskSource>('github')
const [taskSource, setTaskSource] = useState<TaskSource>(pageData.taskSource ?? 'github')
// Why: pageData.taskSource changes when the user clicks a specific source
// icon in the sidebar while the task page is already open. useState only
// initializes once, so sync from the store when the value changes.
useEffect(() => {
if (pageData.taskSource) {
setTaskSource(pageData.taskSource)
}
}, [pageData.taskSource])
const [taskSearchInput, setTaskSearchInput] = useState(initialTaskQuery)
const [appliedTaskSearch, setAppliedTaskSearch] = useState(initialTaskQuery)
const [activeTaskPreset, setActiveTaskPreset] = useState<TaskViewPresetId | null>(
@ -294,6 +336,22 @@ export default function TaskPage(): React.JSX.Element {
const [newIssueBody, setNewIssueBody] = useState('')
const [newIssueSubmitting, setNewIssueSubmitting] = useState(false)
const [drawerLinearIssue, setDrawerLinearIssue] = useState<LinearIssue | null>(null)
// Linear tab state
const [linearIssues, setLinearIssues] = useState<LinearIssue[]>([])
const [linearLoading, setLinearLoading] = useState(false)
const [linearError, setLinearError] = useState<string | null>(null)
const [linearSearchInput, setLinearSearchInput] = useState('')
const [activeLinearPreset, setActiveLinearPreset] = useState<LinearPresetId>('all')
const [linearRefreshNonce, setLinearRefreshNonce] = useState(0)
const [linearConnectOpen, setLinearConnectOpen] = useState(false)
const [linearApiKeyDraft, setLinearApiKeyDraft] = useState('')
const [linearConnectState, setLinearConnectState] = useState<'idle' | 'connecting' | 'error'>(
'idle'
)
const [linearConnectError, setLinearConnectError] = useState<string | null>(null)
const filteredWorkItems = useMemo(() => {
if (!activeTaskPreset) {
return workItems
@ -553,7 +611,7 @@ export default function TaskPage(): React.JSX.Element {
useEffect(() => {
// Why: when a modal is open, let it own Esc dismissal.
if (drawerWorkItem || newIssueOpen || activeModal !== 'none') {
if (drawerWorkItem || drawerLinearIssue || newIssueOpen || activeModal !== 'none') {
return
}
@ -587,7 +645,146 @@ export default function TaskPage(): React.JSX.Element {
window.addEventListener('keydown', onKeyDown, { capture: true })
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
}, [activeModal, closeTaskPage, drawerWorkItem, newIssueOpen])
}, [activeModal, closeTaskPage, drawerLinearIssue, drawerWorkItem, newIssueOpen])
// Why: check Linear connection status on mount so the UI can show the
// correct connected/disconnected state without requiring a settings visit.
useEffect(() => {
void checkLinearConnection()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Why: debounce the Linear search input so we don't fire a request on every
// keystroke — matches the 300ms cadence used for GitHub search.
const [appliedLinearSearch, setAppliedLinearSearch] = useState('')
useEffect(() => {
const timeout = window.setTimeout(() => {
setAppliedLinearSearch(linearSearchInput)
}, TASK_SEARCH_DEBOUNCE_MS)
return () => window.clearTimeout(timeout)
}, [linearSearchInput])
// Why: fetch Linear issues when the tab is active and the account is
// connected. An empty search falls back to `listLinearIssues` (assigned
// issues) so the default view shows the user's own work.
useEffect(() => {
if (taskSource !== 'linear') {
return
}
if (!linearStatus.connected) {
return
}
let cancelled = false
setLinearLoading(true)
setLinearError(null)
const trimmed = appliedLinearSearch.trim()
const request =
trimmed.length > 0
? searchLinearIssues(trimmed, WORK_ITEM_LIMIT)
: listLinearIssues(activeLinearPreset, WORK_ITEM_LIMIT)
void request
.then((issues) => {
if (cancelled) {
return
}
setLinearIssues(issues)
setLinearLoading(false)
})
.catch((err) => {
if (cancelled) {
return
}
setLinearError(err instanceof Error ? err.message : 'Failed to load Linear issues.')
setLinearLoading(false)
})
return () => {
cancelled = true
}
// Why: searchLinearIssues and listLinearIssues are stable zustand selectors;
// depending on them would re-run the effect on unrelated store updates.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
taskSource,
linearStatus.connected,
appliedLinearSearch,
activeLinearPreset,
linearRefreshNonce
])
// Why: for Linear issues the "Use" flow opens the composer with the issue
// info adapted to the LinkedWorkItemSummary shape. Linear identifiers are
// strings (e.g. "ENG-123") so we use 0 as a placeholder number since the
// URL is the primary artifact the agent will act on.
const openComposerForLinearItem = useCallback(
(issue: LinearIssue): void => {
const linkedWorkItem: LinkedWorkItemSummary = {
type: 'issue',
number: 0,
title: issue.title,
url: issue.url
}
openModal('new-workspace-composer', {
linkedWorkItem,
prefilledName: getLinkedWorkItemSuggestedName(issue)
})
},
[openModal]
)
const handleUseLinearItem = useCallback(
(issue: LinearIssue): void => {
const repoId = primaryRepo?.id
if (!repoId) {
openComposerForLinearItem(issue)
return
}
// Why: unlike GitHub issues (fetchable via `gh`), Linear has no CLI —
// paste the full issue context so the agent can act on it without needing
// to fetch anything externally.
const parts = [
`[${issue.identifier}] ${issue.title}`,
`Status: ${issue.state.name} · Team: ${issue.team.name}`,
issue.assignee ? `Assignee: ${issue.assignee.displayName}` : null,
issue.labels.length > 0 ? `Labels: ${issue.labels.join(', ')}` : null,
`URL: ${issue.url}`,
issue.description ? `\n${issue.description}` : null
]
const pasteContent = parts.filter(Boolean).join('\n')
void launchWorkItemDirect({
item: { title: issue.title, url: issue.url, type: 'issue', number: null, pasteContent },
repoId,
openModalFallback: () => openComposerForLinearItem(issue)
})
},
[openComposerForLinearItem, primaryRepo?.id]
)
const handleLinearConnect = useCallback(async (): Promise<void> => {
const key = linearApiKeyDraft.trim()
if (!key) {
return
}
setLinearConnectState('connecting')
setLinearConnectError(null)
try {
const result = await connectLinear(key)
if (result.ok) {
setLinearApiKeyDraft('')
setLinearConnectState('idle')
setLinearConnectOpen(false)
} else {
setLinearConnectState('error')
setLinearConnectError(result.error)
}
} catch (error) {
setLinearConnectState('error')
setLinearConnectError(error instanceof Error ? error.message : 'Connection failed')
}
}, [connectLinear, linearApiKeyDraft])
return (
<div className="relative flex h-full min-h-0 flex-1 overflow-hidden bg-background text-foreground">
@ -700,7 +897,9 @@ export default function TaskPage(): React.JSX.Element {
)
})}
</div>
<div className="w-[200px]">
{/* Why: Linear issues are not repo-scoped, so the repo
selector is only relevant for the GitHub tab. */}
<div className={cn('w-[200px]', taskSource !== 'github' && 'invisible')}>
<RepoMultiCombobox
repos={eligibleRepos}
selected={repoSelection}
@ -726,7 +925,7 @@ export default function TaskPage(): React.JSX.Element {
</div>
</div>
{taskSource === 'github' && (
{taskSource === 'github' ? (
<div className="rounded-md rounded-b-none border border-border/50 bg-muted/50 p-3 shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-2">
@ -836,7 +1035,98 @@ export default function TaskPage(): React.JSX.Element {
</div>
</div>
</div>
)}
) : linearStatus.connected ? (
<div className="rounded-md rounded-b-none border border-border/50 bg-muted/50 p-3 shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-2">
{LINEAR_PRESETS.map((preset) => {
const active = !linearSearchInput && activeLinearPreset === preset.id
return (
<button
key={preset.id}
type="button"
onClick={() => {
setLinearSearchInput('')
setAppliedLinearSearch('')
setActiveLinearPreset(preset.id)
setLinearRefreshNonce((n) => n + 1)
}}
className={cn(
'rounded-md border px-2 py-1 text-xs transition',
active
? 'border-border/50 bg-foreground/90 text-background backdrop-blur-md'
: 'border-border/50 bg-transparent text-foreground hover:bg-muted/50'
)}
>
{preset.label}
</button>
)
})}
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => setLinearRefreshNonce((n) => n + 1)}
disabled={linearLoading}
aria-label="Refresh Linear issues"
className="border-border/50 bg-transparent hover:bg-muted/50 backdrop-blur-md supports-[backdrop-filter]:bg-transparent"
>
{linearLoading ? (
<LoaderCircle className="size-4 animate-spin" />
) : (
<RefreshCw className="size-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
Refresh Linear issues
</TooltipContent>
</Tooltip>
</div>
<div className="mt-3 flex items-center gap-3">
<div className="relative min-w-[320px] flex-1">
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={linearSearchInput}
onChange={(e) => setLinearSearchInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
if (
shouldSuppressEnterSubmit(
{ isComposing: e.nativeEvent.isComposing, shiftKey: e.shiftKey },
false
)
) {
return
}
e.preventDefault()
setAppliedLinearSearch(linearSearchInput.trim())
setLinearRefreshNonce((n) => n + 1)
}
}}
placeholder="Search Linear issues..."
className="h-8 rounded-md border-border/50 bg-background pl-8 pr-8 text-xs"
/>
{linearSearchInput ? (
<button
type="button"
aria-label="Clear search"
onClick={() => {
setLinearSearchInput('')
setAppliedLinearSearch('')
setLinearRefreshNonce((n) => n + 1)
}}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground transition hover:text-foreground"
>
<X className="size-4" />
</button>
) : null}
</div>
</div>
</div>
) : null}
</div>
</section>
</div>
@ -1052,9 +1342,206 @@ export default function TaskPage(): React.JSX.Element {
</div>
</div>
</div>
) : !linearStatusChecked ? (
<div className="mt-4 flex items-center justify-center py-14">
<LoaderCircle className="size-5 animate-spin text-muted-foreground" />
</div>
) : !linearStatus.connected ? (
<div className="mt-4 flex flex-col items-center justify-center rounded-md border border-border/50 bg-muted/50 px-6 py-14 text-center shadow-sm">
<LinearIcon className="mb-4 size-8 text-muted-foreground/60" />
<p className="text-base font-medium text-foreground">Connect your Linear account</p>
<p className="mt-2 max-w-sm text-sm text-muted-foreground">
Browse and start work on your assigned Linear issues directly from here.
</p>
<Button
className="mt-5"
onClick={() => {
setLinearApiKeyDraft('')
setLinearConnectState('idle')
setLinearConnectError(null)
setLinearConnectOpen(true)
}}
>
Connect Linear
</Button>
</div>
) : (
<div className="mt-4 px-1 py-6">
<p className="text-sm text-muted-foreground">Coming soon</p>
/* Connected state: Linear issues table */
<div className="flex min-h-0 max-h-full flex-col rounded-md border border-t-0 border-border/50 bg-muted/50 overflow-hidden rounded-t-none shadow-sm">
<div className="flex-none grid grid-cols-[90px_minmax(0,3fr)_100px_120px_80px_90px_80px] gap-3 border-b border-border/50 px-3 py-2 text-[10px] font-medium uppercase tracking-[0.16em] text-muted-foreground">
<span>Identifier</span>
<span>Title</span>
<span>Team</span>
<span>Status</span>
<span>Priority</span>
<span>Updated</span>
<span />
</div>
<div
className="min-h-0 flex-initial overflow-y-auto scrollbar-sleek"
style={{ scrollbarGutter: 'stable' }}
>
{linearError ? (
<div className="border-b border-border px-4 py-4 text-sm text-destructive">
{linearError}
</div>
) : null}
{linearLoading && linearIssues.length === 0 ? (
// Why: shimmer skeleton matches the GitHub tab pattern — 3 placeholder
// rows while the initial fetch is in flight so the card never flashes empty.
<div className="divide-y divide-border/50">
{Array.from({ length: 3 }).map((_, i) => (
<div
key={i}
className="grid w-full gap-2 px-3 py-2 grid-cols-[90px_minmax(0,3fr)_100px_120px_80px_90px_80px]"
>
<div className="flex items-center">
<div className="h-7 w-16 animate-pulse rounded-lg bg-muted/70" />
</div>
<div className="min-w-0">
<div className="h-4 w-3/5 animate-pulse rounded bg-muted/70" />
<div className="mt-2 h-3 w-2/5 animate-pulse rounded bg-muted/60" />
</div>
<div className="flex items-center">
<div className="h-3 w-16 animate-pulse rounded bg-muted/60" />
</div>
<div className="flex items-center">
<div className="h-5 w-16 animate-pulse rounded-full bg-muted/70" />
</div>
<div className="flex items-center">
<div className="h-3 w-12 animate-pulse rounded bg-muted/60" />
</div>
<div className="flex items-center">
<div className="h-3 w-16 animate-pulse rounded bg-muted/60" />
</div>
<div className="flex items-center justify-start lg:justify-end">
<div className="h-7 w-16 animate-pulse rounded-xl bg-muted/70" />
</div>
</div>
))}
</div>
) : null}
{!linearLoading && linearIssues.length === 0 && !linearError ? (
<div className="px-4 py-10 text-center">
<p className="text-base font-medium text-foreground">No Linear issues found</p>
<p className="mt-2 text-sm text-muted-foreground">
{linearSearchInput
? 'Try a different search query.'
: 'No assigned issues. Try searching for something.'}
</p>
</div>
) : null}
<div className="divide-y divide-border/50">
{linearIssues.map((issue) => (
<div
key={issue.id}
role="button"
tabIndex={0}
onClick={() => setDrawerLinearIssue(issue)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setDrawerLinearIssue(issue)
}
}}
className="cursor-pointer grid w-full gap-2 px-3 py-2 text-left transition hover:bg-muted/40 grid-cols-[90px_minmax(0,3fr)_100px_120px_80px_90px_80px]"
>
<div className="flex items-center">
<span className="inline-flex items-center gap-1 rounded-md border border-border/50 bg-muted/40 px-1.5 py-0.5 text-muted-foreground">
<span className="font-mono text-[11px] font-normal">
{issue.identifier}
</span>
</span>
</div>
<div className="min-w-0">
<h3 className="truncate text-[15px] font-semibold text-foreground">
{issue.title}
</h3>
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-muted-foreground">
{issue.assignee ? <span>{issue.assignee.displayName}</span> : null}
{issue.labels.slice(0, 3).map((label) => (
<span
key={label}
className="rounded-full border border-border/50 bg-background/80 px-1.5 py-0 text-[10px] text-muted-foreground"
>
{label}
</span>
))}
</div>
</div>
<div className="min-w-0 flex items-center text-xs text-muted-foreground">
<span className="truncate">{issue.team.name}</span>
</div>
<div className="flex items-center gap-1.5">
{/* Why: render the status dot using the color Linear
provides per-state so users recognise their workflow
colours without a separate legend. */}
<span
className="inline-block size-2 shrink-0 rounded-full"
style={{ backgroundColor: issue.state.color }}
/>
<span className="truncate text-xs text-muted-foreground">
{issue.state.name}
</span>
</div>
<div className="flex items-center text-xs text-muted-foreground">
{getLinearPriorityLabel(issue.priority)}
</div>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center text-[11px] text-muted-foreground">
{formatRelativeTime(issue.updatedAt)}
</div>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{new Date(issue.updatedAt).toLocaleString()}
</TooltipContent>
</Tooltip>
<div className="flex items-center justify-start gap-1 lg:justify-end">
<button
type="button"
onClick={(e) => {
e.stopPropagation()
handleUseLinearItem(issue)
}}
className="inline-flex items-center gap-1 rounded-md border border-border/50 bg-background/80 px-2 py-1 text-[11px] text-foreground transition hover:bg-muted/60"
>
Use
<ArrowRight className="size-3" />
</button>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<button
type="button"
onClick={(e) => e.stopPropagation()}
className="rounded-lg p-1.5 text-muted-foreground transition hover:bg-muted/60 hover:text-foreground"
aria-label="More actions"
>
<EllipsisVertical className="size-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => window.open(issue.url, '_blank')}>
<ExternalLink className="size-4" />
Open in browser
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
))}
</div>
</div>
</div>
)}
</div>
@ -1157,6 +1644,101 @@ export default function TaskPage(): React.JSX.Element {
}}
onClose={() => setDrawerWorkItem(null)}
/>
<LinearItemDrawer
issue={drawerLinearIssue}
onUse={(issue) => {
setDrawerLinearIssue(null)
handleUseLinearItem(issue)
}}
onClose={() => setDrawerLinearIssue(null)}
/>
<Dialog
open={linearConnectOpen}
onOpenChange={(open) => {
if (linearConnectState !== 'connecting') {
setLinearConnectOpen(open)
}
}}
>
<DialogContent
className="sm:max-w-md"
onKeyDown={(e) => {
if (
e.key === 'Enter' &&
linearApiKeyDraft.trim() &&
linearConnectState !== 'connecting'
) {
e.preventDefault()
void handleLinearConnect()
}
}}
>
<DialogHeader>
<DialogTitle>Connect Linear</DialogTitle>
<DialogDescription>
Paste a Personal API key to browse your assigned issues.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3">
<Input
autoFocus
type="password"
placeholder="lin_api_..."
value={linearApiKeyDraft}
onChange={(e) => {
setLinearApiKeyDraft(e.target.value)
if (linearConnectState === 'error') {
setLinearConnectState('idle')
setLinearConnectError(null)
}
}}
disabled={linearConnectState === 'connecting'}
/>
{linearConnectState === 'error' && linearConnectError && (
<p className="text-xs text-destructive">{linearConnectError}</p>
)}
<p className="text-xs text-muted-foreground">
Create a key at{' '}
<button
className="text-primary underline-offset-2 hover:underline"
onClick={() =>
window.api.shell.openUrl('https://linear.app/settings/account/security')
}
>
Linear Settings Security
</button>
</p>
<p className="flex items-center gap-1.5 text-[11px] text-muted-foreground/70">
<Lock className="size-3 shrink-0" />
Your key is encrypted via the OS keychain and stored locally.
</p>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setLinearConnectOpen(false)}
disabled={linearConnectState === 'connecting'}
>
Cancel
</Button>
<Button
onClick={() => void handleLinearConnect()}
disabled={!linearApiKeyDraft.trim() || linearConnectState === 'connecting'}
>
{linearConnectState === 'connecting' ? (
<>
<LoaderCircle className="size-4 animate-spin" />
Verifying
</>
) : (
'Connect'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@ -0,0 +1,309 @@
import { useEffect, useState } from 'react'
import { Github, ExternalLink, Link, LoaderCircle, Lock, Terminal } from 'lucide-react'
import { useAppStore } from '../../store'
import { Button } from '../ui/button'
import { Input } from '../ui/input'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '../ui/dialog'
import type { SettingsSearchEntry } from './settings-search'
function LinearIcon({ className }: { className?: string }): React.JSX.Element {
return (
<svg viewBox="0 0 24 24" aria-hidden className={className} fill="currentColor">
<path d="M2.886 4.18A11.982 11.982 0 0 1 11.99 0C18.624 0 24 5.376 24 12.009c0 3.64-1.62 6.903-4.18 9.105L2.887 4.18ZM1.817 5.626l16.556 16.556c-.524.33-1.075.62-1.65.866L.951 7.277c.247-.575.537-1.126.866-1.65ZM.322 9.163l14.515 14.515c-.71.172-1.443.282-2.195.322L0 11.358a12 12 0 0 1 .322-2.195Zm-.17 4.862 9.823 9.824a12.02 12.02 0 0 1-9.824-9.824Z" />
</svg>
)
}
export const INTEGRATIONS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
{
title: 'GitHub Integration',
description: 'GitHub authentication via the gh CLI.',
keywords: ['github', 'gh', 'integration']
},
{
title: 'Linear Integration',
description: 'Connect Linear to browse and link issues.',
keywords: ['linear', 'integration', 'api key', 'connect', 'disconnect']
}
]
type GhStatus = 'checking' | 'connected' | 'not-installed' | 'not-authenticated'
export function IntegrationsPane(): React.JSX.Element {
const linearStatus = useAppStore((s) => s.linearStatus)
const connectLinear = useAppStore((s) => s.connectLinear)
const disconnectLinear = useAppStore((s) => s.disconnectLinear)
const checkLinearConnection = useAppStore((s) => s.checkLinearConnection)
const [ghStatus, setGhStatus] = useState<GhStatus>('checking')
const [linearDialogOpen, setLinearDialogOpen] = useState(false)
const [linearApiKeyDraft, setLinearApiKeyDraft] = useState('')
const [linearConnectState, setLinearConnectState] = useState<'idle' | 'connecting' | 'error'>(
'idle'
)
const [linearConnectError, setLinearConnectError] = useState<string | null>(null)
useEffect(() => {
void checkLinearConnection()
void window.api.preflight.check().then((status) => {
if (!status.gh.installed) {
setGhStatus('not-installed')
} else if (!status.gh.authenticated) {
setGhStatus('not-authenticated')
} else {
setGhStatus('connected')
}
})
// eslint-disable-next-line react-hooks/exhaustive-deps -- one-shot mount check
}, [])
const handleLinearConnect = async (): Promise<void> => {
if (!linearApiKeyDraft.trim()) {
return
}
setLinearConnectState('connecting')
setLinearConnectError(null)
try {
const result = await connectLinear(linearApiKeyDraft.trim())
if (result.ok) {
setLinearApiKeyDraft('')
setLinearConnectState('idle')
setLinearDialogOpen(false)
} else {
setLinearConnectState('error')
setLinearConnectError(result.error)
}
} catch (error) {
setLinearConnectState('error')
setLinearConnectError(error instanceof Error ? error.message : 'Connection failed')
}
}
const handleLinearDisconnect = async (): Promise<void> => {
await disconnectLinear()
setLinearConnectState('idle')
setLinearConnectError(null)
}
const handleRefreshGh = (): void => {
setGhStatus('checking')
void window.api.preflight.check({ force: true }).then((status) => {
if (!status.gh.installed) {
setGhStatus('not-installed')
} else if (!status.gh.authenticated) {
setGhStatus('not-authenticated')
} else {
setGhStatus('connected')
}
})
}
return (
<div className="space-y-3">
{/* GitHub */}
<div className="rounded-md border border-border/50 bg-muted/30 px-4 py-3">
<div className="flex items-center gap-3">
<Github className="size-5 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1 space-y-0.5">
<p className="text-sm font-medium">GitHub</p>
<p className="text-xs text-muted-foreground">
Pull requests, issues, and checks via the{' '}
<span className="font-mono text-[11px]">gh</span> CLI.
</p>
</div>
{ghStatus === 'checking' ? (
<LoaderCircle className="size-4 shrink-0 animate-spin text-muted-foreground" />
) : ghStatus === 'connected' ? (
<span className="shrink-0 rounded-full border border-emerald-500/30 bg-emerald-500/10 px-2.5 py-1 text-[11px] font-medium text-emerald-700 dark:text-emerald-300">
Connected
</span>
) : (
<span className="shrink-0 rounded-full border border-amber-500/30 bg-amber-500/10 px-2.5 py-1 text-[11px] font-medium text-amber-700 dark:text-amber-300">
{ghStatus === 'not-installed' ? 'Not installed' : 'Not authenticated'}
</span>
)}
</div>
{ghStatus !== 'checking' && ghStatus !== 'connected' && (
<div className="mt-3 rounded-md border border-border/30 bg-background/50 px-3 py-2.5 space-y-2">
{ghStatus === 'not-installed' ? (
<>
<p className="text-xs text-muted-foreground">
Install the GitHub CLI to enable pull requests, issues, and checks.
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => window.api.shell.openUrl('https://cli.github.com')}
>
<ExternalLink className="size-3.5 mr-1.5" />
Install GitHub CLI
</Button>
<Button variant="ghost" size="sm" onClick={handleRefreshGh}>
Re-check
</Button>
</div>
</>
) : (
<>
<p className="text-xs text-muted-foreground">
The GitHub CLI is installed but not authenticated. Run this command in a terminal:
</p>
<div className="flex items-center gap-2 rounded-md bg-muted/50 px-2.5 py-1.5 font-mono text-xs">
<Terminal className="size-3.5 shrink-0 text-muted-foreground" />
gh auth login
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() =>
window.api.shell.openUrl('https://cli.github.com/manual/gh_auth_login')
}
>
<ExternalLink className="size-3.5 mr-1.5" />
Learn more
</Button>
<Button variant="ghost" size="sm" onClick={handleRefreshGh}>
Re-check
</Button>
</div>
</>
)}
</div>
)}
</div>
{/* Linear */}
<div className="rounded-md border border-border/50 bg-muted/30 px-4 py-3">
<div className="flex items-center gap-3">
<LinearIcon className="size-5 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1 space-y-0.5">
<p className="text-sm font-medium">Linear</p>
<p className="text-xs text-muted-foreground">
{linearStatus.connected
? `${linearStatus.viewer?.organizationName ?? ''} · ${linearStatus.viewer?.displayName ?? ''}${linearStatus.viewer?.email ? ` · ${linearStatus.viewer.email}` : ''}`
: 'Browse and link issues to workspaces.'}
</p>
</div>
{linearStatus.connected ? (
<div className="flex shrink-0 items-center gap-1.5">
<button
onClick={handleLinearDisconnect}
aria-label="Disconnect Linear"
className="rounded-md p-1 text-muted-foreground/50 transition-colors hover:text-destructive"
>
<Link className="size-3.5" />
</button>
<span className="rounded-full border border-emerald-500/30 bg-emerald-500/10 px-2.5 py-1 text-[11px] font-medium text-emerald-700 dark:text-emerald-300">
Connected
</span>
</div>
) : (
<button
className="shrink-0 rounded-full border border-border/50 bg-muted/40 px-2.5 py-1 text-[11px] font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
onClick={() => setLinearDialogOpen(true)}
>
Connect
</button>
)}
</div>
</div>
{/* Linear Connect Dialog */}
<Dialog
open={linearDialogOpen}
onOpenChange={(open) => {
if (linearConnectState !== 'connecting') {
setLinearDialogOpen(open)
}
}}
>
<DialogContent
className="sm:max-w-md"
onKeyDown={(e) => {
if (
e.key === 'Enter' &&
linearApiKeyDraft.trim() &&
linearConnectState !== 'connecting'
) {
e.preventDefault()
void handleLinearConnect()
}
}}
>
<DialogHeader>
<DialogTitle>Connect Linear</DialogTitle>
<DialogDescription>
Paste a Personal API key to browse your assigned issues.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3">
<Input
autoFocus
type="password"
placeholder="lin_api_..."
value={linearApiKeyDraft}
onChange={(e) => {
setLinearApiKeyDraft(e.target.value)
if (linearConnectState === 'error') {
setLinearConnectState('idle')
setLinearConnectError(null)
}
}}
disabled={linearConnectState === 'connecting'}
/>
{linearConnectState === 'error' && linearConnectError && (
<p className="text-xs text-destructive">{linearConnectError}</p>
)}
<p className="text-xs text-muted-foreground">
Create a key at{' '}
<button
className="text-primary underline-offset-2 hover:underline"
onClick={() =>
window.api.shell.openUrl('https://linear.app/settings/account/security')
}
>
Linear Settings Security
</button>
</p>
<p className="flex items-center gap-1.5 text-[11px] text-muted-foreground/70">
<Lock className="size-3 shrink-0" />
Your key is encrypted via the OS keychain and stored locally.
</p>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setLinearDialogOpen(false)}
disabled={linearConnectState === 'connecting'}
>
Cancel
</Button>
<Button
onClick={() => void handleLinearConnect()}
disabled={!linearApiKeyDraft.trim() || linearConnectState === 'connecting'}
>
{linearConnectState === 'connecting' ? (
<>
<LoaderCircle className="size-4 animate-spin" />
Verifying
</>
) : (
'Connect'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@ -11,6 +11,7 @@ import {
Palette,
Server,
SlidersHorizontal,
Blocks,
SquareTerminal
} from 'lucide-react'
import type { OrcaHooks } from '../../../../shared/types'
@ -32,12 +33,14 @@ import { SshPane, SSH_PANE_SEARCH_ENTRIES } from './SshPane'
import { ExperimentalPane, EXPERIMENTAL_PANE_SEARCH_ENTRIES } from './ExperimentalPane'
import { AgentsPane, AGENTS_PANE_SEARCH_ENTRIES } from './AgentsPane'
import { StatsPane, STATS_PANE_SEARCH_ENTRIES } from '../stats/StatsPane'
import { IntegrationsPane, INTEGRATIONS_PANE_SEARCH_ENTRIES } from './IntegrationsPane'
import { SettingsSidebar } from './SettingsSidebar'
import { SettingsSection } from './SettingsSection'
import { matchesSettingsSearch, type SettingsSearchEntry } from './settings-search'
type SettingsNavTarget =
| 'general'
| 'integrations'
| 'browser'
| 'git'
| 'appearance'
@ -330,6 +333,13 @@ function Settings(): React.JSX.Element {
icon: Keyboard,
searchEntries: SHORTCUTS_PANE_SEARCH_ENTRIES
},
{
id: 'integrations',
title: 'Integrations',
description: 'GitHub, Linear, and other service connections.',
icon: Blocks,
searchEntries: INTEGRATIONS_PANE_SEARCH_ENTRIES
},
{
id: 'stats',
title: 'Stats & Usage',
@ -536,6 +546,15 @@ function Settings(): React.JSX.Element {
<GeneralPane settings={settings} updateSettings={updateSettings} />
</SettingsSection>
<SettingsSection
id="integrations"
title="Integrations"
description="GitHub, Linear, and other service connections."
searchEntries={INTEGRATIONS_PANE_SEARCH_ENTRIES}
>
<IntegrationsPane />
</SettingsSection>
<SettingsSection
id="agents"
title="Agents"

View File

@ -74,9 +74,35 @@ const SidebarNav = React.memo(function SidebarNav() {
>
<ListChecks className="size-4 shrink-0" />
<span className="flex-1">Tasks</span>
<span className="flex items-center gap-1 text-muted-foreground/70">
<Github className="size-3.5" aria-hidden />
<LinearIcon className="size-3.5" />
<span className="flex items-center gap-1">
<span
role="button"
tabIndex={-1}
onClick={(e) => {
e.stopPropagation()
if (!canBrowseTasks) {
return
}
openTaskPage({ taskSource: 'github' })
}}
className="rounded p-0.5 text-muted-foreground/70 transition-colors hover:text-foreground"
>
<Github className="size-3.5" aria-hidden />
</span>
<span
role="button"
tabIndex={-1}
onClick={(e) => {
e.stopPropagation()
if (!canBrowseTasks) {
return
}
openTaskPage({ taskSource: 'linear' })
}}
className="rounded p-0.5 text-muted-foreground/70 transition-colors hover:text-foreground"
>
<LinearIcon className="size-3.5" />
</span>
</span>
</button>
</div>

View File

@ -29,6 +29,7 @@ function makeWorktree(overrides: Partial<Worktree> = {}): Worktree {
isMainWorktree: overrides.isMainWorktree ?? false,
linkedIssue: overrides.linkedIssue ?? null,
linkedPR: overrides.linkedPR ?? null,
linkedLinearIssue: null,
isArchived: overrides.isArchived ?? false,
comment: overrides.comment ?? '',
isUnread: overrides.isUnread ?? false,

View File

@ -15,6 +15,7 @@ function makeWorktree(id: string, repoId = 'repo1'): Worktree {
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,

View File

@ -20,6 +20,7 @@ const worktree: Worktree = {
isMainWorktree: false,
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
comment: '',
isUnread: false,

View File

@ -32,6 +32,7 @@ function worktrees(...ids: string[]): Record<string, Worktree[]> {
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,

View File

@ -19,6 +19,7 @@ function makeWorktree(overrides: Partial<Worktree> = {}): Worktree {
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,

View File

@ -10,13 +10,17 @@ import {
getSetupConfig,
getWorkspaceSeedName
} from '@/lib/new-workspace'
import type {
GitHubWorkItem,
OrcaHooks,
RepoHookSettings,
SetupDecision,
TuiAgent
} from '../../../shared/types'
import type { OrcaHooks, RepoHookSettings, SetupDecision, TuiAgent } from '../../../shared/types'
export type LaunchableWorkItem = {
title: string
url: string
type: 'issue' | 'pr'
number: number | null
repoId?: string
/** Content to paste into the agent's input. Defaults to the URL when omitted. */
pasteContent?: string
}
// Why: bracketed paste markers let modern TUIs treat the inserted text as a
// single atomic paste — Claude Code / Codex / Gemini put it in their input
@ -27,7 +31,7 @@ const BRACKETED_PASTE_BEGIN = '\x1b[200~'
const BRACKETED_PASTE_END = '\x1b[201~'
export type LaunchWorkItemDirectArgs = {
item: GitHubWorkItem
item: LaunchableWorkItem
repoId: string
/** Called when the flow cannot proceed without user input (setup policy is
* `ask`, or the selected repo cannot resolve). Callers wire this to the
@ -117,8 +121,8 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom
const workspaceName = getWorkspaceSeedName({
explicitName: getLinkedWorkItemSuggestedName(item),
prompt: '',
linkedIssueNumber: item.type === 'issue' ? item.number : null,
linkedPR: item.type === 'pr' ? item.number : null
linkedIssueNumber: item.type === 'issue' ? (item.number ?? null) : null,
linkedPR: item.type === 'pr' ? (item.number ?? null) : null
})
// Why: launch the agent with no prompt so the first frame it draws is the
@ -164,9 +168,9 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom
}
const meta: { linkedIssue?: number; linkedPR?: number } = {}
if (item.type === 'issue') {
if (item.type === 'issue' && item.number) {
meta.linkedIssue = item.number
} else {
} else if (item.type === 'pr' && item.number) {
meta.linkedPR = item.number
}
try {
@ -204,10 +208,14 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom
return
}
// Why: some TUIs buffer input while they paint their first frame even after
// the foreground/title signal flips ready. One extra tick lets the input box
// render before we shove bytes into the PTY.
await new Promise((resolve) => window.setTimeout(resolve, 120))
// Why: TUIs must enable bracketed paste mode (\x1b[?2004h) before they can
// interpret our paste markers. `title-idle` means the TUI has fully rendered
// its input box and enabled paste mode; weaker signals (`foreground-match`,
// `child-process`) only confirm the binary is running — the TUI's input
// setup may still be in-flight, especially on slow shell environments.
const graceMs = readyResult.reason === 'title-idle' ? 150 : 600
await new Promise((resolve) => window.setTimeout(resolve, graceMs))
window.api.pty.write(ptyId, `${BRACKETED_PASTE_BEGIN}${item.url}${BRACKETED_PASTE_END}`)
const content = item.pasteContent ?? item.url
window.api.pty.write(ptyId, `${BRACKETED_PASTE_BEGIN}${content}${BRACKETED_PASTE_END}`)
}

View File

@ -15,6 +15,7 @@ function makeWorktree(overrides: Partial<Worktree> = {}): Worktree {
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,

View File

@ -7,6 +7,7 @@ import { createTabsSlice } from './slices/tabs'
import { createUISlice } from './slices/ui'
import { createSettingsSlice } from './slices/settings'
import { createGitHubSlice } from './slices/github'
import { createLinearSlice } from './slices/linear'
import { createEditorSlice } from './slices/editor'
import { createStatsSlice } from './slices/stats'
import { createClaudeUsageSlice } from './slices/claude-usage'
@ -28,6 +29,7 @@ export const useAppStore = create<AppState>()((...a) => ({
...createUISlice(...a),
...createSettingsSlice(...a),
...createGitHubSlice(...a),
...createLinearSlice(...a),
...createEditorSlice(...a),
...createStatsSlice(...a),
...createClaudeUsageSlice(...a),

View File

@ -0,0 +1,226 @@
import type { StateCreator } from 'zustand'
import type { AppState } from '../types'
import type { LinearViewer, LinearConnectionStatus, LinearIssue } from '../../../../shared/types'
import type { CacheEntry } from './github'
const CACHE_TTL = 60_000 // 60s — same as GitHub work-items TTL
const MAX_CACHE_ENTRIES = 500
function isFresh<T>(entry: CacheEntry<T> | undefined): entry is CacheEntry<T> {
return entry !== undefined && Date.now() - entry.fetchedAt < CACHE_TTL
}
function evictStaleEntries<T>(
cache: Record<string, CacheEntry<T>>,
maxEntries = MAX_CACHE_ENTRIES
): Record<string, CacheEntry<T>> {
const keys = Object.keys(cache)
if (keys.length <= maxEntries) {
return cache
}
const sorted = keys.sort((a, b) => (cache[a]?.fetchedAt ?? 0) - (cache[b]?.fetchedAt ?? 0))
const pruned: Record<string, CacheEntry<T>> = {}
for (const key of sorted.slice(sorted.length - maxEntries)) {
pruned[key] = cache[key]
}
return pruned
}
function looksLikeAuthError(error: unknown): boolean {
const msg = error instanceof Error ? error.message : String(error)
return /authenticat|unauthorized|401/i.test(msg)
}
const inflightIssueRequests = new Map<string, Promise<LinearIssue | null>>()
const inflightSearchRequests = new Map<string, Promise<LinearIssue[]>>()
const inflightListRequests = new Map<string, Promise<LinearIssue[]>>()
export type LinearSlice = {
linearStatus: LinearConnectionStatus
linearStatusChecked: boolean
linearIssueCache: Record<string, CacheEntry<LinearIssue>>
linearSearchCache: Record<string, CacheEntry<LinearIssue[]>>
checkLinearConnection: () => Promise<void>
connectLinear: (
apiKey: string
) => Promise<{ ok: true; viewer: LinearViewer } | { ok: false; error: string }>
disconnectLinear: () => Promise<void>
fetchLinearIssue: (id: string) => Promise<LinearIssue | null>
searchLinearIssues: (query: string, limit?: number) => Promise<LinearIssue[]>
listLinearIssues: (
filter?: 'assigned' | 'created' | 'all' | 'completed',
limit?: number
) => Promise<LinearIssue[]>
}
export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (set, get) => ({
linearStatus: { connected: false, viewer: null },
linearStatusChecked: false,
linearIssueCache: {},
linearSearchCache: {},
checkLinearConnection: async () => {
try {
const status = (await window.api.linear.status()) as LinearConnectionStatus
const prev = get().linearStatus
if (prev.connected !== status.connected || prev.viewer?.email !== status.viewer?.email) {
set({ linearStatus: status, linearStatusChecked: true })
} else if (!get().linearStatusChecked) {
set({ linearStatusChecked: true })
}
} catch {
if (get().linearStatus.connected) {
set({ linearStatus: { connected: false, viewer: null }, linearStatusChecked: true })
} else if (!get().linearStatusChecked) {
set({ linearStatusChecked: true })
}
}
},
connectLinear: async (apiKey: string) => {
try {
const result = await window.api.linear.connect({ apiKey })
if (result.ok) {
set({
linearStatus: {
connected: true,
viewer: result.viewer as LinearViewer
}
})
}
return result as { ok: true; viewer: LinearViewer } | { ok: false; error: string }
} catch (error) {
const message = error instanceof Error ? error.message : 'Connection failed'
return { ok: false as const, error: message }
}
},
disconnectLinear: async () => {
await window.api.linear.disconnect()
inflightIssueRequests.clear()
inflightSearchRequests.clear()
inflightListRequests.clear()
set({
linearStatus: { connected: false, viewer: null },
linearIssueCache: {},
linearSearchCache: {}
})
},
fetchLinearIssue: async (id: string) => {
const cached = get().linearIssueCache[id]
if (isFresh(cached)) {
return cached.data
}
const inflight = inflightIssueRequests.get(id)
if (inflight) {
return inflight
}
const promise = window.api.linear
.getIssue({ id })
.then((issue) => {
const data = issue as LinearIssue | null
set((s) => ({
linearIssueCache: evictStaleEntries({
...s.linearIssueCache,
[id]: { data, fetchedAt: Date.now() }
})
}))
return data
})
.catch((error) => {
console.warn('[linear] fetchLinearIssue failed:', error)
if (looksLikeAuthError(error)) {
set({ linearStatus: { connected: false, viewer: null } })
}
return null
})
.finally(() => {
inflightIssueRequests.delete(id)
})
inflightIssueRequests.set(id, promise)
return promise
},
searchLinearIssues: async (query: string, limit = 20) => {
const cacheKey = `${query}::${limit}`
const cached = get().linearSearchCache[cacheKey]
if (isFresh(cached)) {
return cached.data ?? []
}
const inflight = inflightSearchRequests.get(cacheKey)
if (inflight) {
return inflight
}
const promise = window.api.linear
.searchIssues({ query, limit })
.then((issues) => {
const data = issues as LinearIssue[]
set((s) => ({
linearSearchCache: evictStaleEntries({
...s.linearSearchCache,
[cacheKey]: { data, fetchedAt: Date.now() }
})
}))
return data
})
.catch((error) => {
console.warn('[linear] searchLinearIssues failed:', error)
if (looksLikeAuthError(error)) {
set({ linearStatus: { connected: false, viewer: null } })
}
return []
})
.finally(() => {
inflightSearchRequests.delete(cacheKey)
})
inflightSearchRequests.set(cacheKey, promise)
return promise
},
listLinearIssues: async (filter = 'assigned', limit = 20) => {
const cacheKey = `list::${filter}::${limit}`
const cached = get().linearSearchCache[cacheKey]
if (isFresh(cached)) {
return cached.data ?? []
}
const inflight = inflightListRequests.get(cacheKey)
if (inflight) {
return inflight
}
const promise = window.api.linear
.listIssues({ filter, limit })
.then((issues) => {
const data = issues as LinearIssue[]
set((s) => ({
linearSearchCache: evictStaleEntries({
...s.linearSearchCache,
[cacheKey]: { data, fetchedAt: Date.now() }
})
}))
return data
})
.catch((error) => {
console.warn('[linear] listLinearIssues failed:', error)
if (looksLikeAuthError(error)) {
set({ linearStatus: { connected: false, viewer: null } })
}
return []
})
.finally(() => {
inflightListRequests.delete(cacheKey)
})
inflightListRequests.set(cacheKey, promise)
return promise
}
})

View File

@ -91,6 +91,7 @@ import { createTabsSlice } from './tabs'
import { createUISlice } from './ui'
import { createSettingsSlice } from './settings'
import { createGitHubSlice } from './github'
import { createLinearSlice } from './linear'
import { createEditorSlice } from './editor'
import { createStatsSlice } from './stats'
import { createClaudeUsageSlice } from './claude-usage'
@ -111,6 +112,7 @@ function createTestStore() {
...createUISlice(...a),
...createSettingsSlice(...a),
...createGitHubSlice(...a),
...createLinearSlice(...a),
...createEditorSlice(...a),
...createStatsSlice(...a),
...createClaudeUsageSlice(...a),
@ -137,6 +139,7 @@ function makeWorktree(overrides: Partial<Worktree> & { id: string; repoId: strin
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,

View File

@ -15,6 +15,7 @@ import { createTabsSlice } from './tabs'
import { createUISlice } from './ui'
import { createSettingsSlice } from './settings'
import { createGitHubSlice } from './github'
import { createLinearSlice } from './linear'
import { createEditorSlice } from './editor'
import { createStatsSlice } from './stats'
import { createClaudeUsageSlice } from './claude-usage'
@ -43,6 +44,7 @@ export function createTestStore() {
...createUISlice(...a),
...createSettingsSlice(...a),
...createGitHubSlice(...a),
...createLinearSlice(...a),
...createEditorSlice(...a),
...createStatsSlice(...a),
...createClaudeUsageSlice(...a),
@ -82,6 +84,7 @@ export function makeWorktree(
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,

View File

@ -86,6 +86,7 @@ import { createTabsSlice } from './tabs'
import { createUISlice } from './ui'
import { createSettingsSlice } from './settings'
import { createGitHubSlice } from './github'
import { createLinearSlice } from './linear'
import { createEditorSlice } from './editor'
import { createStatsSlice } from './stats'
import { createClaudeUsageSlice } from './claude-usage'
@ -108,6 +109,7 @@ function createTestStore() {
...createUISlice(...a),
...createSettingsSlice(...a),
...createGitHubSlice(...a),
...createLinearSlice(...a),
...createEditorSlice(...a),
...createStatsSlice(...a),
...createClaudeUsageSlice(...a),
@ -765,6 +767,7 @@ describe('TabsSlice', () => {
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
@ -857,6 +860,7 @@ describe('TabsSlice', () => {
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
@ -929,6 +933,7 @@ describe('TabsSlice', () => {
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,

View File

@ -59,6 +59,7 @@ export type UISlice = {
taskPageData: {
preselectedRepoId?: string
prefilledName?: string
taskSource?: 'github' | 'linear'
}
newWorkspaceDraft: {
repoId: string | null

View File

@ -73,6 +73,7 @@ function makeWorktree(overrides: Partial<Worktree> & { id: string; repoId: strin
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,

View File

@ -5,6 +5,7 @@ import type { TabsSlice } from './slices/tabs'
import type { UISlice } from './slices/ui'
import type { SettingsSlice } from './slices/settings'
import type { GitHubSlice } from './slices/github'
import type { LinearSlice } from './slices/linear'
import type { EditorSlice } from './slices/editor'
import type { StatsSlice } from './slices/stats'
import type { ClaudeUsageSlice } from './slices/claude-usage'
@ -23,6 +24,7 @@ export type AppState = RepoSlice &
UISlice &
SettingsSlice &
GitHubSlice &
LinearSlice &
EditorSlice &
StatsSlice &
ClaudeUsageSlice &

View File

@ -40,6 +40,7 @@ export type Worktree = {
comment: string
linkedIssue: number | null
linkedPR: number | null
linkedLinearIssue: string | null
isArchived: boolean
isUnread: boolean
isPinned: boolean
@ -54,6 +55,7 @@ export type WorktreeMeta = {
comment: string
linkedIssue: number | null
linkedPR: number | null
linkedLinearIssue: string | null
isArchived: boolean
isUnread: boolean
isPinned: boolean
@ -436,6 +438,42 @@ export type GitHubWorkItemDetails = {
files?: GitHubPRFile[]
}
// ─── Linear ─────────────────────────────────────────────────────────
export type LinearViewer = {
displayName: string
email: string | null
organizationName: string
}
export type LinearConnectionStatus = {
connected: boolean
viewer: LinearViewer | null
}
export type LinearIssue = {
id: string
identifier: string
title: string
description?: string
url: string
state: {
name: string
type: string
color: string
}
team: {
name: string
key: string
}
labels: string[]
assignee?: {
displayName: string
avatarUrl?: string
}
priority: number
updatedAt: string
}
// ─── Hooks (orca.yaml) ──────────────────────────────────────────────
export type OrcaHooks = {
scripts: {