From faed904aaeffb326cdee4bc52fd2d7ff6ba31e0a Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 24 Apr 2026 13:41:21 -0400 Subject: [PATCH] feat(issues): make issues page writable with inline editing (#1017) --- src/main/github/client.ts | 10 +- src/main/github/gh-utils.ts | 30 + src/main/github/issues.ts | 175 ++++- src/main/github/work-item-details.ts | 22 +- src/main/ipc/github.ts | 44 +- src/main/ipc/linear.ts | 86 ++- src/main/linear/issues.ts | 205 +++++- src/main/linear/mappers.ts | 12 +- src/preload/api-types.d.ts | 30 + src/preload/index.ts | 46 +- src/renderer/src/assets/main.css | 45 ++ .../src/components/GitHubItemDrawer.tsx | 557 ++++++++++++++- .../src/components/LinearItemDrawer.tsx | 642 ++++++++++++++++-- src/renderer/src/components/TaskPage.tsx | 416 +++++++++++- .../src/components/ui/dropdown-menu.tsx | 5 + src/renderer/src/components/ui/popover.tsx | 51 +- src/renderer/src/components/ui/sheet.tsx | 9 + .../src/hooks/useImmediateMutation.ts | 54 ++ src/renderer/src/hooks/useIssueMetadata.ts | 321 +++++++++ src/renderer/src/store/slices/github.ts | 23 + src/renderer/src/store/slices/linear.ts | 40 ++ src/shared/types.ts | 65 ++ 22 files changed, 2756 insertions(+), 132 deletions(-) create mode 100644 src/renderer/src/hooks/useImmediateMutation.ts create mode 100644 src/renderer/src/hooks/useIssueMetadata.ts diff --git a/src/main/github/client.ts b/src/main/github/client.ts index 6e00ac00f..7b2429a32 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -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, diff --git a/src/main/github/gh-utils.ts b/src/main/github/gh-utils.ts index 16d099e6e..bea354587 100644 --- a/src/main/github/gh-utils.ts +++ b/src/main/github/gh-utils.ts @@ -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() diff --git a/src/main/github/issues.ts b/src/main/github/issues.ts index 9df848971..1d0499c4c 100644 --- a/src/main/github/issues.ts +++ b/src/main/github/issues.ts @@ -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 { + 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 { + 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() + } +} diff --git a/src/main/github/work-item-details.ts b/src/main/github/work-item-details.ts index df2cb8510..9aa24e2e1 100644 --- a/src/main/github/work-item-details.ts +++ b/src/main/github/work-item-details.ts @@ -128,7 +128,7 @@ async function getPRFiles(repoPath: string, prNumber: number): Promise { +): 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. diff --git a/src/main/ipc/github.ts b/src/main/ipc/github.ts index 41d10c062..95a81906e 100644 --- a/src/main/ipc/github.ts +++ b/src/main/ipc/github.ts @@ -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()) diff --git a/src/main/ipc/linear.ts b/src/main/ipc/linear.ts index 4652c9998..75a16370b 100644 --- a/src/main/ipc/linear.ts +++ b/src/main/ipc/linear.ts @@ -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(['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()) + }) } diff --git a/src/main/linear/issues.ts b/src/main/linear/issues.ts index 88553ce07..a46046da9 100644 --- a/src/main/linear/issues.ts +++ b/src/main/linear/issues.ts @@ -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 = {} + 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 { + 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 { + 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 { + 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 { + 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() + } +} diff --git a/src/main/linear/mappers.ts b/src/main/linear/mappers.ts index 853f05f80..9a273edd8 100644 --- a/src/main/linear/mappers.ts +++ b/src/main/linear/mappers.ts @@ -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() diff --git a/src/preload/api-types.d.ts b/src/preload/api-types.d.ts index 52579205c..815dfeb06 100644 --- a/src/preload/api-types.d.ts +++ b/src/preload/api-types.d.ts @@ -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 + listAssignableUsers: (args: { repoPath: string }) => Promise checkOrcaStarred: () => Promise starOrca: () => Promise } @@ -425,6 +443,18 @@ export type PreloadApi = { limit?: number }) => Promise getIssue: (args: { id: string }) => Promise + 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 + teamStates: (args: { teamId: string }) => Promise + teamLabels: (args: { teamId: string }) => Promise + teamMembers: (args: { teamId: string }) => Promise } starNag: { onShow: (callback: () => void) => () => void diff --git a/src/preload/index.ts b/src/preload/index.ts index 52b98c7ac..0a46b46fa 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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 => + ipcRenderer.invoke('gh:listLabels', args), + + listAssignableUsers: (args: { repoPath: string }): Promise => + ipcRenderer.invoke('gh:listAssignableUsers', args), + checkOrcaStarred: (): Promise => ipcRenderer.invoke('gh:checkOrcaStarred'), starOrca: (): Promise => ipcRenderer.invoke('gh:starOrca') }, @@ -444,7 +464,31 @@ const api = { }): Promise => ipcRenderer.invoke('linear:listIssues', args), getIssue: (args: { id: string }): Promise => - 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 => + ipcRenderer.invoke('linear:issueComments', args), + + teamStates: (args: { teamId: string }): Promise => + ipcRenderer.invoke('linear:teamStates', args), + + teamLabels: (args: { teamId: string }): Promise => + ipcRenderer.invoke('linear:teamLabels', args), + + teamMembers: (args: { teamId: string }): Promise => + ipcRenderer.invoke('linear:teamMembers', args) }, starNag: { diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index eaf519999..3099ce912 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -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 { diff --git a/src/renderer/src/components/GitHubItemDrawer.tsx b/src/renderer/src/components/GitHubItemDrawer.tsx index 7bfcc2390..77c58d86a 100644 --- a/src/renderer/src/components/GitHubItemDrawer.tsx +++ b/src/renderer/src/components/GitHubItemDrawer.tsx @@ -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(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 = ( + + + + ) + + return ( +
+ {/* State */} + + + + + + + + + + + {/* Labels */} + + + + + + {repoLabels.error ? ( +
+ {repoLabels.error} +
+ ) : ( +
+ {repoLabels.data.map((label) => ( + + ))} +
+ )} +
+
+ + {/* Assignees */} + + + + + + {repoAssignees.error ? ( +
+ {repoAssignees.error} +
+ ) : ( +
+ {repoAssignees.data.map((login) => ( + + ))} +
+ )} +
+
+
+ ) +} + +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(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 ( +
+