feat: add Linear integration (#1007)
This commit is contained in:
parent
529f49b224
commit
e1270486cd
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
})
|
||||
}
|
||||
|
|
@ -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 }
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -422,6 +422,7 @@ function getDefaultWorktreeMeta(): WorktreeMeta {
|
|||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ describe('OrcaRuntimeRpcServer', () => {
|
|||
comment: '',
|
||||
linkedIssue: 123,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
isArchived: false,
|
||||
isUnread: overrides?.isUnread ?? false,
|
||||
isPinned: false,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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<{
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 (0–4). 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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ function makeWorktree(id: string, repoId = 'repo1'): Worktree {
|
|||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ const worktree: Worktree = {
|
|||
isMainWorktree: false,
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
isArchived: false,
|
||||
comment: '',
|
||||
isUnread: false,
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ function worktrees(...ids: string[]): Record<string, Worktree[]> {
|
|||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ function makeWorktree(overrides: Partial<Worktree> = {}): Worktree {
|
|||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ function makeWorktree(overrides: Partial<Worktree> = {}): Worktree {
|
|||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
})
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ export type UISlice = {
|
|||
taskPageData: {
|
||||
preselectedRepoId?: string
|
||||
prefilledName?: string
|
||||
taskSource?: 'github' | 'linear'
|
||||
}
|
||||
newWorkspaceDraft: {
|
||||
repoId: string | null
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 &
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Reference in New Issue