feat(issues): make issues page writable with inline editing (#1017)

This commit is contained in:
Jinwoo Hong 2026-04-24 13:41:21 -04:00 committed by GitHub
parent 1772087db5
commit faed904aae
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 2756 additions and 132 deletions

View File

@ -13,7 +13,15 @@ import { sortWorkItemsByUpdatedAt } from '../../shared/work-items'
import { getPRConflictSummary } from './conflict-summary'
import { execFileAsync, ghExecFileAsync, acquire, release, getOwnerRepo } from './gh-utils'
export { _resetOwnerRepoCache } from './gh-utils'
export { getIssue, listIssues, createIssue } from './issues'
export {
getIssue,
listIssues,
createIssue,
updateIssue,
addIssueComment,
listLabels,
listAssignableUsers
} from './issues'
import {
mapCheckRunRESTStatus,
mapCheckRunRESTConclusion,

View File

@ -1,6 +1,7 @@
import { execFile } from 'child_process'
import { promisify } from 'util'
import { gitExecFileAsync, ghExecFileAsync } from '../git/runner'
import type { ClassifiedError } from '../../shared/types'
// Why: legacy generic execFile wrapper — only used by callers that don't need
// WSL-aware routing (e.g. non-repo-scoped gh commands). Repo-scoped callers
@ -34,6 +35,35 @@ export function release(): void {
}
}
// ── Error classification ─────────────────────────────────────────────
// Why: gh CLI surfaces API errors as unstructured stderr. This helper maps
// known patterns to typed errors so callers can show user-friendly messages.
export function classifyGhError(stderr: string): ClassifiedError {
const s = stderr.toLowerCase()
if (s.includes('http 403') || s.includes('resource not accessible')) {
return {
type: 'permission_denied',
message: "You don't have permission to edit this issue. Check your GitHub token scopes."
}
}
if (s.includes('http 404') || s.includes('could not resolve')) {
return { type: 'not_found', message: 'Issue not found — it may have been deleted.' }
}
if (s.includes('http 422') || s.includes('validation failed')) {
return { type: 'validation_error', message: `Invalid update — ${stderr.trim()}` }
}
if (s.includes('rate limit')) {
return {
type: 'rate_limited',
message: 'GitHub rate limit hit. Try again in a few minutes.'
}
}
if (s.includes('timeout') || s.includes('no such host') || s.includes('network')) {
return { type: 'network_error', message: 'Network error — check your connection.' }
}
return { type: 'unknown', message: `Failed to update issue: ${stderr.trim()}` }
}
// ── Owner/repo resolution for gh api --cache ──────────────────────────
const ownerRepoCache = new Map<string, { owner: string; repo: string } | null>()

View File

@ -1,6 +1,6 @@
import type { IssueInfo } from '../../shared/types'
import type { IssueInfo, GitHubIssueUpdate } from '../../shared/types'
import { mapIssueInfo } from './mappers'
import { ghExecFileAsync, acquire, release, getOwnerRepo } from './gh-utils'
import { ghExecFileAsync, acquire, release, getOwnerRepo, classifyGhError } from './gh-utils'
/**
* Get a single issue by number.
@ -98,9 +98,9 @@ export async function createIssue(
'-X',
'POST',
`repos/${ownerRepo.owner}/${ownerRepo.repo}/issues`,
'-f',
'--raw-field',
`title=${trimmedTitle}`,
'-f',
'--raw-field',
`body=${body}`
],
{ cwd: repoPath }
@ -121,3 +121,170 @@ export async function createIssue(
release()
}
}
/**
* Update an existing GitHub issue. Fans out to separate gh commands for
* state changes vs field edits since `gh issue edit` does not support state.
*/
export async function updateIssue(
repoPath: string,
issueNumber: number,
updates: GitHubIssueUpdate
): Promise<{ ok: true } | { ok: false; error: string }> {
const ownerRepo = await getOwnerRepo(repoPath)
if (!ownerRepo) {
return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' }
}
const repo = `${ownerRepo.owner}/${ownerRepo.repo}`
const errors: string[] = []
// State change requires a separate command
if (updates.state) {
await acquire()
try {
const cmd = updates.state === 'closed' ? 'close' : 'reopen'
await ghExecFileAsync(['issue', cmd, String(issueNumber), '--repo', repo], {
cwd: repoPath
})
} catch (err) {
const stderr = err instanceof Error ? err.message : String(err)
// Treat "already closed/open" as a no-op
if (!stderr.toLowerCase().includes('already')) {
errors.push(classifyGhError(stderr).message)
}
} finally {
release()
}
}
// Field edits (labels, assignees, title) via gh issue edit
const editArgs: string[] = ['issue', 'edit', String(issueNumber), '--repo', repo]
let hasEditArgs = false
if (updates.title) {
editArgs.push('--title', updates.title)
hasEditArgs = true
}
for (const label of updates.addLabels ?? []) {
editArgs.push('--add-label', label)
hasEditArgs = true
}
for (const label of updates.removeLabels ?? []) {
editArgs.push('--remove-label', label)
hasEditArgs = true
}
for (const assignee of updates.addAssignees ?? []) {
editArgs.push('--add-assignee', assignee)
hasEditArgs = true
}
for (const assignee of updates.removeAssignees ?? []) {
editArgs.push('--remove-assignee', assignee)
hasEditArgs = true
}
if (hasEditArgs) {
await acquire()
try {
await ghExecFileAsync(editArgs, { cwd: repoPath })
} catch (err) {
const stderr = err instanceof Error ? err.message : String(err)
errors.push(classifyGhError(stderr).message)
} finally {
release()
}
}
if (errors.length > 0) {
return { ok: false, error: errors.join('; ') }
}
return { ok: true }
}
export async function addIssueComment(
repoPath: string,
issueNumber: number,
body: string
): Promise<{ ok: true; id: number } | { ok: false; error: string }> {
const ownerRepo = await getOwnerRepo(repoPath)
if (!ownerRepo) {
return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' }
}
await acquire()
try {
const { stdout } = await ghExecFileAsync(
[
'api',
'-X',
'POST',
`repos/${ownerRepo.owner}/${ownerRepo.repo}/issues/${issueNumber}/comments`,
'--raw-field',
`body=${body}`
],
{ cwd: repoPath }
)
const data = JSON.parse(stdout) as { id?: number }
return { ok: true, id: data.id ?? 0 }
} catch (err) {
const stderr = err instanceof Error ? err.message : String(err)
return { ok: false, error: classifyGhError(stderr).message }
} finally {
release()
}
}
export async function listLabels(repoPath: string): Promise<string[]> {
const ownerRepo = await getOwnerRepo(repoPath)
if (!ownerRepo) {
return []
}
await acquire()
try {
const { stdout } = await ghExecFileAsync(
[
'api',
'--paginate',
`repos/${ownerRepo.owner}/${ownerRepo.repo}/labels`,
'--jq',
'.[].name'
],
{ cwd: repoPath }
)
return stdout
.trim()
.split('\n')
.filter((l) => l.length > 0)
} catch {
return []
} finally {
release()
}
}
export async function listAssignableUsers(repoPath: string): Promise<string[]> {
const ownerRepo = await getOwnerRepo(repoPath)
if (!ownerRepo) {
return []
}
await acquire()
try {
const { stdout } = await ghExecFileAsync(
[
'api',
'--paginate',
`repos/${ownerRepo.owner}/${ownerRepo.repo}/assignees`,
'--jq',
'.[].login'
],
{ cwd: repoPath }
)
return stdout
.trim()
.split('\n')
.filter((l) => l.length > 0)
} catch {
return []
} finally {
release()
}
}

View File

@ -128,7 +128,7 @@ async function getPRFiles(repoPath: string, prNumber: number): Promise<GitHubPRF
async function getIssueBodyAndComments(
repoPath: string,
issueNumber: number
): Promise<{ body: string; comments: PRComment[] }> {
): Promise<{ body: string; comments: PRComment[]; assignees: string[] }> {
const ownerRepo = await getOwnerRepo(repoPath)
try {
if (ownerRepo) {
@ -152,7 +152,10 @@ async function getIssueBodyAndComments(
{ cwd: repoPath }
)
])
const issue = JSON.parse(issueResult.stdout) as { body?: string | null }
const issue = JSON.parse(issueResult.stdout) as {
body?: string | null
assignees?: { login: string }[]
}
type RESTComment = {
id: number
user: { login: string; avatar_url: string } | null
@ -170,11 +173,12 @@ async function getIssueBodyAndComments(
url: c.html_url
})
)
return { body: issue.body ?? '', comments }
const assignees = (issue.assignees ?? []).map((a) => a.login)
return { body: issue.body ?? '', comments, assignees }
}
// Fallback: non-GitHub remote
const { stdout } = await ghExecFileAsync(
['issue', 'view', String(issueNumber), '--json', 'body,comments'],
['issue', 'view', String(issueNumber), '--json', 'body,comments,assignees'],
{ cwd: repoPath }
)
const data = JSON.parse(stdout) as {
@ -185,6 +189,7 @@ async function getIssueBodyAndComments(
createdAt: string
url: string
}[]
assignees?: { login: string }[]
}
const comments = (data.comments ?? []).map(
(c, i): PRComment => ({
@ -196,9 +201,10 @@ async function getIssueBodyAndComments(
url: c.url ?? ''
})
)
return { body: data.body ?? '', comments }
const fallbackAssignees = (data.assignees ?? []).map((a) => a.login)
return { body: data.body ?? '', comments, assignees: fallbackAssignees }
} catch {
return { body: '', comments: [] }
return { body: '', comments: [], assignees: [] }
}
}
@ -238,8 +244,8 @@ export async function getWorkItemDetails(
await acquire()
try {
if (item.type === 'issue') {
const { body, comments } = await getIssueBodyAndComments(repoPath, item.number)
return { item, body, comments }
const { body, comments, assignees } = await getIssueBodyAndComments(repoPath, item.number)
return { item, body, comments, assignees }
}
// PR: fetch body + comments + checks + files + head/base SHAs in parallel.

View File

@ -1,6 +1,6 @@
import { ipcMain } from 'electron'
import { resolve } from 'path'
import type { Repo } from '../../shared/types'
import type { Repo, GitHubIssueUpdate } from '../../shared/types'
import type { Store } from '../persistence'
import type { StatsCollector } from '../stats/collector'
import {
@ -11,6 +11,10 @@ import {
listWorkItems,
getWorkItem,
createIssue,
updateIssue,
addIssueComment,
listLabels,
listAssignableUsers,
getAuthenticatedViewer,
getPRChecks,
getPRComments,
@ -173,6 +177,44 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi
}
)
ipcMain.handle(
'gh:updateIssue',
(_event, args: { repoPath: string; number: number; updates: GitHubIssueUpdate }) => {
const repo = assertRegisteredRepo(args.repoPath, store)
if (typeof args.number !== 'number' || !Number.isInteger(args.number) || args.number < 1) {
return { ok: false, error: 'Invalid issue number' }
}
if (!args.updates || typeof args.updates !== 'object') {
return { ok: false, error: 'Updates object is required' }
}
return updateIssue(repo.path, args.number, args.updates)
}
)
ipcMain.handle(
'gh:addIssueComment',
(_event, args: { repoPath: string; number: number; body: string }) => {
const repo = assertRegisteredRepo(args.repoPath, store)
if (typeof args.number !== 'number' || !Number.isInteger(args.number) || args.number < 1) {
return { ok: false, error: 'Invalid issue number' }
}
if (!args.body?.trim()) {
return { ok: false, error: 'Comment body required' }
}
return addIssueComment(repo.path, args.number, args.body.trim())
}
)
ipcMain.handle('gh:listLabels', (_event, args: { repoPath: string }) => {
const repo = assertRegisteredRepo(args.repoPath, store)
return listLabels(repo.path)
})
ipcMain.handle('gh:listAssignableUsers', (_event, args: { repoPath: string }) => {
const repo = assertRegisteredRepo(args.repoPath, store)
return listAssignableUsers(repo.path)
})
// Star operations target the Orca repo itself — no repoPath validation needed
ipcMain.handle('gh:viewer', () => getAuthenticatedViewer())
ipcMain.handle('gh:checkOrcaStarred', () => checkOrcaStarred())

View File

@ -1,8 +1,19 @@
import { ipcMain } from 'electron'
import { connect, disconnect, getStatus } from '../linear/client'
import { _resetPreflightCache } from './preflight'
import { getIssue, searchIssues, listIssues } from '../linear/issues'
import {
getIssue,
searchIssues,
listIssues,
updateIssue,
addIssueComment,
getIssueComments,
getTeamStates,
getTeamLabels,
getTeamMembers
} from '../linear/issues'
import type { LinearListFilter } from '../linear/issues'
import type { LinearIssueUpdate } from '../../shared/types'
const VALID_FILTERS = new Set<LinearListFilter>(['assigned', 'created', 'all', 'completed'])
@ -52,4 +63,77 @@ export function registerLinearHandlers(): void {
}
return getIssue(args.id.trim())
})
ipcMain.handle(
'linear:updateIssue',
async (_event, args: { id: string; updates: LinearIssueUpdate }) => {
if (typeof args?.id !== 'string' || !args.id.trim()) {
return { ok: false, error: 'Issue ID is required' }
}
// Why: IPC args are untyped at runtime — validate the updates object and
// individual fields to prevent the Linear SDK from receiving unexpected
// primitives that would produce confusing API errors.
if (!args.updates || typeof args.updates !== 'object') {
return { ok: false, error: 'Updates object is required' }
}
const u = args.updates
if (u.stateId !== undefined && (typeof u.stateId !== 'string' || !u.stateId.trim())) {
return { ok: false, error: 'Invalid state ID' }
}
if (
u.priority !== undefined &&
(!Number.isInteger(u.priority) || u.priority < 0 || u.priority > 4)
) {
return { ok: false, error: 'Priority must be an integer 0-4' }
}
if (
u.labelIds !== undefined &&
(!Array.isArray(u.labelIds) || !u.labelIds.every((id: unknown) => typeof id === 'string'))
) {
return { ok: false, error: 'Label IDs must be an array of strings' }
}
return updateIssue(args.id.trim(), args.updates)
}
)
ipcMain.handle(
'linear:addIssueComment',
async (_event, args: { issueId: string; body: string }) => {
if (typeof args?.issueId !== 'string' || !args.issueId.trim()) {
return { ok: false, error: 'Issue ID is required' }
}
if (!args.body?.trim()) {
return { ok: false, error: 'Comment body is required' }
}
return addIssueComment(args.issueId.trim(), args.body.trim())
}
)
ipcMain.handle('linear:issueComments', async (_event, args: { issueId: string }) => {
if (typeof args?.issueId !== 'string' || !args.issueId.trim()) {
return []
}
return getIssueComments(args.issueId.trim())
})
ipcMain.handle('linear:teamStates', async (_event, args: { teamId: string }) => {
if (typeof args?.teamId !== 'string' || !args.teamId.trim()) {
return []
}
return getTeamStates(args.teamId.trim())
})
ipcMain.handle('linear:teamLabels', async (_event, args: { teamId: string }) => {
if (typeof args?.teamId !== 'string' || !args.teamId.trim()) {
return []
}
return getTeamLabels(args.teamId.trim())
})
ipcMain.handle('linear:teamMembers', async (_event, args: { teamId: string }) => {
if (typeof args?.teamId !== 'string' || !args.teamId.trim()) {
return []
}
return getTeamMembers(args.teamId.trim())
})
}

View File

@ -1,4 +1,11 @@
import type { LinearIssue } from '../../shared/types'
import type {
LinearIssue,
LinearIssueUpdate,
LinearComment,
LinearWorkflowState,
LinearLabel,
LinearMember
} from '../../shared/types'
import { acquire, release, getClient, isAuthError, clearToken } from './client'
import { mapLinearIssue } from './mappers'
@ -112,3 +119,199 @@ export async function listIssues(
release()
}
}
export async function updateIssue(
id: string,
updates: LinearIssueUpdate
): Promise<{ ok: true } | { ok: false; error: string }> {
const client = getClient()
if (!client) {
return { ok: false, error: 'Not connected to Linear' }
}
await acquire()
try {
// Why: labelIds is a full-replace field — a TOCTOU race exists if another
// user changes labels between fetch and write. The caller passes the
// complete set built from recently-fetched data. Acceptable for v1;
// a future version could re-fetch right before writing or use webhooks.
const resolvedLabelIds = updates.labelIds
const payload: Record<string, unknown> = {}
if (updates.stateId !== undefined) {
payload.stateId = updates.stateId
}
if (updates.title !== undefined) {
payload.title = updates.title
}
if (updates.assigneeId !== undefined) {
payload.assigneeId = updates.assigneeId
}
if (updates.priority !== undefined) {
payload.priority = updates.priority
}
if (resolvedLabelIds !== undefined) {
payload.labelIds = resolvedLabelIds
}
const result = await client.updateIssue(id, payload)
if (!result.success) {
return { ok: false, error: 'Linear update failed' }
}
return { ok: true }
} catch (error) {
if (isAuthError(error)) {
clearToken()
throw error
}
const message = error instanceof Error ? error.message : String(error)
return { ok: false, error: message }
} finally {
release()
}
}
export async function addIssueComment(
issueId: string,
body: string
): Promise<{ ok: true; id: string } | { ok: false; error: string }> {
const client = getClient()
if (!client) {
return { ok: false, error: 'Not connected to Linear' }
}
await acquire()
try {
const result = await client.createComment({ issueId, body })
if (!result.success) {
return { ok: false, error: 'Failed to create comment' }
}
const comment = await result.comment
return { ok: true, id: comment?.id ?? '' }
} catch (error) {
if (isAuthError(error)) {
clearToken()
throw error
}
const message = error instanceof Error ? error.message : String(error)
return { ok: false, error: message }
} finally {
release()
}
}
export async function getIssueComments(issueId: string): Promise<LinearComment[]> {
const client = getClient()
if (!client) {
return []
}
await acquire()
try {
const issue = await client.issue(issueId)
const comments = await issue.comments()
const results: LinearComment[] = []
for (const c of comments.nodes) {
const user = await c.user
results.push({
id: c.id,
body: c.body,
createdAt: c.createdAt.toISOString(),
user: user
? { displayName: user.displayName, avatarUrl: user.avatarUrl ?? undefined }
: undefined
})
}
return results
} catch (error) {
if (isAuthError(error)) {
clearToken()
throw error
}
console.warn('[linear] getIssueComments failed:', error)
return []
} finally {
release()
}
}
export async function getTeamStates(teamId: string): Promise<LinearWorkflowState[]> {
const client = getClient()
if (!client) {
return []
}
await acquire()
try {
const team = await client.team(teamId)
const states = await team.states()
return states.nodes
.map((s) => ({
id: s.id,
name: s.name,
type: s.type,
color: s.color,
position: s.position
}))
.sort((a, b) => a.position - b.position)
} catch (error) {
if (isAuthError(error)) {
clearToken()
throw error
}
console.warn('[linear] getTeamStates failed:', error)
return []
} finally {
release()
}
}
export async function getTeamLabels(teamId: string): Promise<LinearLabel[]> {
const client = getClient()
if (!client) {
return []
}
await acquire()
try {
const team = await client.team(teamId)
const labels = await team.labels()
return labels.nodes.map((l) => ({ id: l.id, name: l.name, color: l.color }))
} catch (error) {
if (isAuthError(error)) {
clearToken()
throw error
}
console.warn('[linear] getTeamLabels failed:', error)
return []
} finally {
release()
}
}
export async function getTeamMembers(teamId: string): Promise<LinearMember[]> {
const client = getClient()
if (!client) {
return []
}
await acquire()
try {
const team = await client.team(teamId)
const members = await team.members()
return members.nodes.map((m) => ({
id: m.id,
displayName: m.displayName,
avatarUrl: m.avatarUrl ?? undefined
}))
} catch (error) {
if (isAuthError(error)) {
clearToken()
throw error
}
console.warn('[linear] getTeamMembers failed:', error)
return []
} finally {
release()
}
}

View File

@ -14,13 +14,17 @@ export async function mapLinearIssue(issue: Issue | IssueSearchResult): Promise<
// 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[] = []
let labelIds: string[] = []
if ('labels' in issue && typeof issue.labels === 'function') {
try {
const labelsConnection = await (issue as Issue).labels()
labelNames = labelsConnection.nodes.map((l) => l.name)
labelIds = labelsConnection.nodes.map((l) => l.id)
} catch {
// Swallow — labels are non-critical display data.
}
} else if ('labelIds' in issue && Array.isArray(issue.labelIds)) {
labelIds = issue.labelIds as string[]
}
return {
@ -35,12 +39,18 @@ export async function mapLinearIssue(issue: Issue | IssueSearchResult): Promise<
color: state?.color ?? ''
},
team: {
id: team?.id ?? '',
name: team?.name ?? '',
key: team?.key ?? ''
},
labels: labelNames,
labelIds,
assignee: assignee
? { displayName: assignee.displayName, avatarUrl: assignee.avatarUrl ?? undefined }
? {
id: assignee.id,
displayName: assignee.displayName,
avatarUrl: assignee.avatarUrl ?? undefined
}
: undefined,
priority: issue.priority,
updatedAt: issue.updatedAt.toISOString()

View File

@ -24,6 +24,12 @@ import type {
LinearViewer,
LinearConnectionStatus,
LinearIssue,
LinearIssueUpdate,
LinearComment,
LinearWorkflowState,
LinearLabel,
LinearMember,
GitHubIssueUpdate,
NotificationDispatchRequest,
NotificationDispatchResult,
OpenCodeStatusEvent,
@ -410,6 +416,18 @@ export type PreloadApi = {
prNumber: number
method?: 'merge' | 'squash' | 'rebase'
}) => Promise<{ ok: true } | { ok: false; error: string }>
updateIssue: (args: {
repoPath: string
number: number
updates: GitHubIssueUpdate
}) => Promise<{ ok: true } | { ok: false; error: string }>
addIssueComment: (args: {
repoPath: string
number: number
body: string
}) => Promise<{ ok: true; id: number } | { ok: false; error: string }>
listLabels: (args: { repoPath: string }) => Promise<string[]>
listAssignableUsers: (args: { repoPath: string }) => Promise<string[]>
checkOrcaStarred: () => Promise<boolean | null>
starOrca: () => Promise<boolean>
}
@ -425,6 +443,18 @@ export type PreloadApi = {
limit?: number
}) => Promise<LinearIssue[]>
getIssue: (args: { id: string }) => Promise<LinearIssue | null>
updateIssue: (args: {
id: string
updates: LinearIssueUpdate
}) => Promise<{ ok: true } | { ok: false; error: string }>
addIssueComment: (args: {
issueId: string
body: string
}) => Promise<{ ok: true; id: string } | { ok: false; error: string }>
issueComments: (args: { issueId: string }) => Promise<LinearComment[]>
teamStates: (args: { teamId: string }) => Promise<LinearWorkflowState[]>
teamLabels: (args: { teamId: string }) => Promise<LinearLabel[]>
teamMembers: (args: { teamId: string }) => Promise<LinearMember[]>
}
starNag: {
onShow: (callback: () => void) => () => void

View File

@ -421,6 +421,26 @@ const api = {
}): Promise<{ ok: true } | { ok: false; error: string }> =>
ipcRenderer.invoke('gh:mergePR', args),
updateIssue: (args: {
repoPath: string
number: number
updates: unknown
}): Promise<{ ok: true } | { ok: false; error: string }> =>
ipcRenderer.invoke('gh:updateIssue', args),
addIssueComment: (args: {
repoPath: string
number: number
body: string
}): Promise<{ ok: true; id: number } | { ok: false; error: string }> =>
ipcRenderer.invoke('gh:addIssueComment', args),
listLabels: (args: { repoPath: string }): Promise<string[]> =>
ipcRenderer.invoke('gh:listLabels', args),
listAssignableUsers: (args: { repoPath: string }): Promise<string[]> =>
ipcRenderer.invoke('gh:listAssignableUsers', args),
checkOrcaStarred: (): Promise<boolean | null> => ipcRenderer.invoke('gh:checkOrcaStarred'),
starOrca: (): Promise<boolean> => ipcRenderer.invoke('gh:starOrca')
},
@ -444,7 +464,31 @@ const api = {
}): Promise<unknown[]> => ipcRenderer.invoke('linear:listIssues', args),
getIssue: (args: { id: string }): Promise<unknown> =>
ipcRenderer.invoke('linear:getIssue', args)
ipcRenderer.invoke('linear:getIssue', args),
updateIssue: (args: {
id: string
updates: unknown
}): Promise<{ ok: true } | { ok: false; error: string }> =>
ipcRenderer.invoke('linear:updateIssue', args),
addIssueComment: (args: {
issueId: string
body: string
}): Promise<{ ok: true; id: string } | { ok: false; error: string }> =>
ipcRenderer.invoke('linear:addIssueComment', args),
issueComments: (args: { issueId: string }): Promise<unknown[]> =>
ipcRenderer.invoke('linear:issueComments', args),
teamStates: (args: { teamId: string }): Promise<unknown[]> =>
ipcRenderer.invoke('linear:teamStates', args),
teamLabels: (args: { teamId: string }): Promise<unknown[]> =>
ipcRenderer.invoke('linear:teamLabels', args),
teamMembers: (args: { teamId: string }): Promise<unknown[]> =>
ipcRenderer.invoke('linear:teamMembers', args)
},
starNag: {

View File

@ -225,6 +225,20 @@
scrollbar-color: color-mix(in srgb, var(--muted-foreground, #737373) 48%, transparent) transparent;
}
[data-slot='popover-content'] {
max-height: var(--radix-popover-content-available-height);
}
/* Radix owns the available-height variable on PopoverContent. Make that same
node scrollable so Chromium scrollbar hit-testing stays inside the layer. */
.popover-scroll-content {
color-scheme: dark;
max-height: min(15rem, var(--radix-popover-content-available-height, 15rem));
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
}
/* ── Editor-style scrollbar (matches Monaco) ────────── */
.scrollbar-editor {
@ -282,6 +296,20 @@
/* ── Titlebar ────────────────────────────────────────── */
/* Why: Radix portals (dropdowns, popovers, dialogs, sheets) render at the
document root. Electron's OS-level drag hit-test ignores z-index portaled
content that visually overlaps a -webkit-app-region: drag element is
unclickable without an explicit no-drag. These rules cover every Radix
portal variant: popper wrappers (dropdowns, popovers), dialog/sheet
overlays, and their content panels. */
[data-radix-popper-content-wrapper],
[data-slot='sheet-overlay'],
[data-slot='sheet-content'],
[data-slot='dialog-overlay'],
[data-slot='dialog-content'] {
-webkit-app-region: no-drag;
}
.titlebar {
height: 42px;
min-height: 42px;
@ -1051,6 +1079,7 @@
width: 100%;
min-height: 56px;
max-height: 240px;
overflow-y: auto;
resize: none;
padding: 6px 8px;
border: 1px solid color-mix(in srgb, var(--foreground) 18%, transparent);
@ -1063,6 +1092,22 @@
font-size: 12px;
line-height: 1.4;
outline: none;
scrollbar-width: thin;
scrollbar-color: color-mix(in srgb, var(--muted-foreground, #737373) 34%, transparent) transparent;
}
.orca-diff-comment-popover-textarea::-webkit-scrollbar {
width: 12px;
}
.orca-diff-comment-popover-textarea::-webkit-scrollbar-track {
background: transparent;
}
.orca-diff-comment-popover-textarea::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--muted-foreground, #737373) 28%, transparent);
border: 3px solid transparent;
background-clip: padding-box;
}
.orca-diff-comment-popover-textarea:focus {

View File

@ -11,17 +11,23 @@ import {
GitPullRequest,
LoaderCircle,
MessageSquare,
Send,
X
} from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Sheet, SheetContent, SheetDescription, SheetTitle } from '@/components/ui/sheet'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { VisuallyHidden } from 'radix-ui'
import CommentMarkdown from '@/components/sidebar/CommentMarkdown'
import { detectLanguage } from '@/lib/language-detect'
import { cn } from '@/lib/utils'
import { CHECK_COLOR, CHECK_ICON } from '@/components/right-sidebar/checks-helpers'
import { useAppStore } from '@/store'
import { useRepoLabels, useRepoAssignees, useImmediateMutation } from '@/hooks/useIssueMetadata'
import type {
GitHubPRFile,
GitHubPRFileContents,
@ -433,6 +439,449 @@ function ChecksTab({
)
}
function GHEditSection({
item,
repoPath,
localState,
localLabels,
onStateChange,
onLabelsChange,
assignees
}: {
item: GitHubWorkItem
repoPath: string
localState: GitHubWorkItem['state']
localLabels: string[]
onStateChange: (state: GitHubWorkItem['state']) => void
onLabelsChange: (labels: string[]) => void
assignees: string[]
}): React.JSX.Element | null {
const [labelPopoverOpen, setLabelPopoverOpen] = useState(false)
const [assigneePopoverOpen, setAssigneePopoverOpen] = useState(false)
const [localAssignees, setLocalAssignees] = useState<string[]>(assignees)
const hasEditedAssigneesRef = useRef(false)
const patchWorkItem = useAppStore((s) => s.patchWorkItem)
const { isPending, run } = useImmediateMutation()
const repoLabels = useRepoLabels(repoPath)
const repoAssignees = useRepoAssignees(repoPath)
// Why: sync local assignees when item changes or when the detail fetch
// resolves with real data — but skip if the user already made an
// optimistic edit so we don't clobber in-flight changes.
useEffect(() => {
if (hasEditedAssigneesRef.current) {
return
}
setLocalAssignees(assignees)
}, [item.id, assignees])
// Reset the dirty flag when we switch to a different item.
useEffect(() => {
hasEditedAssigneesRef.current = false
}, [item.id])
const handleStateChange = useCallback(
(newState: 'open' | 'closed') => {
if (newState === localState) {
return
}
const prevState = localState
run('state', {
mutate: () =>
window.api.gh.updateIssue({
repoPath,
number: item.number,
updates: { state: newState }
}),
onOptimistic: () => {
onStateChange(newState)
patchWorkItem(item.id, { state: newState })
},
onRevert: () => {
onStateChange(prevState)
patchWorkItem(item.id, { state: prevState })
},
onSuccess: () => {
patchWorkItem(item.id, { state: newState })
},
onError: (err) => toast.error(err)
})
},
[item.id, item.number, localState, repoPath, patchWorkItem, run, onStateChange]
)
const handleLabelToggle = useCallback(
(label: string) => {
const isAdding = !localLabels.includes(label)
const prevLabels = localLabels
const newLabels = isAdding ? [...prevLabels, label] : prevLabels.filter((l) => l !== label)
if (isAdding) {
run('labels', {
mutate: () =>
window.api.gh.updateIssue({
repoPath,
number: item.number,
updates: { addLabels: [label] }
}),
onOptimistic: () => {
onLabelsChange(newLabels)
patchWorkItem(item.id, { labels: newLabels })
},
onSuccess: () => {},
onRevert: () => {
onLabelsChange(prevLabels)
patchWorkItem(item.id, { labels: prevLabels })
},
onError: (err) => toast.error(err)
})
} else {
run('labels', {
mutate: () =>
window.api.gh.updateIssue({
repoPath,
number: item.number,
updates: { removeLabels: [label] }
}),
onOptimistic: () => {
onLabelsChange(newLabels)
patchWorkItem(item.id, { labels: newLabels })
},
onRevert: () => {
onLabelsChange(prevLabels)
patchWorkItem(item.id, { labels: prevLabels })
},
onSuccess: () => {},
onError: (err) => toast.error(err)
})
}
},
[item.id, item.number, localLabels, repoPath, patchWorkItem, run, onLabelsChange]
)
const handleAssigneeToggle = useCallback(
(login: string) => {
const isAssigned = localAssignees.includes(login)
const prevAssignees = localAssignees
const newAssignees = isAssigned
? prevAssignees.filter((l) => l !== login)
: [...prevAssignees, login]
hasEditedAssigneesRef.current = true
if (isAssigned) {
run('assignees', {
mutate: () =>
window.api.gh.updateIssue({
repoPath,
number: item.number,
updates: { removeAssignees: [login] }
}),
onOptimistic: () => {
setLocalAssignees(newAssignees)
},
onRevert: () => {
setLocalAssignees(prevAssignees)
},
onSuccess: () => {},
onError: (err) => toast.error(err)
})
} else {
run('assignees', {
mutate: () =>
window.api.gh.updateIssue({
repoPath,
number: item.number,
updates: { addAssignees: [login] }
}),
onOptimistic: () => {
setLocalAssignees(newAssignees)
},
onSuccess: () => {},
onRevert: () => {
setLocalAssignees(prevAssignees)
},
onError: (err) => toast.error(err)
})
}
},
[item.number, repoPath, localAssignees, run]
)
if (item.type === 'pr') {
return null
}
const checkIcon = (
<svg className="size-2.5" viewBox="0 0 12 12" fill="none">
<path
d="M2 6l3 3 5-5"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
return (
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 border-b border-border/60 px-4 py-2.5">
{/* State */}
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
'rounded-full border px-2 py-0.5 text-[11px] font-medium transition hover:opacity-80',
getStateTone({ ...item, state: localState })
)}
>
{getStateLabel({ ...item, state: localState })}
</button>
</PopoverTrigger>
<PopoverContent className="w-36 p-1" align="start">
<button
type="button"
onClick={() => handleStateChange('open')}
className={cn(
'flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-[12px] hover:bg-accent',
localState === 'open' && 'bg-accent/50'
)}
>
<CircleDot className="size-3 text-emerald-500" />
Open
</button>
<button
type="button"
onClick={() => handleStateChange('closed')}
className={cn(
'flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-[12px] hover:bg-accent',
localState === 'closed' && 'bg-accent/50'
)}
>
<CircleDashed className="size-3 text-rose-500" />
Closed
</button>
</PopoverContent>
</Popover>
{/* Labels */}
<Popover open={labelPopoverOpen} onOpenChange={setLabelPopoverOpen}>
<PopoverTrigger asChild>
<button
type="button"
disabled={isPending('labels') || repoLabels.loading}
className="flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[11px] transition hover:bg-muted/40 disabled:opacity-50"
>
{localLabels.length === 0 ? (
<span className="text-muted-foreground">+ Label</span>
) : (
localLabels.map((name) => (
<span
key={name}
className="rounded-full border border-border/50 bg-background/60 px-1.5 py-0.5 text-[10px] text-muted-foreground"
>
{name}
</span>
))
)}
{isPending('labels') && (
<LoaderCircle className="size-3 animate-spin text-muted-foreground" />
)}
</button>
</PopoverTrigger>
<PopoverContent className="popover-scroll-content scrollbar-sleek w-52 p-1" align="start">
{repoLabels.error ? (
<div className="px-2 py-3 text-center text-[12px] text-destructive">
{repoLabels.error}
</div>
) : (
<div>
{repoLabels.data.map((label) => (
<button
key={label}
type="button"
onClick={() => handleLabelToggle(label)}
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-[12px] hover:bg-accent"
>
<span
className={cn(
'flex size-3.5 items-center justify-center rounded-sm border',
localLabels.includes(label)
? 'border-primary bg-primary text-primary-foreground'
: 'border-input'
)}
>
{localLabels.includes(label) && checkIcon}
</span>
{label}
</button>
))}
</div>
)}
</PopoverContent>
</Popover>
{/* Assignees */}
<Popover open={assigneePopoverOpen} onOpenChange={setAssigneePopoverOpen}>
<PopoverTrigger asChild>
<button
type="button"
disabled={isPending('assignees') || repoAssignees.loading}
className="flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[11px] transition hover:bg-muted/40 disabled:opacity-50"
>
{localAssignees.length === 0 ? (
<span className="text-muted-foreground">+ Assignee</span>
) : (
localAssignees.map((login) => (
<span
key={login}
className="rounded-full border border-border/50 bg-background/60 px-1.5 py-0.5 text-[10px] text-muted-foreground"
>
{login}
</span>
))
)}
{isPending('assignees') && (
<LoaderCircle className="size-3 animate-spin text-muted-foreground" />
)}
</button>
</PopoverTrigger>
<PopoverContent className="popover-scroll-content scrollbar-sleek w-52 p-1" align="start">
{repoAssignees.error ? (
<div className="px-2 py-3 text-center text-[12px] text-destructive">
{repoAssignees.error}
</div>
) : (
<div>
{repoAssignees.data.map((login) => (
<button
key={login}
type="button"
onClick={() => handleAssigneeToggle(login)}
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-[12px] hover:bg-accent"
>
<span
className={cn(
'flex size-3.5 items-center justify-center rounded-sm border',
localAssignees.includes(login)
? 'border-primary bg-primary text-primary-foreground'
: 'border-input'
)}
>
{localAssignees.includes(login) && checkIcon}
</span>
{login}
</button>
))}
</div>
)}
</PopoverContent>
</Popover>
</div>
)
}
function GHCommentFooter({
repoPath,
issueNumber,
onCommentAdded
}: {
repoPath: string
issueNumber: number
onCommentAdded: (comment: PRComment) => void
}): React.JSX.Element {
const [body, setBody] = useState('')
const [submitting, setSubmitting] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const autoGrow = useCallback(() => {
const el = textareaRef.current
if (!el) {
return
}
el.style.height = 'auto'
el.style.height = `${Math.min(el.scrollHeight, 96)}px`
}, [])
const handleSubmit = useCallback(async () => {
const trimmed = body.trim()
if (!trimmed) {
return
}
setSubmitting(true)
try {
const result = await window.api.gh.addIssueComment({
repoPath,
number: issueNumber,
body: trimmed
})
const typed = result as { ok: boolean; id?: number; comment?: PRComment; error?: string }
if (typed.ok) {
setBody('')
// Why: use the comment returned by GitHub so the optimistic row shows
// the real login/avatar immediately instead of waiting for a reopen.
onCommentAdded(
typed.comment ?? {
id: typeof typed.id === 'number' ? typed.id : Date.now(),
author: 'You',
authorAvatarUrl: '',
body: trimmed,
createdAt: new Date().toISOString(),
url: ''
}
)
} else {
toast.error(typed.error ?? 'Failed to add comment')
}
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to add comment')
} finally {
setSubmitting(false)
}
}, [body, repoPath, issueNumber, onCommentAdded])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
handleSubmit()
}
},
[handleSubmit]
)
return (
<div className="flex items-end gap-2 border-t border-border/60 bg-background/40 px-4 py-2">
<textarea
ref={textareaRef}
value={body}
onChange={(e) => {
setBody(e.target.value)
autoGrow()
}}
onKeyDown={handleKeyDown}
placeholder="Add a comment…"
rows={1}
className="scrollbar-sleek min-h-[32px] max-h-[96px] flex-1 resize-none overflow-y-auto rounded-md border border-input bg-transparent px-3 py-2 text-[13px] placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
<Button
size="icon"
onClick={handleSubmit}
disabled={!body.trim() || submitting}
className="size-8 shrink-0"
aria-label="Send comment"
>
{submitting ? (
<LoaderCircle className="size-3.5 animate-spin" />
) : (
<Send className="size-3.5" />
)}
</Button>
</div>
)
}
export default function GitHubItemDrawer({
workItem,
repoPath,
@ -443,8 +892,30 @@ export default function GitHubItemDrawer({
const [details, setDetails] = useState<GitHubWorkItemDetails | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [localState, setLocalState] = useState<GitHubWorkItem['state']>(workItem?.state ?? 'open')
const [localLabels, setLocalLabels] = useState<string[]>(workItem?.labels ?? [])
const workItemId = workItem?.id
const workItemState = workItem?.state
const workItemLabels = workItem?.labels
// Why: reset lifted edit state when the drawer switches items or when the
// same item receives an optimistic cache patch from the surrounding table.
useEffect(() => {
if (workItemState && workItemLabels) {
setLocalState(workItemState)
setLocalLabels(workItemLabels)
}
}, [workItemId, workItemState, workItemLabels])
const requestIdRef = useRef(0)
// Why: track comments added optimistically before the detail fetch resolves
// so they can be merged into the fetch result instead of being overwritten.
const optimisticCommentsRef = useRef<PRComment[]>([])
// Why: track the last item we fetched so we can distinguish "reopen same
// item" from "switch to a different item". Reopening the same item must
// preserve optimistic comments because gh's 60s response cache will return
// stale data that doesn't include the just-posted comment.
const prevItemIdRef = useRef<string | null>(null)
// Why: when this drawer opens immediately after another Radix overlay
// (e.g. the New Issue dialog) closed, Radix may leave `pointer-events: none`
@ -485,6 +956,15 @@ export default function GitHubItemDrawer({
// results whose id matches the latest one.
requestIdRef.current += 1
const requestId = requestIdRef.current
// Why: only clear optimistic comments when switching to a genuinely
// different item. When reopening the same item (close → reopen), the
// gh API's 60s response cache will return stale data that omits the
// just-posted comment — preserving the optimistic ref lets the merge
// logic below re-attach it to the stale response.
if (workItem.id !== prevItemIdRef.current) {
optimisticCommentsRef.current = []
}
prevItemIdRef.current = workItem.id
setLoading(true)
setError(null)
setDetails(null)
@ -496,6 +976,16 @@ export default function GitHubItemDrawer({
if (requestId !== requestIdRef.current) {
return
}
// Why: merge any comments the user posted optimistically while the
// detail fetch was in-flight, using id to avoid duplicates.
const opt = optimisticCommentsRef.current
if (opt.length > 0 && result) {
const fetchedIds = new Set(result.comments.map((c: PRComment) => c.id))
const missing = opt.filter((c) => !fetchedIds.has(c.id))
if (missing.length > 0) {
result = { ...result, comments: [...result.comments, ...missing] }
}
}
setDetails(result)
})
.catch((err) => {
@ -557,19 +1047,9 @@ export default function GitHubItemDrawer({
<div className="flex items-start gap-2">
<Icon 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(workItem)
)}
>
{getStateLabel(workItem)}
</span>
<span className="font-mono text-[12px] text-muted-foreground">
#{workItem.number}
</span>
</div>
<span className="font-mono text-[12px] text-muted-foreground">
#{workItem.number}
</span>
<h2 className="mt-1 text-[15px] font-semibold leading-tight text-foreground">
{workItem.title}
</h2>
@ -582,18 +1062,6 @@ export default function GitHubItemDrawer({
</span>
)}
</div>
{workItem.labels.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{workItem.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>
@ -632,6 +1100,19 @@ export default function GitHubItemDrawer({
</div>
</div>
{/* Edit section (issues only) */}
{repoPath && (
<GHEditSection
item={workItem}
repoPath={repoPath}
localState={localState}
localLabels={localLabels}
onStateChange={setLocalState}
onLabelsChange={setLocalLabels}
assignees={details?.assignees ?? []}
/>
)}
{/* Tabs + body */}
<div className="min-h-0 flex-1">
{error ? (
@ -720,6 +1201,32 @@ export default function GitHubItemDrawer({
)}
</div>
{/* Comment footer */}
{repoPath && (
<GHCommentFooter
repoPath={repoPath}
issueNumber={workItem.number}
onCommentAdded={(comment) => {
// Why: skip refreshDetails() — gh api --cache 60s returns stale data
// that overwrites the optimistic comment. The next drawer open (after
// cache expiry) will pick up the server-confirmed version.
optimisticCommentsRef.current.push(comment)
setDetails((prev) => {
if (prev) {
return { ...prev, comments: [...prev.comments, comment] }
}
// Why: details may still be loading — create a minimal shell
// so the optimistic comment isn't silently dropped.
return {
item: workItem,
body: '',
comments: [comment]
}
})
}}
/>
)}
{/* Footer */}
<div className="flex-none border-t border-border/60 bg-background/40 px-4 py-3">
<Button

View File

@ -1,12 +1,23 @@
import React, { useEffect, useRef, useState } from 'react'
import { ArrowRight, ExternalLink, X } from 'lucide-react'
/* eslint-disable max-lines -- Why: the Linear drawer co-locates read-only preview, edit controls, and comment input so the full issue surface stays in one file. */
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { ArrowRight, ExternalLink, LoaderCircle, Send, X } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Sheet, SheetContent, SheetDescription, SheetTitle } from '@/components/ui/sheet'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { VisuallyHidden } from 'radix-ui'
import CommentMarkdown from '@/components/sidebar/CommentMarkdown'
import { cn } from '@/lib/utils'
import type { LinearIssue } from '../../../shared/types'
import { useAppStore } from '@/store'
import {
useTeamStates,
useTeamLabels,
useTeamMembers,
useImmediateMutation
} from '@/hooks/useIssueMetadata'
import type { LinearIssue, LinearComment } from '../../../shared/types'
function LinearIcon({ className }: { className?: string }): React.JSX.Element {
return (
@ -43,20 +54,13 @@ function formatRelativeTime(input: string): string {
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'
// Why: derive pill border/background/text from the actual Linear state color
// so the pill always matches the colored dot, regardless of state type.
function statePillStyle(color: string): React.CSSProperties {
return {
borderColor: `color-mix(in srgb, ${color} 30%, transparent)`,
backgroundColor: `color-mix(in srgb, ${color} 10%, transparent)`,
color
}
}
@ -66,42 +70,522 @@ type LinearItemDrawerProps = {
onClose: () => void
}
type LinearEditState = {
state: LinearIssue['state']
priority: number
assignee: LinearIssue['assignee']
labelIds: string[]
labels: string[]
}
type EditSectionProps = {
issue: LinearIssue
editState: LinearEditState
onEditStateChange: (patch: Partial<LinearEditState>) => void
}
function EditSection({ issue, editState, onEditStateChange }: EditSectionProps): React.JSX.Element {
const [labelPopoverOpen, setLabelPopoverOpen] = useState(false)
const patchLinearIssue = useAppStore((s) => s.patchLinearIssue)
const { isPending, run } = useImmediateMutation()
const {
state: localState,
priority: localPriority,
assignee: localAssignee,
labelIds: localLabelIds,
labels: localLabels
} = editState
const teamId = issue.team?.id || null
const states = useTeamStates(teamId)
const labels = useTeamLabels(teamId)
const members = useTeamMembers(teamId)
const handleStateChange = useCallback(
(stateId: string) => {
const newState = states.data.find((s) => s.id === stateId)
if (!newState) {
return
}
const prevState = localState
const stateValue = { name: newState.name, type: newState.type, color: newState.color }
run('state', {
mutate: () => window.api.linear.updateIssue({ id: issue.id, updates: { stateId } }),
onOptimistic: () => {
onEditStateChange({ state: stateValue })
patchLinearIssue(issue.id, { state: stateValue })
},
onRevert: () => {
onEditStateChange({ state: prevState })
patchLinearIssue(issue.id, { state: prevState })
},
onError: (err) => toast.error(err)
})
},
[issue.id, localState, states.data, patchLinearIssue, run, onEditStateChange]
)
const handlePriorityChange = useCallback(
(value: string) => {
const priority = parseInt(value, 10)
const prevPriority = localPriority
run('priority', {
mutate: () => window.api.linear.updateIssue({ id: issue.id, updates: { priority } }),
onOptimistic: () => {
onEditStateChange({ priority })
patchLinearIssue(issue.id, { priority })
},
onRevert: () => {
onEditStateChange({ priority: prevPriority })
patchLinearIssue(issue.id, { priority: prevPriority })
},
onError: (err) => toast.error(err)
})
},
[issue.id, localPriority, patchLinearIssue, run, onEditStateChange]
)
const handleAssigneeChange = useCallback(
(memberId: string) => {
const assigneeId = memberId === '__unassign__' ? null : memberId
const member = members.data.find((m) => m.id === memberId)
const prevAssignee = localAssignee
const newAssignee = member
? { id: member.id, displayName: member.displayName, avatarUrl: member.avatarUrl }
: undefined
run('assignee', {
mutate: () => window.api.linear.updateIssue({ id: issue.id, updates: { assigneeId } }),
onOptimistic: () => {
onEditStateChange({ assignee: newAssignee })
patchLinearIssue(issue.id, { assignee: newAssignee })
},
onRevert: () => {
onEditStateChange({ assignee: prevAssignee })
patchLinearIssue(issue.id, { assignee: prevAssignee })
},
onError: (err) => toast.error(err)
})
},
[issue.id, localAssignee, members.data, patchLinearIssue, run, onEditStateChange]
)
const handleLabelToggle = useCallback(
(labelId: string) => {
const prevLabelIds = localLabelIds
const prevLabels = localLabels
const isRemoving = prevLabelIds.includes(labelId)
const newLabelIds = isRemoving
? prevLabelIds.filter((id) => id !== labelId)
: [...prevLabelIds, labelId]
const newLabels = newLabelIds
.map((id) => labels.data.find((l) => l.id === id)?.name)
.filter((n): n is string => !!n)
run('labels', {
mutate: () =>
window.api.linear.updateIssue({ id: issue.id, updates: { labelIds: newLabelIds } }),
onOptimistic: () => {
onEditStateChange({ labelIds: newLabelIds, labels: newLabels })
patchLinearIssue(issue.id, { labelIds: newLabelIds, labels: newLabels })
},
onRevert: () => {
onEditStateChange({ labelIds: prevLabelIds, labels: prevLabels })
patchLinearIssue(issue.id, { labelIds: prevLabelIds, labels: prevLabels })
},
onError: (err) => toast.error(err)
})
},
[issue.id, localLabelIds, localLabels, labels.data, patchLinearIssue, run, onEditStateChange]
)
const currentStateId = states.data.find(
(s) => s.name === localState.name && s.type === localState.type
)?.id
const checkIcon = (
<svg className="size-2.5" viewBox="0 0 12 12" fill="none">
<path
d="M2 6l3 3 5-5"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
return (
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 border-b border-border/60 px-4 py-2.5">
{/* Status */}
<Popover>
<PopoverTrigger asChild>
<button
type="button"
disabled={isPending('state') || states.loading}
className="flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-[11px] font-medium transition hover:opacity-80 disabled:opacity-50"
style={statePillStyle(localState.color)}
>
<span
className="inline-block size-2 rounded-full"
style={{ backgroundColor: localState.color }}
/>
{localState.name}
{isPending('state') && <LoaderCircle className="size-3 animate-spin" />}
</button>
</PopoverTrigger>
<PopoverContent className="popover-scroll-content scrollbar-sleek w-48 p-1" align="start">
<div>
{states.data.map((s) => (
<button
key={s.id}
type="button"
onClick={() => handleStateChange(s.id)}
className={cn(
'flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-[12px] hover:bg-accent',
currentStateId === s.id && 'bg-accent/50'
)}
>
<span
className="inline-block size-2 rounded-full"
style={{ backgroundColor: s.color }}
/>
{s.name}
</button>
))}
</div>
</PopoverContent>
</Popover>
{/* Priority */}
<Popover>
<PopoverTrigger asChild>
<button
type="button"
disabled={isPending('priority')}
className="rounded-md px-1.5 py-0.5 text-[11px] text-muted-foreground transition hover:bg-muted/40 disabled:opacity-50"
>
{PRIORITY_LABELS[localPriority] ?? `P${localPriority}`}
{isPending('priority') && <LoaderCircle className="ml-1 inline size-3 animate-spin" />}
</button>
</PopoverTrigger>
<PopoverContent className="w-36 p-1" align="start">
{[0, 1, 2, 3, 4].map((p) => (
<button
key={p}
type="button"
onClick={() => handlePriorityChange(String(p))}
className={cn(
'flex w-full items-center rounded-sm px-2 py-1.5 text-[12px] hover:bg-accent',
localPriority === p && 'bg-accent/50'
)}
>
{PRIORITY_LABELS[p]}
</button>
))}
</PopoverContent>
</Popover>
{/* Assignee */}
<Popover>
<PopoverTrigger asChild>
<button
type="button"
disabled={isPending('assignee') || members.loading}
className="flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[11px] transition hover:bg-muted/40 disabled:opacity-50"
>
{localAssignee ? (
<span className="text-muted-foreground">{localAssignee.displayName}</span>
) : (
<span className="text-muted-foreground">+ Assignee</span>
)}
{isPending('assignee') && (
<LoaderCircle className="size-3 animate-spin text-muted-foreground" />
)}
</button>
</PopoverTrigger>
<PopoverContent className="popover-scroll-content scrollbar-sleek w-48 p-1" align="start">
<div>
<button
type="button"
onClick={() => handleAssigneeChange('__unassign__')}
className={cn(
'flex w-full items-center rounded-sm px-2 py-1.5 text-[12px] hover:bg-accent',
!localAssignee && 'bg-accent/50'
)}
>
Unassigned
</button>
{members.data.map((m) => (
<button
key={m.id}
type="button"
onClick={() => handleAssigneeChange(m.id)}
className={cn(
'flex w-full items-center rounded-sm px-2 py-1.5 text-[12px] hover:bg-accent',
localAssignee?.id === m.id && 'bg-accent/50'
)}
>
{m.displayName}
</button>
))}
</div>
</PopoverContent>
</Popover>
{/* Labels */}
<Popover open={labelPopoverOpen} onOpenChange={setLabelPopoverOpen}>
<PopoverTrigger asChild>
<button
type="button"
disabled={isPending('labels') || labels.loading}
className="flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[11px] transition hover:bg-muted/40 disabled:opacity-50"
>
{localLabelIds.length === 0 ? (
<span className="text-muted-foreground">+ Label</span>
) : (
localLabels.map((name) => (
<span
key={name}
className="rounded-full border border-border/50 bg-background/60 px-1.5 py-0.5 text-[10px] text-muted-foreground"
>
{name}
</span>
))
)}
{isPending('labels') && (
<LoaderCircle className="size-3 animate-spin text-muted-foreground" />
)}
</button>
</PopoverTrigger>
<PopoverContent className="popover-scroll-content scrollbar-sleek w-52 p-1" align="start">
{labels.error ? (
<div className="px-2 py-3 text-center text-[12px] text-destructive">{labels.error}</div>
) : (
<div>
{labels.data.map((label) => (
<button
key={label.id}
type="button"
onClick={() => handleLabelToggle(label.id)}
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-[12px] hover:bg-accent"
>
<span
className={cn(
'flex size-3.5 items-center justify-center rounded-sm border',
localLabelIds.includes(label.id)
? 'border-primary bg-primary text-primary-foreground'
: 'border-input'
)}
>
{localLabelIds.includes(label.id) && checkIcon}
</span>
<span
className="inline-block size-2 rounded-full"
style={{ backgroundColor: label.color }}
/>
{label.name}
</button>
))}
</div>
)}
</PopoverContent>
</Popover>
</div>
)
}
type LocalComment = { id: string; body: string; createdAt: string }
function CommentFooter({
issueId,
onCommentAdded
}: {
issueId: string
onCommentAdded: (comment: LocalComment) => void
}): React.JSX.Element {
const [body, setBody] = useState('')
const [submitting, setSubmitting] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const autoGrow = useCallback(() => {
const el = textareaRef.current
if (!el) {
return
}
el.style.height = 'auto'
el.style.height = `${Math.min(el.scrollHeight, 96)}px`
}, [])
const handleSubmit = useCallback(async () => {
const trimmed = body.trim()
if (!trimmed) {
return
}
setSubmitting(true)
try {
const result = await window.api.linear.addIssueComment({ issueId, body: trimmed })
const typed = result as { ok: boolean; id?: string; error?: string }
if (typed.ok) {
setBody('')
onCommentAdded({
id: typed.id ?? crypto.randomUUID(),
body: trimmed,
createdAt: new Date().toISOString()
})
} else {
toast.error(typed.error ?? 'Failed to add comment')
}
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to add comment')
} finally {
setSubmitting(false)
}
}, [body, issueId, onCommentAdded])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
handleSubmit()
}
},
[handleSubmit]
)
return (
<div className="flex items-end gap-2 border-t border-border/60 bg-background/40 px-4 py-3">
<textarea
ref={textareaRef}
value={body}
onChange={(e) => {
setBody(e.target.value)
autoGrow()
}}
onKeyDown={handleKeyDown}
placeholder="Add a comment…"
rows={1}
className="scrollbar-sleek min-h-[32px] max-h-[96px] flex-1 resize-none overflow-y-auto rounded-md border border-input bg-transparent px-3 py-2 text-[13px] placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
<Button
size="icon"
onClick={handleSubmit}
disabled={!body.trim() || submitting}
className="size-8 shrink-0"
aria-label="Send comment"
>
{submitting ? (
<LoaderCircle className="size-3.5 animate-spin" />
) : (
<Send className="size-3.5" />
)}
</Button>
</div>
)
}
function initEditState(issue: LinearIssue): LinearEditState {
return {
state: issue.state,
priority: issue.priority,
assignee: issue.assignee,
labelIds: issue.labelIds,
labels: issue.labels
}
}
export default function LinearItemDrawer({
issue,
onUse,
onClose
}: LinearItemDrawerProps): React.JSX.Element {
const [fullIssue, setFullIssue] = useState<LinearIssue | null>(null)
const [comments, setComments] = useState<LinearComment[]>([])
const [commentsLoading, setCommentsLoading] = useState(false)
const [editState, setEditState] = useState<LinearEditState | null>(null)
const requestIdRef = useRef(0)
const hasEditedRef = useRef(false)
const optimisticCommentsRef = useRef<LinearComment[]>([])
const handleEditStateChange = useCallback((patch: Partial<LinearEditState>) => {
hasEditedRef.current = true
setEditState((prev) => (prev ? { ...prev, ...patch } : prev))
}, [])
// Why: the list view may not include the full description. Re-fetch
// the issue by ID to get the complete body for the drawer.
// the issue by ID and its comments to populate the drawer.
useEffect(() => {
if (!issue) {
setFullIssue(null)
setComments([])
setEditState(null)
hasEditedRef.current = false
return
}
hasEditedRef.current = false
optimisticCommentsRef.current = []
setComments([])
setCommentsLoading(true)
setEditState(initEditState(issue))
requestIdRef.current += 1
const requestId = requestIdRef.current
setFullIssue(issue)
// Why: fetch issue and comments independently so a transient comments
// failure doesn't discard the successfully-fetched issue data.
window.api.linear
.getIssue({ id: issue.id })
.then((result) => {
.then((issueResult) => {
if (requestId !== requestIdRef.current) {
return
}
if (result) {
setFullIssue(result as LinearIssue)
if (issueResult) {
const fetched = issueResult as LinearIssue
setFullIssue(fetched)
// Why: skip if the user already made optimistic edits — the fetch
// carries pre-edit data that would clobber in-flight changes.
if (!hasEditedRef.current) {
setEditState(initEditState(fetched))
}
}
})
.catch(() => {})
}, [issue])
window.api.linear
.issueComments({ issueId: issue.id })
.then((commentsResult) => {
if (requestId !== requestIdRef.current) {
return
}
// Why: merge any comments the user posted optimistically while the
// fetch was in-flight, using id to avoid duplicates.
let fetched = commentsResult as LinearComment[]
const opt = optimisticCommentsRef.current
if (opt.length > 0) {
const fetchedIds = new Set(fetched.map((c) => c.id))
const missing = opt.filter((c) => !fetchedIds.has(c.id))
if (missing.length > 0) {
fetched = [...fetched, ...missing]
}
}
setComments(fetched)
})
.catch(() => {})
.finally(() => {
if (requestId === requestIdRef.current) {
setCommentsLoading(false)
}
})
// oxlint-disable-next-line react-hooks/exhaustive-deps
}, [issue?.id])
// Why: same pointer-events fix as GitHubItemDrawer — Radix may leave
// pointer-events: none on body when overlays transition.
// oxlint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => {
if (!issue) {
if (!issue?.id) {
return
}
let cancelled = false
@ -121,7 +605,18 @@ export default function LinearItemDrawer({
return () => {
cancelled = true
}
}, [issue])
}, [issue?.id])
const handleCommentAdded = useCallback((comment: LocalComment) => {
const newComment: LinearComment = {
id: comment.id,
body: comment.body,
createdAt: comment.createdAt,
user: { displayName: 'You' }
}
optimisticCommentsRef.current.push(newComment)
setComments((prev) => [...prev, newComment])
}, [])
const displayed = fullIssue ?? issue
@ -139,7 +634,7 @@ export default function LinearItemDrawer({
<SheetTitle>{displayed?.title ?? 'Linear issue'}</SheetTitle>
</VisuallyHidden.Root>
<VisuallyHidden.Root asChild>
<SheetDescription>Read-only preview of the selected Linear issue.</SheetDescription>
<SheetDescription>Preview and edit the selected Linear issue.</SheetDescription>
</VisuallyHidden.Root>
{displayed && (
@ -149,44 +644,16 @@ export default function LinearItemDrawer({
<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>
<span className="font-mono text-[12px] text-muted-foreground">
{displayed.identifier}
</span>
<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>
{displayed.team?.name && <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>
@ -225,7 +692,16 @@ export default function LinearItemDrawer({
</div>
</div>
{/* Body */}
{/* Edit section */}
{editState && (
<EditSection
issue={displayed}
editState={editState}
onEditStateChange={handleEditStateChange}
/>
)}
{/* Body + comments */}
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-sleek">
<div className="px-4 py-4">
{displayed.description?.trim() ? (
@ -237,9 +713,57 @@ export default function LinearItemDrawer({
<span className="italic text-muted-foreground">No description provided.</span>
)}
</div>
<div className="border-t border-border/40 px-4 py-4">
<div className="flex items-center gap-2 pb-3">
<span className="text-[13px] font-medium text-foreground">Comments</span>
{comments.length > 0 && (
<span className="text-[12px] text-muted-foreground">{comments.length}</span>
)}
</div>
{commentsLoading && comments.length === 0 ? (
<div className="flex items-center justify-center py-6">
<LoaderCircle className="size-4 animate-spin text-muted-foreground" />
</div>
) : comments.length === 0 ? (
<p className="text-[13px] text-muted-foreground">No comments yet.</p>
) : (
<div className="flex flex-col gap-3">
{comments.map((comment) => (
<div
key={comment.id}
className="rounded-lg border border-border/40 bg-background/30"
>
<div className="flex items-center gap-2 border-b border-border/40 px-3 py-2">
{comment.user?.avatarUrl && (
<img
src={comment.user.avatarUrl}
alt={comment.user.displayName}
className="size-5 shrink-0 rounded-full"
/>
)}
<span className="text-[13px] font-semibold text-foreground">
{comment.user?.displayName ?? 'Unknown'}
</span>
<span className="text-[12px] text-muted-foreground">
· {formatRelativeTime(comment.createdAt)}
</span>
</div>
<div className="px-3 py-2">
<CommentMarkdown
content={comment.body}
className="text-[13px] leading-relaxed"
/>
</div>
</div>
))}
</div>
)}
</div>
</div>
{/* Footer */}
{/* Comment footer + Start workspace */}
<CommentFooter issueId={displayed.id} onCommentAdded={handleCommentAdded} />
<div className="flex-none border-t border-border/60 bg-background/40 px-4 py-3">
<Button
onClick={() => onUse(displayed)}

View File

@ -18,6 +18,7 @@ import {
X
} from 'lucide-react'
import { toast } from 'sonner'
import { useAppStore } from '@/store'
import { useRepoMap } from '@/store/selectors'
import { Button } from '@/components/ui/button'
@ -37,6 +38,7 @@ import {
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import RepoMultiCombobox from '@/components/ui/repo-multi-combobox'
import RepoDotLabel from '@/components/repo/RepoDotLabel'
import { stripRepoQualifiers } from '../../../shared/task-query'
@ -47,6 +49,7 @@ import { getLinkedWorkItemSuggestedName, getTaskPresetQuery } from '@/lib/new-wo
import type { LinkedWorkItemSummary } from '@/lib/new-workspace'
import { launchWorkItemDirect } from '@/lib/launch-work-item-direct'
import { isGitRepoKind } from '../../../shared/repo-kind'
import { useTeamStates } from '@/hooks/useIssueMetadata'
import type { GitHubWorkItem, LinearIssue, TaskViewPresetId } from '../../../shared/types'
import { shouldSuppressEnterSubmit } from '@/lib/new-workspace-enter-guard'
@ -164,8 +167,315 @@ const LINEAR_PRIORITY_LABELS: Record<number, string> = {
4: 'Low'
}
function getLinearPriorityLabel(priority: number): string {
return LINEAR_PRIORITY_LABELS[priority] ?? 'None'
function GHStatusCell({
item,
repoPath
}: {
item: GitHubWorkItem
repoPath: string | null
}): React.JSX.Element {
const patchWorkItem = useAppStore((s) => s.patchWorkItem)
const [localState, setLocalState] = useState(item.state)
const [open, setOpen] = useState(false)
const reqRef = useRef(0)
useEffect(() => {
setLocalState(item.state)
}, [item.state])
const handleStateChange = useCallback(
(newState: 'open' | 'closed') => {
if (newState === localState || !repoPath || item.type !== 'issue') {
return
}
reqRef.current += 1
const reqId = reqRef.current
setLocalState(newState)
patchWorkItem(item.id, { state: newState })
window.api.gh
.updateIssue({ repoPath, number: item.number, updates: { state: newState } })
.then((result) => {
if (reqId !== reqRef.current) {
return
}
const typed = result as { ok?: boolean; error?: string }
if (typed && typed.ok === false) {
setLocalState(newState === 'closed' ? 'open' : 'closed')
patchWorkItem(item.id, { state: newState === 'closed' ? 'open' : 'closed' })
toast.error(typed.error ?? 'Failed to update state')
}
})
.catch(() => {
if (reqId !== reqRef.current) {
return
}
setLocalState(newState === 'closed' ? 'open' : 'closed')
patchWorkItem(item.id, { state: newState === 'closed' ? 'open' : 'closed' })
toast.error('Failed to update state')
})
},
[item.id, item.number, item.type, localState, repoPath, patchWorkItem]
)
if (item.type !== 'issue' || !repoPath) {
return (
<span
className={cn(
'rounded-full border px-2 py-0.5 text-[10px] font-medium',
getTaskStatusTone(item)
)}
>
{getTaskStatusLabel(item)}
</span>
)
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
onClick={(e) => e.stopPropagation()}
className={cn(
'rounded-full border px-2 py-0.5 text-[10px] font-medium transition hover:opacity-80',
localState === 'closed'
? 'border-rose-500/30 bg-rose-500/10 text-rose-600 dark:text-rose-300'
: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300'
)}
>
{localState === 'closed' ? 'Closed' : 'Open'}
</button>
</PopoverTrigger>
<PopoverContent className="w-36 p-1" align="start" onClick={(e) => e.stopPropagation()}>
<button
type="button"
onClick={() => {
handleStateChange('open')
setOpen(false)
}}
className={cn(
'flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-[12px] hover:bg-accent',
localState === 'open' && 'bg-accent/50'
)}
>
<CircleDot className="size-3 text-emerald-500" />
Open
</button>
<button
type="button"
onClick={() => {
handleStateChange('closed')
setOpen(false)
}}
className={cn(
'flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-[12px] hover:bg-accent',
localState === 'closed' && 'bg-accent/50'
)}
>
<CircleDot className="size-3 text-rose-500" />
Closed
</button>
</PopoverContent>
</Popover>
)
}
function LinearStatusCell({ issue }: { issue: LinearIssue }): React.JSX.Element {
const patchLinearIssue = useAppStore((s) => s.patchLinearIssue)
const fetchLinearIssue = useAppStore((s) => s.fetchLinearIssue)
const [localState, setLocalState] = useState(issue.state)
const reqRef = useRef(0)
useEffect(() => {
setLocalState(issue.state)
}, [issue.state])
const teamId = issue.team?.id || null
const states = useTeamStates(teamId)
const handleStateChange = useCallback(
(stateId: string) => {
const newState = states.data.find((s) => s.id === stateId)
if (!newState) {
return
}
const stateValue = { name: newState.name, type: newState.type, color: newState.color }
reqRef.current += 1
const reqId = reqRef.current
setLocalState(stateValue)
patchLinearIssue(issue.id, { state: stateValue })
window.api.linear
.updateIssue({ id: issue.id, updates: { stateId } })
.then((result) => {
if (reqId !== reqRef.current) {
return
}
const typed = result as { ok?: boolean; error?: string }
if (typed && typed.ok === false) {
setLocalState(issue.state)
patchLinearIssue(issue.id, { state: issue.state })
toast.error(typed.error ?? 'Failed to update status')
} else {
fetchLinearIssue(issue.id)
}
})
.catch(() => {
if (reqId !== reqRef.current) {
return
}
setLocalState(issue.state)
patchLinearIssue(issue.id, { state: issue.state })
toast.error('Failed to update status')
})
},
[issue.id, issue.state, states.data, patchLinearIssue, fetchLinearIssue]
)
const currentStateId = states.data.find(
(s) => s.name === localState.name && s.type === localState.type
)?.id
const [open, setOpen] = useState(false)
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
onClick={(e) => e.stopPropagation()}
disabled={states.loading}
className="flex items-center gap-1.5 rounded-sm px-1 py-0.5 transition hover:bg-muted/60 disabled:opacity-50"
>
<span
className="inline-block size-2 shrink-0 rounded-full"
style={{ backgroundColor: localState.color }}
/>
<span className="truncate text-xs text-muted-foreground">{localState.name}</span>
</button>
</PopoverTrigger>
<PopoverContent
className="popover-scroll-content scrollbar-sleek w-48 p-1"
align="start"
onClick={(e) => e.stopPropagation()}
>
<div>
{states.data.map((s) => (
<button
key={s.id}
type="button"
onClick={() => {
handleStateChange(s.id)
setOpen(false)
}}
className={cn(
'flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-[12px] hover:bg-accent',
currentStateId === s.id && 'bg-accent/50'
)}
>
<span
className="inline-block size-2 rounded-full"
style={{ backgroundColor: s.color }}
/>
{s.name}
</button>
))}
</div>
</PopoverContent>
</Popover>
)
}
function LinearPriorityCell({ issue }: { issue: LinearIssue }): React.JSX.Element {
const patchLinearIssue = useAppStore((s) => s.patchLinearIssue)
const fetchLinearIssue = useAppStore((s) => s.fetchLinearIssue)
const [localPriority, setLocalPriority] = useState(issue.priority)
const [pending, setPending] = useState(false)
const reqRef = useRef(0)
useEffect(() => {
setLocalPriority(issue.priority)
}, [issue.priority])
const handlePriorityChange = useCallback(
(priority: number) => {
if (priority === localPriority) {
return
}
reqRef.current += 1
const reqId = reqRef.current
setLocalPriority(priority)
patchLinearIssue(issue.id, { priority })
setPending(true)
window.api.linear
.updateIssue({ id: issue.id, updates: { priority } })
.then((result) => {
if (reqId !== reqRef.current) {
return
}
const typed = result as { ok?: boolean; error?: string }
if (typed && typed.ok === false) {
setLocalPriority(issue.priority)
patchLinearIssue(issue.id, { priority: issue.priority })
toast.error(typed.error ?? 'Failed to update priority')
} else {
fetchLinearIssue(issue.id)
}
})
.catch(() => {
if (reqId !== reqRef.current) {
return
}
setLocalPriority(issue.priority)
patchLinearIssue(issue.id, { priority: issue.priority })
toast.error('Failed to update priority')
})
.finally(() => {
if (reqId !== reqRef.current) {
return
}
setPending(false)
})
},
[issue.id, issue.priority, localPriority, patchLinearIssue, fetchLinearIssue]
)
const [open, setOpen] = useState(false)
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
onClick={(e) => e.stopPropagation()}
disabled={pending}
className="rounded-sm px-1 py-0.5 text-xs text-muted-foreground transition hover:bg-muted/60 disabled:opacity-50"
>
{LINEAR_PRIORITY_LABELS[localPriority] ?? `P${localPriority}`}
{pending && <LoaderCircle className="ml-1 inline size-3 animate-spin" />}
</button>
</PopoverTrigger>
<PopoverContent className="w-36 p-1" align="start" onClick={(e) => e.stopPropagation()}>
{[0, 1, 2, 3, 4].map((p) => (
<button
key={p}
type="button"
onClick={() => {
handlePriorityChange(p)
setOpen(false)
}}
className={cn(
'flex w-full items-center rounded-sm px-2 py-1.5 text-[12px] hover:bg-accent',
localPriority === p && 'bg-accent/50'
)}
>
{LINEAR_PRIORITY_LABELS[p]}
</button>
))}
</PopoverContent>
</Popover>
)
}
export default function TaskPage(): React.JSX.Element {
@ -330,13 +640,70 @@ export default function TaskPage(): React.JSX.Element {
// Why: clicking a GitHub row opens this drawer for a read-only preview.
// Drawer's "Use" button routes through the same direct-launch flow as the
// row-level "Use" CTA so behavior is consistent regardless of entry point.
const [drawerWorkItem, setDrawerWorkItem] = useState<GitHubWorkItem | null>(null)
const [drawerWorkItemId, setDrawerWorkItemId] = useState<string | null>(null)
const [drawerWorkItemFallback, setDrawerWorkItemFallback] = useState<GitHubWorkItem | null>(null)
const workItemsCache = useAppStore((s) => s.workItemsCache)
const linearIssueCache = useAppStore((s) => s.linearIssueCache)
const linearSearchCache = useAppStore((s) => s.linearSearchCache)
// Why: derive the drawer's work item from the store cache so it reflects
// optimistic patches (e.g. table-cell status toggle). Falls back to the
// snapshot stored at click time for newly-created stubs not yet in the cache.
const drawerWorkItem = useMemo(() => {
if (!drawerWorkItemId) {
return null
}
for (const entry of Object.values(workItemsCache)) {
const found = entry?.data?.find((wi) => wi.id === drawerWorkItemId)
if (found) {
return found
}
}
return drawerWorkItemFallback
}, [drawerWorkItemId, workItemsCache, drawerWorkItemFallback])
const setDrawerWorkItem = useCallback((item: GitHubWorkItem | null) => {
setDrawerWorkItemId(item?.id ?? null)
setDrawerWorkItemFallback(item)
}, [])
const [newIssueOpen, setNewIssueOpen] = useState(false)
const [newIssueTitle, setNewIssueTitle] = useState('')
const [newIssueBody, setNewIssueBody] = useState('')
const [newIssueSubmitting, setNewIssueSubmitting] = useState(false)
const [drawerLinearIssue, setDrawerLinearIssue] = useState<LinearIssue | null>(null)
const [drawerLinearIssueId, setDrawerLinearIssueId] = useState<string | null>(null)
const [drawerLinearIssueFallback, setDrawerLinearIssueFallback] = useState<LinearIssue | null>(
null
)
// Why: the Linear table keeps its own fetched array, while cell edits patch
// the shared caches. Deriving the drawer item from those caches prevents a
// stale row snapshot from mounting in the drawer after status/priority edits.
const drawerLinearIssue = useMemo(() => {
if (!drawerLinearIssueId) {
return null
}
const cachedIssue = linearIssueCache[drawerLinearIssueId]?.data
if (cachedIssue) {
return cachedIssue
}
for (const entry of Object.values(linearSearchCache)) {
const found = entry?.data?.find((issue) => issue.id === drawerLinearIssueId)
if (found) {
return found
}
}
return drawerLinearIssueFallback
}, [drawerLinearIssueId, linearIssueCache, linearSearchCache, drawerLinearIssueFallback])
const setDrawerLinearIssue = useCallback((issue: LinearIssue | null) => {
setDrawerLinearIssueId(issue?.id ?? null)
setDrawerLinearIssueFallback(issue)
}, [])
// Linear tab state
const [linearIssues, setLinearIssues] = useState<LinearIssue[]>([])
@ -607,7 +974,14 @@ export default function TaskPage(): React.JSX.Element {
} finally {
setNewIssueSubmitting(false)
}
}, [isSingleRepo, newIssueBody, newIssueSubmitting, newIssueTitle, primaryRepo])
}, [
isSingleRepo,
newIssueBody,
newIssueSubmitting,
newIssueTitle,
primaryRepo,
setDrawerWorkItem
])
useEffect(() => {
// Why: when a modal is open, let it own Esc dismissal.
@ -1279,14 +1653,7 @@ export default function TaskPage(): React.JSX.Element {
</div>
<div className="flex items-center">
<span
className={cn(
'rounded-full border px-2 py-0.5 text-[10px] font-medium',
getTaskStatusTone(item)
)}
>
{getTaskStatusLabel(item)}
</span>
<GHStatusCell item={item} repoPath={itemRepo?.path ?? null} />
</div>
<Tooltip>
@ -1329,7 +1696,7 @@ export default function TaskPage(): React.JSX.Element {
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" onClick={(e) => e.stopPropagation()}>
<DropdownMenuItem onSelect={() => window.open(item.url, '_blank')}>
<DropdownMenuItem onSelect={() => window.api.shell.openUrl(item.url)}>
<ExternalLink className="size-4" />
Open in browser
</DropdownMenuItem>
@ -1479,21 +1846,12 @@ export default function TaskPage(): React.JSX.Element {
<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 className="flex items-center">
<LinearStatusCell issue={issue} />
</div>
<div className="flex items-center text-xs text-muted-foreground">
{getLinearPriorityLabel(issue.priority)}
<div className="flex items-center">
<LinearPriorityCell issue={issue} />
</div>
<Tooltip>
@ -1530,8 +1888,8 @@ export default function TaskPage(): React.JSX.Element {
<EllipsisVertical className="size-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => window.open(issue.url, '_blank')}>
<DropdownMenuContent align="end" onClick={(e) => e.stopPropagation()}>
<DropdownMenuItem onSelect={() => window.api.shell.openUrl(issue.url)}>
<ExternalLink className="size-4" />
Open in browser
</DropdownMenuItem>

View File

@ -23,6 +23,7 @@ function DropdownMenuTrigger({
function DropdownMenuContent({
className,
sideOffset = 4,
style,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
@ -34,6 +35,10 @@ function DropdownMenuContent({
'z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[11rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-[11px] border border-black/14 bg-[rgba(255,255,255,0.82)] p-1 text-black dark:text-white shadow-[0_16px_36px_rgba(0,0,0,0.24),inset_0_1px_0_rgba(255,255,255,0.14)] backdrop-blur-2xl dark:border-white/14 dark:bg-[rgba(0,0,0,0.72)] dark:shadow-[0_20px_44px_rgba(0,0,0,0.42),inset_0_1px_0_rgba(255,255,255,0.04)] data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
className
)}
// Why: Electron's -webkit-app-region: drag on the titlebar captures
// clicks at the OS level regardless of z-index. Without no-drag,
// dropdown menus that visually overlap the titlebar are unclickable.
style={{ ...style, WebkitAppRegion: 'no-drag' } as React.CSSProperties}
{...props}
/>
</DropdownMenuPrimitive.Portal>

View File

@ -17,8 +17,47 @@ function PopoverContent({
className,
align = 'center',
sideOffset = 4,
style,
onWheel,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
const handleWheel = React.useCallback(
(event: React.WheelEvent<HTMLDivElement>) => {
onWheel?.(event)
if (event.defaultPrevented) {
return
}
const el = event.currentTarget
if (!el.classList.contains('popover-scroll-content') || el.scrollHeight <= el.clientHeight) {
return
}
const delta =
event.deltaMode === WheelEvent.DOM_DELTA_LINE
? event.deltaY * 16
: event.deltaMode === WheelEvent.DOM_DELTA_PAGE
? event.deltaY * el.clientHeight
: event.deltaY
const maxScrollTop = el.scrollHeight - el.clientHeight
const nextScrollTop = Math.max(0, Math.min(maxScrollTop, el.scrollTop + delta))
// Why: issue drawers are Radix dialogs with scroll-lock. These popovers
// are portaled outside the dialog subtree, so native wheel scrolling is
// swallowed even though the scrollbar can be dragged.
if (nextScrollTop !== el.scrollTop) {
const previousScrollTop = el.scrollTop
event.stopPropagation()
requestAnimationFrame(() => {
if (el.scrollTop === previousScrollTop) {
el.scrollTop = nextScrollTop
}
})
}
},
[onWheel]
)
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
@ -26,9 +65,19 @@ function PopoverContent({
align={align}
sideOffset={sideOffset}
className={cn(
'z-[60] rounded-md border border-border/50 bg-popover text-popover-foreground shadow-md outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
'z-[60] overflow-hidden rounded-md border border-border/50 bg-popover text-popover-foreground shadow-md outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
className
)}
// Why: Electron's -webkit-app-region: drag on the titlebar captures
// clicks at the OS level regardless of z-index. Without no-drag,
// popovers that visually overlap the titlebar are unclickable.
style={
{
...style,
WebkitAppRegion: 'no-drag'
} as React.CSSProperties
}
onWheel={handleWheel}
{...props}
/>
</PopoverPrimitive.Portal>

View File

@ -25,6 +25,7 @@ function SheetPortal({ ...props }: React.ComponentProps<typeof SheetPrimitive.Po
function SheetOverlay({
className,
style,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
@ -34,6 +35,10 @@ function SheetOverlay({
'fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
className
)}
// Why: Electron's OS-level drag hit-test ignores z-index. Without
// no-drag, the overlay is transparent to clicks in the titlebar's
// drag strip, so clicking the sheet header buttons drags the window.
style={{ ...style, WebkitAppRegion: 'no-drag' } as React.CSSProperties}
{...props}
/>
)
@ -63,6 +68,7 @@ function SheetContent({
children,
side = 'right',
showCloseButton = true,
style,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> &
VariantProps<typeof sheetContentVariants> & {
@ -74,6 +80,9 @@ function SheetContent({
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(sheetContentVariants({ side }), className)}
// Why: same as SheetOverlay — the sheet content portals to the
// document root and its header overlaps the titlebar drag strip.
style={{ ...style, WebkitAppRegion: 'no-drag' } as React.CSSProperties}
{...props}
>
{children}

View File

@ -0,0 +1,54 @@
import { useCallback, useRef, useState } from 'react'
/**
* Wraps an immediate mutation (no undo delay) with loading/error state
* and optimistic patching. Skips if the same key is already in-flight.
*/
export function useImmediateMutation() {
const [pendingKeys, setPendingKeys] = useState<Set<string>>(new Set())
const pendingRef = useRef(pendingKeys)
pendingRef.current = pendingKeys
const isPending = useCallback((key: string) => pendingKeys.has(key), [pendingKeys])
const run = useCallback(
async <T>(
key: string,
opts: {
mutate: () => Promise<T>
onOptimistic?: () => void
onSuccess?: (result: T) => void
onRevert?: () => void
onError?: (error: string) => void
}
) => {
if (pendingRef.current.has(key)) {
return
}
setPendingKeys((prev) => new Set(prev).add(key))
opts.onOptimistic?.()
try {
const result = await opts.mutate()
const asResult = result as { ok?: boolean; error?: string }
if (asResult && asResult.ok === false) {
opts.onRevert?.()
opts.onError?.(asResult.error ?? 'Update failed')
} else {
opts.onSuccess?.(result)
}
} catch (err) {
opts.onRevert?.()
opts.onError?.(err instanceof Error ? err.message : 'Update failed')
} finally {
setPendingKeys((prev) => {
const next = new Set(prev)
next.delete(key)
return next
})
}
},
[]
)
return { isPending, run }
}

View File

@ -0,0 +1,321 @@
import { useEffect, useRef, useState } from 'react'
import type { LinearWorkflowState, LinearLabel, LinearMember } from '../../../shared/types'
type MetadataState<T> = {
data: T
loading: boolean
error: string | null
}
const METADATA_TTL = 300_000 // 5 min
type CachedMetadata<T> = { data: T; fetchedAt: number }
function isCacheFresh<T>(cache: Map<string, CachedMetadata<T>>, key: string): boolean {
const entry = cache.get(key)
return !!entry && Date.now() - entry.fetchedAt < METADATA_TTL
}
// ─── GitHub ────────────────────────────────────────────────
const ghLabelCache = new Map<string, CachedMetadata<string[]>>()
const ghAssigneeCache = new Map<string, CachedMetadata<string[]>>()
export function useRepoLabels(repoPath: string | null): MetadataState<string[]> {
const [state, setState] = useState<MetadataState<string[]>>({
data: [],
loading: false,
error: null
})
const activeKeyRef = useRef<string | null>(null)
useEffect(() => {
if (!repoPath) {
return
}
const cached = ghLabelCache.get(repoPath)
if (cached && isCacheFresh(ghLabelCache, repoPath)) {
if (activeKeyRef.current !== repoPath) {
setState({ data: cached.data, loading: false, error: null })
activeKeyRef.current = repoPath
}
return
}
activeKeyRef.current = repoPath
const requestKey = repoPath
setState((s) => ({
...s,
data: s.data.length ? ([] as typeof s.data) : s.data,
loading: true,
error: null
}))
window.api.gh
.listLabels({ repoPath })
.then((labels) => {
if (activeKeyRef.current !== requestKey) {
return
}
const data = labels as string[]
ghLabelCache.set(repoPath, { data, fetchedAt: Date.now() })
setState({ data, loading: false, error: null })
})
.catch((err) => {
if (activeKeyRef.current !== requestKey) {
return
}
activeKeyRef.current = null
setState((s) => ({
...s,
loading: false,
error: err instanceof Error ? err.message : 'Failed to load labels'
}))
})
}, [repoPath])
return state
}
export function useRepoAssignees(repoPath: string | null): MetadataState<string[]> {
const [state, setState] = useState<MetadataState<string[]>>({
data: [],
loading: false,
error: null
})
const activeKeyRef = useRef<string | null>(null)
useEffect(() => {
if (!repoPath) {
return
}
const cached = ghAssigneeCache.get(repoPath)
if (cached && isCacheFresh(ghAssigneeCache, repoPath)) {
if (activeKeyRef.current !== repoPath) {
setState({ data: cached.data, loading: false, error: null })
activeKeyRef.current = repoPath
}
return
}
activeKeyRef.current = repoPath
const requestKey = repoPath
setState((s) => ({
...s,
data: s.data.length ? ([] as typeof s.data) : s.data,
loading: true,
error: null
}))
window.api.gh
.listAssignableUsers({ repoPath })
.then((users) => {
if (activeKeyRef.current !== requestKey) {
return
}
const data = users as string[]
ghAssigneeCache.set(repoPath, { data, fetchedAt: Date.now() })
setState({ data, loading: false, error: null })
})
.catch((err) => {
if (activeKeyRef.current !== requestKey) {
return
}
activeKeyRef.current = null
setState((s) => ({
...s,
loading: false,
error: err instanceof Error ? err.message : 'Failed to load assignees'
}))
})
}, [repoPath])
return state
}
// ─── Linear ────────────────────────────────────────────────
const linearStateCache = new Map<string, CachedMetadata<LinearWorkflowState[]>>()
const linearLabelCache = new Map<string, CachedMetadata<LinearLabel[]>>()
const linearMemberCache = new Map<string, CachedMetadata<LinearMember[]>>()
export function clearLinearMetadataCache(): void {
linearStateCache.clear()
linearLabelCache.clear()
linearMemberCache.clear()
}
export function clearGitHubMetadataCache(): void {
ghLabelCache.clear()
ghAssigneeCache.clear()
}
export function useTeamStates(teamId: string | null): MetadataState<LinearWorkflowState[]> {
const [state, setState] = useState<MetadataState<LinearWorkflowState[]>>({
data: [],
loading: false,
error: null
})
const activeKeyRef = useRef<string | null>(null)
useEffect(() => {
if (!teamId) {
return
}
const cached = linearStateCache.get(teamId)
if (cached && isCacheFresh(linearStateCache, teamId)) {
if (activeKeyRef.current !== teamId) {
setState({ data: cached.data, loading: false, error: null })
activeKeyRef.current = teamId
}
return
}
activeKeyRef.current = teamId
const requestKey = teamId
setState((s) => ({
...s,
data: s.data.length ? ([] as typeof s.data) : s.data,
loading: true,
error: null
}))
window.api.linear
.teamStates({ teamId })
.then((states) => {
if (activeKeyRef.current !== requestKey) {
return
}
const data = states as LinearWorkflowState[]
linearStateCache.set(teamId, { data, fetchedAt: Date.now() })
setState({ data, loading: false, error: null })
})
.catch((err) => {
if (activeKeyRef.current !== requestKey) {
return
}
activeKeyRef.current = null
setState((s) => ({
...s,
loading: false,
error: err instanceof Error ? err.message : 'Failed to load states'
}))
})
}, [teamId])
return state
}
export function useTeamLabels(teamId: string | null): MetadataState<LinearLabel[]> {
const [state, setState] = useState<MetadataState<LinearLabel[]>>({
data: [],
loading: false,
error: null
})
const activeKeyRef = useRef<string | null>(null)
useEffect(() => {
if (!teamId) {
return
}
const cached = linearLabelCache.get(teamId)
if (cached && isCacheFresh(linearLabelCache, teamId)) {
if (activeKeyRef.current !== teamId) {
setState({ data: cached.data, loading: false, error: null })
activeKeyRef.current = teamId
}
return
}
activeKeyRef.current = teamId
const requestKey = teamId
setState((s) => ({
...s,
data: s.data.length ? ([] as typeof s.data) : s.data,
loading: true,
error: null
}))
window.api.linear
.teamLabels({ teamId })
.then((labels) => {
if (activeKeyRef.current !== requestKey) {
return
}
const data = labels as LinearLabel[]
linearLabelCache.set(teamId, { data, fetchedAt: Date.now() })
setState({ data, loading: false, error: null })
})
.catch((err) => {
if (activeKeyRef.current !== requestKey) {
return
}
activeKeyRef.current = null
setState((s) => ({
...s,
loading: false,
error: err instanceof Error ? err.message : 'Failed to load labels'
}))
})
}, [teamId])
return state
}
export function useTeamMembers(teamId: string | null): MetadataState<LinearMember[]> {
const [state, setState] = useState<MetadataState<LinearMember[]>>({
data: [],
loading: false,
error: null
})
const activeKeyRef = useRef<string | null>(null)
useEffect(() => {
if (!teamId) {
return
}
const cached = linearMemberCache.get(teamId)
if (cached && isCacheFresh(linearMemberCache, teamId)) {
if (activeKeyRef.current !== teamId) {
setState({ data: cached.data, loading: false, error: null })
activeKeyRef.current = teamId
}
return
}
activeKeyRef.current = teamId
const requestKey = teamId
setState((s) => ({
...s,
data: s.data.length ? ([] as typeof s.data) : s.data,
loading: true,
error: null
}))
window.api.linear
.teamMembers({ teamId })
.then((members) => {
if (activeKeyRef.current !== requestKey) {
return
}
const data = members as LinearMember[]
linearMemberCache.set(teamId, { data, fetchedAt: Date.now() })
setState({ data, loading: false, error: null })
})
.catch((err) => {
if (activeKeyRef.current !== requestKey) {
return
}
activeKeyRef.current = null
setState((s) => ({
...s,
loading: false,
error: err instanceof Error ? err.message : 'Failed to load members'
}))
})
}, [teamId])
return state
}
export { useImmediateMutation } from './useImmediateMutation'

View File

@ -194,6 +194,7 @@ export type GitHubSlice = {
* "new workspace" buttons) to warm the cache before the page mounts.
*/
prefetchWorkItems: (repoId: string, repoPath: string, limit?: number, query?: string) => void
patchWorkItem: (itemId: string, patch: Partial<GitHubWorkItem>) => void
}
export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (set, get) => ({
@ -623,6 +624,28 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
}
},
patchWorkItem: (itemId, patch) => {
set((s) => {
const nextCache = { ...s.workItemsCache }
let changed = false
for (const key of Object.keys(nextCache)) {
const entry = nextCache[key]
if (!entry?.data) {
continue
}
const idx = entry.data.findIndex((item) => item.id === itemId)
if (idx === -1) {
continue
}
const updatedItems = [...entry.data]
updatedItems[idx] = { ...updatedItems[idx], ...patch }
nextCache[key] = { ...entry, data: updatedItems }
changed = true
}
return changed ? { workItemsCache: nextCache } : {}
})
},
// Why: worktree switches previously force-refreshed GitHub data on every
// click, bypassing the 5-min TTL. This variant only fetches when stale,
// avoiding unnecessary API calls and latency during rapid switching.

View File

@ -2,6 +2,7 @@ import type { StateCreator } from 'zustand'
import type { AppState } from '../types'
import type { LinearViewer, LinearConnectionStatus, LinearIssue } from '../../../../shared/types'
import type { CacheEntry } from './github'
import { clearLinearMetadataCache } from '../../hooks/useIssueMetadata'
const CACHE_TTL = 60_000 // 60s — same as GitHub work-items TTL
const MAX_CACHE_ENTRIES = 500
@ -52,6 +53,7 @@ export type LinearSlice = {
filter?: 'assigned' | 'created' | 'all' | 'completed',
limit?: number
) => Promise<LinearIssue[]>
patchLinearIssue: (issueId: string, patch: Partial<LinearIssue>) => void
}
export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (set, get) => ({
@ -101,6 +103,7 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
inflightIssueRequests.clear()
inflightSearchRequests.clear()
inflightListRequests.clear()
clearLinearMetadataCache()
set({
linearStatus: { connected: false, viewer: null },
linearIssueCache: {},
@ -222,5 +225,42 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
inflightListRequests.set(cacheKey, promise)
return promise
},
patchLinearIssue: (issueId, patch) => {
set((s) => {
let changed = false
const nextIssueCache = { ...s.linearIssueCache }
const issueEntry = nextIssueCache[issueId]
if (issueEntry?.data) {
// Why: set fetchedAt to 0 so the next fetchLinearIssue call
// actually hits IPC instead of returning the stale optimistic data.
nextIssueCache[issueId] = {
...issueEntry,
data: { ...issueEntry.data, ...patch },
fetchedAt: 0
}
changed = true
}
const nextSearchCache = { ...s.linearSearchCache }
for (const key of Object.keys(nextSearchCache)) {
const entry = nextSearchCache[key]
if (!entry?.data) {
continue
}
const idx = entry.data.findIndex((item) => item.id === issueId)
if (idx === -1) {
continue
}
const updatedItems = [...entry.data]
updatedItems[idx] = { ...updatedItems[idx], ...patch }
nextSearchCache[key] = { ...entry, data: updatedItems }
changed = true
}
return changed ? { linearIssueCache: nextIssueCache, linearSearchCache: nextSearchCache } : {}
})
}
})

View File

@ -436,6 +436,8 @@ export type GitHubWorkItemDetails = {
baseSha?: string
checks?: PRCheckDetail[]
files?: GitHubPRFile[]
/** Logins of current assignees. Only set for issues. */
assignees?: string[]
}
// ─── Linear ─────────────────────────────────────────────────────────
@ -462,11 +464,14 @@ export type LinearIssue = {
color: string
}
team: {
id: string
name: string
key: string
}
labels: string[]
labelIds: string[]
assignee?: {
id: string
displayName: string
avatarUrl?: string
}
@ -474,6 +479,66 @@ export type LinearIssue = {
updatedAt: string
}
export type LinearComment = {
id: string
body: string
createdAt: string
user?: {
displayName: string
avatarUrl?: string
}
}
// ─── Issue Mutations ────────────────────────────────────────────────
export type GitHubIssueUpdate = {
state?: 'open' | 'closed'
title?: string
addLabels?: string[]
removeLabels?: string[]
addAssignees?: string[]
removeAssignees?: string[]
}
export type LinearIssueUpdate = {
stateId?: string
title?: string
assigneeId?: string | null
priority?: number
labelIds?: string[]
}
export type ClassifiedError = {
type:
| 'permission_denied'
| 'not_found'
| 'validation_error'
| 'rate_limited'
| 'network_error'
| 'unknown'
message: string
}
export type LinearWorkflowState = {
id: string
name: string
type: string
color: string
position: number
}
export type LinearLabel = {
id: string
name: string
color: string
}
export type LinearMember = {
id: string
displayName: string
avatarUrl?: string
}
// ─── Hooks (orca.yaml) ──────────────────────────────────────────────
export type OrcaHooks = {
scripts: {