feat(issues): make issues page writable with inline editing (#1017)
This commit is contained in:
parent
1772087db5
commit
faed904aae
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>()
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 } : {}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Reference in New Issue