3192 lines
134 KiB
TypeScript
3192 lines
134 KiB
TypeScript
/* eslint-disable max-lines -- Why: the tasks page keeps the repo selector,
|
||
task source controls, and GitHub task list co-located so the wiring between the
|
||
selected repo, the task filters, and the work-item list stays readable in one
|
||
place while this surface is still evolving. */
|
||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||
import {
|
||
ArrowRight,
|
||
ChevronDown,
|
||
ChevronLeft,
|
||
ChevronRight,
|
||
CircleDot,
|
||
EllipsisVertical,
|
||
ExternalLink,
|
||
Github,
|
||
GitPullRequest,
|
||
LoaderCircle,
|
||
Lock,
|
||
Plus,
|
||
RefreshCw,
|
||
Search,
|
||
X
|
||
} from 'lucide-react'
|
||
import { toast } from 'sonner'
|
||
|
||
import { useAppStore } from '@/store'
|
||
import { useRepoMap } from '@/store/selectors'
|
||
import {
|
||
workItemsCacheKey,
|
||
type WorkItemsCacheSources,
|
||
type WorkItemsCacheError
|
||
} from '@/store/slices/github'
|
||
import { Button } from '@/components/ui/button'
|
||
import { Input } from '@/components/ui/input'
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue
|
||
} from '@/components/ui/select'
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle
|
||
} from '@/components/ui/dialog'
|
||
import {
|
||
DropdownMenu,
|
||
DropdownMenuContent,
|
||
DropdownMenuItem,
|
||
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 TeamMultiCombobox from '@/components/ui/team-multi-combobox'
|
||
import RepoDotLabel from '@/components/repo/RepoDotLabel'
|
||
import IssueSourceIndicator, { sameGitHubOwnerRepo } from '@/components/github/IssueSourceIndicator'
|
||
import IssueSourceSelector, { issueSourceChipClass } from '@/components/github/IssueSourceSelector'
|
||
import GitHubRateLimitPill from '@/components/github/GitHubRateLimitPill'
|
||
import { stripRepoQualifiers } from '../../../shared/task-query'
|
||
import GitHubItemDialog from '@/components/GitHubItemDialog'
|
||
import ProjectViewWrapper from '@/components/github-project/ProjectViewWrapper'
|
||
import LinearItemDrawer from '@/components/LinearItemDrawer'
|
||
import { cn } from '@/lib/utils'
|
||
import {
|
||
getLinkedWorkItemSuggestedName,
|
||
getTaskPresetQuery,
|
||
PER_REPO_FETCH_LIMIT,
|
||
CROSS_REPO_DISPLAY_LIMIT
|
||
} from '@/lib/new-workspace'
|
||
import type { LinkedWorkItemSummary } from '@/lib/new-workspace'
|
||
import { isGitRepoKind } from '../../../shared/repo-kind'
|
||
import { useTeamStates } from '@/hooks/useIssueMetadata'
|
||
import type {
|
||
GitHubOwnerRepo,
|
||
GitHubWorkItem,
|
||
LinearIssue,
|
||
TaskViewPresetId
|
||
} from '../../../shared/types'
|
||
import { shouldSuppressEnterSubmit } from '@/lib/new-workspace-enter-guard'
|
||
|
||
type TaskSource = 'github' | 'linear'
|
||
type TaskQueryPreset = {
|
||
id: TaskViewPresetId
|
||
label: string
|
||
query: string
|
||
}
|
||
|
||
type SourceOption = {
|
||
id: TaskSource
|
||
label: string
|
||
Icon: (props: { className?: string }) => React.JSX.Element
|
||
disabled?: boolean
|
||
}
|
||
|
||
function LinearIcon({ className }: { className?: string }): React.JSX.Element {
|
||
return (
|
||
<svg viewBox="0 0 24 24" aria-hidden className={className} fill="currentColor">
|
||
<path d="M2.886 4.18A11.982 11.982 0 0 1 11.99 0C18.624 0 24 5.376 24 12.009c0 3.64-1.62 6.903-4.18 9.105L2.887 4.18ZM1.817 5.626l16.556 16.556c-.524.33-1.075.62-1.65.866L.951 7.277c.247-.575.537-1.126.866-1.65ZM.322 9.163l14.515 14.515c-.71.172-1.443.282-2.195.322L0 11.358a12 12 0 0 1 .322-2.195Zm-.17 4.862 9.823 9.824a12.02 12.02 0 0 1-9.824-9.824Z" />
|
||
</svg>
|
||
)
|
||
}
|
||
|
||
const SOURCE_OPTIONS: SourceOption[] = [
|
||
{
|
||
id: 'github',
|
||
label: 'GitHub',
|
||
Icon: ({ className }) => <Github className={className} />
|
||
},
|
||
{
|
||
id: 'linear',
|
||
label: 'Linear',
|
||
Icon: ({ className }) => <LinearIcon className={className} />
|
||
}
|
||
]
|
||
|
||
const TASK_QUERY_PRESETS: TaskQueryPreset[] = [
|
||
{ id: 'all', label: 'All', query: getTaskPresetQuery('all') },
|
||
{ id: 'issues', label: 'Issues', query: getTaskPresetQuery('issues') },
|
||
{ id: 'my-issues', label: 'My Issues', query: getTaskPresetQuery('my-issues') },
|
||
{ id: 'review', label: 'Needs My Review', query: getTaskPresetQuery('review') },
|
||
{ id: 'prs', label: 'PRs', query: getTaskPresetQuery('prs') },
|
||
{ id: 'my-prs', label: 'My PRs', query: getTaskPresetQuery('my-prs') }
|
||
]
|
||
|
||
type LinearPresetId = 'assigned' | 'created' | 'all' | 'completed'
|
||
type LinearPreset = { id: LinearPresetId; label: string }
|
||
|
||
const LINEAR_PRESETS: LinearPreset[] = [
|
||
{ id: 'all', label: 'All' },
|
||
{ id: 'assigned', label: 'My Issues' },
|
||
{ id: 'created', label: 'Created' },
|
||
{ id: 'completed', label: 'Completed' }
|
||
]
|
||
|
||
const TASK_SEARCH_DEBOUNCE_MS = 300
|
||
const LINEAR_ITEM_LIMIT = 36
|
||
|
||
// Why: Intl.RelativeTimeFormat allocation is non-trivial, and previously we
|
||
// built a new formatter per work-item row render. Hoisting to module scope
|
||
// means all rows share one instance — zero per-row allocation cost.
|
||
const relativeTimeFormatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
|
||
|
||
function formatRelativeTime(input: string): string {
|
||
const date = new Date(input)
|
||
if (Number.isNaN(date.getTime())) {
|
||
return 'recently'
|
||
}
|
||
|
||
const diffMs = date.getTime() - Date.now()
|
||
const diffMinutes = Math.round(diffMs / 60_000)
|
||
|
||
if (Math.abs(diffMinutes) < 60) {
|
||
return relativeTimeFormatter.format(diffMinutes, 'minute')
|
||
}
|
||
|
||
const diffHours = Math.round(diffMinutes / 60)
|
||
if (Math.abs(diffHours) < 24) {
|
||
return relativeTimeFormatter.format(diffHours, 'hour')
|
||
}
|
||
|
||
const diffDays = Math.round(diffHours / 24)
|
||
return relativeTimeFormatter.format(diffDays, 'day')
|
||
}
|
||
|
||
function getTaskStatusLabel(item: GitHubWorkItem): string {
|
||
if (item.type === 'issue') {
|
||
return 'Open'
|
||
}
|
||
if (item.state === 'draft') {
|
||
return 'Draft'
|
||
}
|
||
return 'Ready'
|
||
}
|
||
|
||
function getTaskStatusTone(item: GitHubWorkItem): string {
|
||
if (item.type === 'issue') {
|
||
return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-200'
|
||
}
|
||
if (item.state === 'draft') {
|
||
return 'border-slate-500/30 bg-slate-500/10 text-slate-600 dark:text-slate-300'
|
||
}
|
||
return 'border-cyan-500/30 bg-cyan-500/10 text-cyan-700 dark:text-cyan-200'
|
||
}
|
||
|
||
// Why: Linear encodes priority as an integer (0–4). Map to human-readable
|
||
// labels so the table column is scannable without memorising the scale.
|
||
const LINEAR_PRIORITY_LABELS: Record<number, string> = {
|
||
0: 'None',
|
||
1: 'Urgent',
|
||
2: 'High',
|
||
3: 'Medium',
|
||
4: 'Low'
|
||
}
|
||
|
||
function 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 opacity-70',
|
||
getTaskStatusTone(item)
|
||
)}
|
||
>
|
||
{getTaskStatusLabel(item)}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<Popover open={open} onOpenChange={setOpen}>
|
||
<PopoverTrigger asChild>
|
||
<button
|
||
type="button"
|
||
onClick={(e) => e.stopPropagation()}
|
||
className={cn(
|
||
'group/status inline-flex items-center gap-0.5 rounded-full border px-2 py-0.5 text-[10px] font-medium transition hover:brightness-125 hover:ring-1 hover:ring-white/10',
|
||
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'}
|
||
<ChevronDown className="size-2.5 opacity-50" />
|
||
</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="group/status 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>
|
||
<ChevronDown className="size-2.5 shrink-0 text-muted-foreground opacity-50" />
|
||
</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="group/priority inline-flex items-center gap-0.5 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" />
|
||
) : (
|
||
<ChevronDown className="size-2.5 shrink-0 opacity-50" />
|
||
)}
|
||
</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>
|
||
)
|
||
}
|
||
|
||
// Why: builds the page number array with ellipsis gaps, matching GitHub's
|
||
// pagination pattern: always show first page, last page, and a window of
|
||
// pages around the current page with "..." gaps between distant ranges.
|
||
function getPageNumbers(current: number, total: number): (number | 'ellipsis')[] {
|
||
if (total <= 9) {
|
||
return Array.from({ length: total }, (_, i) => i)
|
||
}
|
||
const pages = new Set<number>()
|
||
pages.add(0)
|
||
pages.add(total - 1)
|
||
for (let i = Math.max(0, current - 2); i <= Math.min(total - 1, current + 2); i++) {
|
||
pages.add(i)
|
||
}
|
||
const sorted = [...pages].sort((a, b) => a - b)
|
||
const result: (number | 'ellipsis')[] = []
|
||
for (let i = 0; i < sorted.length; i++) {
|
||
if (i > 0 && sorted[i] - sorted[i - 1] > 1) {
|
||
result.push('ellipsis')
|
||
}
|
||
result.push(sorted[i])
|
||
}
|
||
return result
|
||
}
|
||
|
||
function PaginationBar({
|
||
currentPage,
|
||
totalPages,
|
||
loadingTarget,
|
||
onPageChange
|
||
}: {
|
||
currentPage: number
|
||
totalPages: number
|
||
loadingTarget: number | null
|
||
onPageChange: (page: number) => void
|
||
}): React.JSX.Element {
|
||
const pageNumbers = getPageNumbers(currentPage, totalPages)
|
||
const btnClass =
|
||
'inline-flex items-center gap-0.5 rounded-md px-2 py-1 text-sm text-muted-foreground transition hover:bg-muted/60 hover:text-foreground disabled:pointer-events-none disabled:opacity-40'
|
||
const numClass = (page: number): string =>
|
||
cn(
|
||
'inline-flex size-8 items-center justify-center rounded-md text-sm transition',
|
||
page === currentPage
|
||
? 'bg-primary text-primary-foreground font-medium'
|
||
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'
|
||
)
|
||
|
||
return (
|
||
<nav
|
||
aria-label="Pagination"
|
||
className="flex items-center justify-center gap-1 border-t border-border/50 px-4 py-3"
|
||
>
|
||
<button
|
||
type="button"
|
||
disabled={currentPage === 0 || loadingTarget !== null}
|
||
onClick={() => onPageChange(currentPage - 1)}
|
||
aria-label="Previous page"
|
||
className={btnClass}
|
||
>
|
||
<ChevronLeft className="size-4" />
|
||
Previous
|
||
</button>
|
||
|
||
{pageNumbers.map((entry, idx) =>
|
||
entry === 'ellipsis' ? (
|
||
<span
|
||
key={`ellipsis-${idx}`}
|
||
aria-hidden
|
||
className="inline-flex size-8 items-center justify-center text-sm text-muted-foreground"
|
||
>
|
||
…
|
||
</span>
|
||
) : (
|
||
<button
|
||
key={entry}
|
||
type="button"
|
||
disabled={loadingTarget !== null && loadingTarget !== entry}
|
||
onClick={() => onPageChange(entry)}
|
||
aria-label={`Page ${entry + 1}`}
|
||
aria-current={entry === currentPage ? 'page' : undefined}
|
||
className={numClass(entry)}
|
||
>
|
||
{loadingTarget === entry ? (
|
||
<LoaderCircle className="size-3.5 animate-spin" />
|
||
) : (
|
||
entry + 1
|
||
)}
|
||
</button>
|
||
)
|
||
)}
|
||
|
||
<button
|
||
type="button"
|
||
disabled={currentPage >= totalPages - 1 || loadingTarget !== null}
|
||
onClick={() => onPageChange(currentPage + 1)}
|
||
aria-label="Next page"
|
||
className={btnClass}
|
||
>
|
||
Next
|
||
<ChevronRight className="size-4" />
|
||
</button>
|
||
</nav>
|
||
)
|
||
}
|
||
|
||
// Why: feature 1 — shape of the per-repo view derived from `workItemsCache`,
|
||
// used by both the indicator render and the `hasDivergentSources` guard.
|
||
// Hoisted to module scope so the type isn't re-parsed per render and so the
|
||
// guard below can narrow it without a forward reference.
|
||
type RepoSourceState = {
|
||
repoId: string
|
||
repoPath: string
|
||
sources: WorkItemsCacheSources | null
|
||
error: WorkItemsCacheError | null
|
||
}
|
||
|
||
// Why: type-guard predicate used to filter `perRepoSourceState` down to rows
|
||
// whose issue-source and PR-source slugs differ. Hoisted to module scope so
|
||
// the predicate isn't re-allocated on every TaskPage render.
|
||
const hasDivergentSources = (
|
||
s: RepoSourceState
|
||
): s is RepoSourceState & {
|
||
sources: { issues: GitHubOwnerRepo; prs: GitHubOwnerRepo }
|
||
} => !!s.sources?.issues && !!s.sources.prs && !sameGitHubOwnerRepo(s.sources.issues, s.sources.prs)
|
||
|
||
// Why: the selector keeps rendering even after the user picks 'origin' (which
|
||
// collapses `sources.issues` onto origin). Upstream-candidate divergence is
|
||
// the right render gate — a repo that has an `upstream` remote pointing
|
||
// somewhere different from origin is always a candidate for the toggle,
|
||
// regardless of the current effective preference.
|
||
const hasUpstreamCandidateDivergence = (
|
||
s: RepoSourceState
|
||
): s is RepoSourceState & {
|
||
sources: { prs: GitHubOwnerRepo; upstreamCandidate: GitHubOwnerRepo }
|
||
} =>
|
||
!!s.sources?.prs &&
|
||
!!s.sources.upstreamCandidate &&
|
||
!sameGitHubOwnerRepo(s.sources.prs, s.sources.upstreamCandidate)
|
||
|
||
export default function TaskPage(): React.JSX.Element {
|
||
const settings = useAppStore((s) => s.settings)
|
||
const persistedUIReady = useAppStore((s) => s.persistedUIReady)
|
||
const taskResumeState = useAppStore((s) => s.taskResumeState)
|
||
const setTaskResumeState = useAppStore((s) => s.setTaskResumeState)
|
||
const pageData = useAppStore((s) => s.taskPageData)
|
||
const closeTaskPage = useAppStore((s) => s.closeTaskPage)
|
||
const activeModal = useAppStore((s) => s.activeModal)
|
||
const repos = useAppStore((s) => s.repos)
|
||
const repoMap = useRepoMap()
|
||
const openModal = useAppStore((s) => s.openModal)
|
||
const updateSettings = useAppStore((s) => s.updateSettings)
|
||
const fetchWorkItemsAcrossRepos = useAppStore((s) => s.fetchWorkItemsAcrossRepos)
|
||
const getCachedWorkItems = useAppStore((s) => s.getCachedWorkItems)
|
||
const setIssueSourcePreference = useAppStore((s) => s.setIssueSourcePreference)
|
||
// Why: bumped by `setIssueSourcePreference` after cache eviction so the
|
||
// fetch effect below re-runs and repopulates work-items against the new
|
||
// source. Eviction alone isn't enough because the effect's deps don't
|
||
// include `workItemsCache`.
|
||
const workItemsInvalidationNonce = useAppStore((s) => s.workItemsInvalidationNonce)
|
||
const linearStatus = useAppStore((s) => s.linearStatus)
|
||
const linearStatusChecked = useAppStore((s) => s.linearStatusChecked)
|
||
const connectLinear = useAppStore((s) => s.connectLinear)
|
||
const searchLinearIssues = useAppStore((s) => s.searchLinearIssues)
|
||
const listLinearIssues = useAppStore((s) => s.listLinearIssues)
|
||
const checkLinearConnection = useAppStore((s) => s.checkLinearConnection)
|
||
// Why: in workspace view (a worktree is active) App.tsx hides its
|
||
// full-width titlebar, so this page renders its own 42px titlebar strip to
|
||
// keep the top band continuous with the sidebar header and tab rows. When
|
||
// the sidebar is also collapsed, App.tsx floats its titlebar-left controls
|
||
// (traffic lights, sidebar toggle, agent badge) over our strip — reserve
|
||
// the measured width of those controls on the left so the titlebar strip
|
||
// never sits behind them. In non-workspace mode App.tsx already owns the
|
||
// top titlebar, so skip our strip to avoid a duplicate band.
|
||
const sidebarOpen = useAppStore((s) => s.sidebarOpen)
|
||
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
|
||
const workspaceActive = activeWorktreeId !== null
|
||
const reserveCollapsedHeaderSpace = workspaceActive && !sidebarOpen
|
||
|
||
const eligibleRepos = useMemo(() => repos.filter((repo) => isGitRepoKind(repo)), [repos])
|
||
|
||
// Why: initial selection resolution honors (1) an explicit preselection from
|
||
// the caller, (2) the persisted defaultRepoSelection (null = sticky-all,
|
||
// array = curated subset, empty after filter = fall back to all), (3) fall
|
||
// back to "all eligible". An explicit preselection wins so "open tasks for
|
||
// this specific repo" entry points still land on a single-repo view.
|
||
const resolvedInitialSelection = useMemo<ReadonlySet<string>>(() => {
|
||
const preferred = pageData.preselectedRepoId
|
||
if (preferred && eligibleRepos.some((repo) => repo.id === preferred)) {
|
||
return new Set([preferred])
|
||
}
|
||
const persisted = settings?.defaultRepoSelection
|
||
if (Array.isArray(persisted)) {
|
||
const filtered = persisted.filter((id) => eligibleRepos.some((r) => r.id === id))
|
||
if (filtered.length > 0) {
|
||
return new Set(filtered)
|
||
}
|
||
// Why: empty after filtering (e.g. all persisted repos were removed)
|
||
// falls through to "all eligible" so the page never renders with an
|
||
// empty selection — see the multi-combobox invariant.
|
||
}
|
||
return new Set(eligibleRepos.map((r) => r.id))
|
||
}, [eligibleRepos, pageData.preselectedRepoId, settings?.defaultRepoSelection])
|
||
|
||
const [repoSelection, setRepoSelection] = useState<ReadonlySet<string>>(resolvedInitialSelection)
|
||
|
||
// Why: prune selection when a previously-selected repo is removed, and
|
||
// preserve sticky-all (when the selection equaled every eligible repo
|
||
// pre-change, keep it equal to every eligible repo post-change so "All
|
||
// repos" stays truthful). Recreating the Set every time eligibleRepos
|
||
// changes would churn the fetch effect — only write when the identity of
|
||
// the selection actually needs to change.
|
||
const prevEligibleCountRef = useRef(eligibleRepos.length)
|
||
useEffect(() => {
|
||
const prevCount = prevEligibleCountRef.current
|
||
prevEligibleCountRef.current = eligibleRepos.length
|
||
const eligibleIds = new Set(eligibleRepos.map((r) => r.id))
|
||
const wasAll = repoSelection.size === prevCount && prevCount > 0
|
||
const pruned = new Set<string>()
|
||
for (const id of repoSelection) {
|
||
if (eligibleIds.has(id)) {
|
||
pruned.add(id)
|
||
}
|
||
}
|
||
if (wasAll) {
|
||
const allNow = new Set(eligibleIds)
|
||
if (allNow.size !== repoSelection.size || [...allNow].some((id) => !repoSelection.has(id))) {
|
||
setRepoSelection(allNow)
|
||
}
|
||
return
|
||
}
|
||
if (pruned.size === 0 && eligibleIds.size > 0) {
|
||
setRepoSelection(new Set(eligibleIds))
|
||
return
|
||
}
|
||
if (pruned.size !== repoSelection.size) {
|
||
setRepoSelection(pruned)
|
||
}
|
||
}, [eligibleRepos, repoSelection])
|
||
|
||
const selectedRepos = useMemo(
|
||
() => eligibleRepos.filter((r) => repoSelection.has(r.id)),
|
||
[eligibleRepos, repoSelection]
|
||
)
|
||
|
||
// Why: many affordances (new-issue dialog default, item dialog repo path lookup,
|
||
// optimistic stub) need *a* repo. First selected is used as the default;
|
||
// cross-repo dialogs still let the user override per-action.
|
||
const primaryRepo = selectedRepos[0] ?? null
|
||
|
||
// Why: seed the preset + query from the user's saved default synchronously
|
||
// so the first fetch effect issues exactly one request keyed to the final
|
||
// query. Previously a separate effect "re-seeded" these after mount, which
|
||
// caused a throwaway empty-query fetch followed by a second fetch for the
|
||
// real default — doubling the time-to-first-paint of the list.
|
||
const defaultTaskViewPreset = settings?.defaultTaskViewPreset ?? 'all'
|
||
const initialTaskQuery = getTaskPresetQuery(defaultTaskViewPreset)
|
||
|
||
const defaultTaskSource = settings?.defaultTaskSource ?? 'github'
|
||
const [taskSource, setTaskSource] = useState<TaskSource>(pageData.taskSource ?? defaultTaskSource)
|
||
const taskResumeAppliedRef = useRef(false)
|
||
const githubSearchPersistReadyRef = useRef(false)
|
||
const linearSearchPersistReadyRef = useRef(false)
|
||
const [taskResumeApplied, setTaskResumeApplied] = useState(false)
|
||
|
||
// Why: pageData.taskSource changes when the user clicks a specific source
|
||
// icon in the sidebar while the task page is already open. useState only
|
||
// initializes once, so sync from the store when the value changes.
|
||
useEffect(() => {
|
||
if (pageData.taskSource) {
|
||
setTaskSource(pageData.taskSource)
|
||
}
|
||
}, [pageData.taskSource])
|
||
|
||
// Why: Project mode is a sub-tab within the GitHub source. Visible whenever
|
||
// the user is on the GitHub task source — actual entry into Project mode is
|
||
// gated on a non-null `activeProject` once they pick one.
|
||
const projectModeVisible = taskSource === 'github'
|
||
const [githubMode, setGithubMode] = useState<'items' | 'project'>('items')
|
||
|
||
const [taskSearchInput, setTaskSearchInput] = useState(initialTaskQuery)
|
||
const [appliedTaskSearch, setAppliedTaskSearch] = useState(initialTaskQuery)
|
||
const [activeTaskPreset, setActiveTaskPreset] = useState<TaskViewPresetId | null>(
|
||
defaultTaskViewPreset
|
||
)
|
||
const [tasksLoading, setTasksLoading] = useState(false)
|
||
const [tasksError, setTasksError] = useState<string | null>(null)
|
||
// Why: per-repo failure count surfaced through the "N of M" banner. IPC-level
|
||
// rejections populate tasksError instead — the two are mutually exclusive so
|
||
// a successful-with-partial-failure read and a hard-reject don't double-show.
|
||
const [failedCount, setFailedCount] = useState(0)
|
||
const [taskRefreshNonce, setTaskRefreshNonce] = useState(0)
|
||
// Why: the fetch effect uses this to detect when a nonce bump is from the
|
||
// user clicking the refresh button (force=true) vs. re-running for any
|
||
// other reason — e.g. a repo change while the nonce happens to be > 0.
|
||
const lastFetchedNonceRef = useRef(-1)
|
||
// Why: analogous to `lastFetchedNonceRef` for the invalidation nonce. A
|
||
// preference flip should force the dispatch past fetch-dedupe (same repos +
|
||
// same query, cache just evicted — without `force: true` the fan-out could
|
||
// collapse onto a stale in-flight request that resolved against the
|
||
// pre-flip source).
|
||
const lastFetchedInvalidationNonceRef = useRef(0)
|
||
// Why: pages holds all fetched pages of work items. Page 0 is seeded from
|
||
// cache for instant first paint; subsequent pages are loaded via date cursors.
|
||
const [pages, setPages] = useState<GitHubWorkItem[][]>(() => {
|
||
const trimmed = initialTaskQuery.trim()
|
||
const merged: GitHubWorkItem[] = []
|
||
for (const r of selectedRepos) {
|
||
const cached = getCachedWorkItems(r.path, PER_REPO_FETCH_LIMIT, trimmed)
|
||
if (cached) {
|
||
merged.push(...cached)
|
||
}
|
||
}
|
||
if (merged.length === 0) {
|
||
return [[]]
|
||
}
|
||
const page0 = [...merged]
|
||
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
|
||
.slice(0, CROSS_REPO_DISPLAY_LIMIT)
|
||
return [page0]
|
||
})
|
||
const [currentPage, setCurrentPage] = useState(0)
|
||
const [paginationLoading, setPaginationLoading] = useState(false)
|
||
const [loadingTargetPage, setLoadingTargetPage] = useState<number | null>(null)
|
||
const [totalItemCount, setTotalItemCount] = useState<number | null>(null)
|
||
const fetchWorkItemsNextPage = useAppStore((s) => s.fetchWorkItemsNextPage)
|
||
const countWorkItemsAcrossRepos = useAppStore((s) => s.countWorkItemsAcrossRepos)
|
||
|
||
// Why: clicking a GitHub row (or completing the create-issue flow) opens
|
||
// this dialog for a read/review surface. The dialog'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 [dialogWorkItemKey, setDialogWorkItemKey] = useState<{
|
||
id: string
|
||
repoId: string
|
||
} | null>(null)
|
||
const [dialogWorkItemFallback, setDialogWorkItemFallback] = 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 dialog'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.
|
||
// Disambiguates by repoId so issues with the same number fetched from
|
||
// multiple repos (e.g. fork + non-fork, both routed through the same
|
||
// upstream) resolve to the clicked row's repo, not the first one scanned.
|
||
const dialogWorkItem = useMemo(() => {
|
||
if (!dialogWorkItemKey) {
|
||
return null
|
||
}
|
||
for (const entry of Object.values(workItemsCache)) {
|
||
const found = entry?.data?.find(
|
||
(wi) => wi.id === dialogWorkItemKey.id && wi.repoId === dialogWorkItemKey.repoId
|
||
)
|
||
if (found) {
|
||
return found
|
||
}
|
||
}
|
||
return dialogWorkItemFallback
|
||
}, [dialogWorkItemKey, workItemsCache, dialogWorkItemFallback])
|
||
|
||
const setDialogWorkItem = useCallback((item: GitHubWorkItem | null) => {
|
||
setDialogWorkItemKey(item ? { id: item.id, repoId: item.repoId } : null)
|
||
setDialogWorkItemFallback(item)
|
||
}, [])
|
||
|
||
// Why: feature 1 — render the "Issues from {owner}/{repo}" indicator per
|
||
// selected repo whose issue-source and PR-source slugs differ, and surface
|
||
// a per-repo retryable banner when the issue-side fetch failed. Both derive
|
||
// from the same `workItemsCache` entry the list already consumes, so no
|
||
// extra IPC round-trip is needed. The `RepoSourceState` shape itself lives
|
||
// at module scope so the type isn't re-parsed per render.
|
||
// Why: subscribe to `workItemsCache` directly (already bound above) and
|
||
// memoize the derived per-repo view. The alternative —
|
||
// `useAppStore(useShallow(...))` — doesn't help here because the selector
|
||
// would allocate a wrapper object per repo and zustand's shallow compare
|
||
// uses `Object.is` on each element, so every cache mutation would still
|
||
// force a re-render. Memoizing over stable inputs re-derives only when the
|
||
// cache, selection, or query changes. The dialog's `WorkItemIssueSourceIndicator`
|
||
// subscribes to a single `getWorkItemsAnySourcesForRepo(repoPath, limit)`
|
||
// selector that returns a stable `WorkItemsCacheSources | null` reference,
|
||
// so it doesn't need `useShallow` — unrelated cache writes don't force a
|
||
// re-render because the selector result's reference identity is preserved
|
||
// between unchanged entries.
|
||
const perRepoSourceState = useMemo<RepoSourceState[]>(() => {
|
||
const appliedQ = stripRepoQualifiers(appliedTaskSearch.trim())
|
||
return selectedRepos.map((r) => {
|
||
const key = workItemsCacheKey(r.path, PER_REPO_FETCH_LIMIT, appliedQ)
|
||
const entry = workItemsCache[key]
|
||
return {
|
||
repoId: r.id,
|
||
repoPath: r.path,
|
||
sources: entry?.sources ?? null,
|
||
error: entry?.error ?? null
|
||
}
|
||
})
|
||
}, [selectedRepos, appliedTaskSearch, workItemsCache])
|
||
|
||
// Why: surface a one-time toast per session per repo when the user's
|
||
// preferred `'upstream'` is no longer configured and we fell back to
|
||
// origin. Gated on a ref-backed set so repeated list refreshes don't
|
||
// re-toast. We deliberately do NOT auto-reset the preference — the user
|
||
// may re-add `upstream` later and expect it to pick up again.
|
||
const fellBackToastedRef = useRef<Set<string>>(new Set())
|
||
useEffect(() => {
|
||
if (taskSource !== 'github') {
|
||
return
|
||
}
|
||
const appliedQ = stripRepoQualifiers(appliedTaskSearch.trim())
|
||
for (const r of selectedRepos) {
|
||
const key = workItemsCacheKey(r.path, PER_REPO_FETCH_LIMIT, appliedQ)
|
||
const entry = workItemsCache[key]
|
||
if (!entry?.issueSourceFellBack) {
|
||
continue
|
||
}
|
||
if (fellBackToastedRef.current.has(r.id)) {
|
||
continue
|
||
}
|
||
const prSlug = entry.sources?.prs
|
||
? `${entry.sources.prs.owner}/${entry.sources.prs.repo}`
|
||
: r.displayName
|
||
toast.message(
|
||
`Your preferred issue source (upstream) is no longer configured for ${prSlug}. Using origin.`
|
||
)
|
||
fellBackToastedRef.current.add(r.id)
|
||
}
|
||
}, [selectedRepos, appliedTaskSearch, workItemsCache, taskSource])
|
||
|
||
// Why: on a partial-failure retry the cache still holds successful-side
|
||
// data, so `tasksLoading` (which is gated on `anyUncached`) never flips
|
||
// true and the Retry button would otherwise give no feedback. Track
|
||
// retry-in-flight per repo (keyed by `repoPath`) so that clicking Retry
|
||
// on one banner only flips that banner's button into its "Retrying…"
|
||
// state — other still-failing banners stay in their "Retry" state rather
|
||
// than misleadingly flipping in lockstep. The fetch effect clears the set
|
||
// when the nonce-driven refresh settles.
|
||
const [retryingRepoPaths, setRetryingRepoPaths] = useState<ReadonlySet<string>>(() => new Set())
|
||
|
||
const handleRetryIssuesFetch = useCallback(
|
||
(repoPath: string) => {
|
||
const repo = selectedRepos.find((r) => r.path === repoPath)
|
||
if (!repo) {
|
||
return
|
||
}
|
||
// Why: bumping the shared refresh nonce reuses the Tasks list's
|
||
// single fetch path — nonce changes are treated as force=true so
|
||
// retry doesn't silently dedupe onto a still-failing in-flight request.
|
||
// The nonce bump refreshes ALL selected repos, but the Retrying…
|
||
// state is scoped to the clicked repo so other banners stay in their
|
||
// "Retry" state rather than misleadingly flipping to "Retrying…".
|
||
setRetryingRepoPaths((prev) => {
|
||
const next = new Set(prev)
|
||
next.add(repoPath)
|
||
return next
|
||
})
|
||
setTaskRefreshNonce((n) => n + 1)
|
||
},
|
||
[selectedRepos]
|
||
)
|
||
const [newIssueOpen, setNewIssueOpen] = useState(false)
|
||
const [newIssueTitle, setNewIssueTitle] = useState('')
|
||
const [newIssueBody, setNewIssueBody] = useState('')
|
||
const [newIssueSubmitting, setNewIssueSubmitting] = useState(false)
|
||
const [newIssueRepoId, setNewIssueRepoId] = useState<string | null>(null)
|
||
|
||
// Why: resolve the target repo from the user's choice, falling back to the
|
||
// first selected repo if the chosen id drops out of the selection while the
|
||
// dialog is open — keeps submit always landing on a valid repo.
|
||
const newIssueTargetRepo = useMemo(
|
||
() => selectedRepos.find((r) => r.id === newIssueRepoId) ?? selectedRepos[0] ?? null,
|
||
[selectedRepos, newIssueRepoId]
|
||
)
|
||
|
||
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[]>([])
|
||
const [linearLoading, setLinearLoading] = useState(false)
|
||
const [linearError, setLinearError] = useState<string | null>(null)
|
||
const [linearSearchInput, setLinearSearchInput] = useState('')
|
||
const [appliedLinearSearch, setAppliedLinearSearch] = useState('')
|
||
const [activeLinearPreset, setActiveLinearPreset] = useState<LinearPresetId>('all')
|
||
const [linearRefreshNonce, setLinearRefreshNonce] = useState(0)
|
||
|
||
useEffect(() => {
|
||
if (taskResumeAppliedRef.current || !persistedUIReady || !settings) {
|
||
return
|
||
}
|
||
|
||
setTaskSource(pageData.taskSource ?? settings.defaultTaskSource)
|
||
setRepoSelection(resolvedInitialSelection)
|
||
|
||
const nextGithubMode = taskResumeState?.githubMode ?? 'items'
|
||
setGithubMode(nextGithubMode)
|
||
|
||
const preset = taskResumeState?.githubItemsPreset
|
||
if (preset === null) {
|
||
const query = taskResumeState?.githubItemsQuery ?? ''
|
||
setTaskSearchInput(query)
|
||
setAppliedTaskSearch(query)
|
||
setActiveTaskPreset(null)
|
||
} else {
|
||
const presetId = preset ?? settings.defaultTaskViewPreset
|
||
const query = getTaskPresetQuery(presetId)
|
||
setTaskSearchInput(query)
|
||
setAppliedTaskSearch(query)
|
||
setActiveTaskPreset(presetId)
|
||
}
|
||
|
||
const linearPreset = taskResumeState?.linearPreset ?? 'all'
|
||
const linearQuery = taskResumeState?.linearQuery ?? ''
|
||
setActiveLinearPreset(linearPreset)
|
||
setLinearSearchInput(linearQuery)
|
||
setAppliedLinearSearch(linearQuery)
|
||
|
||
// Why: settings and persisted UI hydrate asynchronously. Apply the restored
|
||
// Tasks context exactly once so later source/filter clicks remain local.
|
||
taskResumeAppliedRef.current = true
|
||
setTaskResumeApplied(true)
|
||
}, [persistedUIReady, settings, pageData.taskSource, resolvedInitialSelection, taskResumeState])
|
||
|
||
// Why: fetch the full team list from the Linear API so the selector shows
|
||
// all teams the user belongs to, not just teams with issues in the current
|
||
// fetch window. Fetched once when the Linear tab is active and connected.
|
||
const [availableTeams, setAvailableTeams] = useState<{ id: string; name: string; key: string }[]>(
|
||
[]
|
||
)
|
||
|
||
useEffect(() => {
|
||
if (!taskResumeApplied) {
|
||
return
|
||
}
|
||
if (taskSource !== 'linear' || !linearStatus.connected) {
|
||
return
|
||
}
|
||
void window.api.linear
|
||
.listTeams()
|
||
.then(setAvailableTeams)
|
||
.catch(() => {
|
||
console.warn('[TaskPage] Failed to fetch Linear teams')
|
||
})
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [taskSource, linearStatus.connected, taskResumeApplied])
|
||
|
||
const defaultLinearTeamSelection = settings?.defaultLinearTeamSelection
|
||
const [linearTeamSelection, setLinearTeamSelection] = useState<ReadonlySet<string>>(() => {
|
||
if (!defaultLinearTeamSelection) {
|
||
return new Set<string>()
|
||
}
|
||
return new Set(defaultLinearTeamSelection)
|
||
})
|
||
|
||
// Why: in sticky-all mode, auto-include all teams once the list arrives.
|
||
// In explicit-selection mode, the set is already correct from the initializer.
|
||
useEffect(() => {
|
||
if (availableTeams.length === 0) {
|
||
return
|
||
}
|
||
if (!defaultLinearTeamSelection) {
|
||
setLinearTeamSelection(new Set(availableTeams.map((t) => t.id)))
|
||
}
|
||
}, [availableTeams, defaultLinearTeamSelection])
|
||
|
||
const filteredLinearIssues = useMemo(
|
||
() => linearIssues.filter((issue) => linearTeamSelection.has(issue.team.id)),
|
||
[linearIssues, linearTeamSelection]
|
||
)
|
||
// New Linear issue dialog state
|
||
const [newLinearIssueOpen, setNewLinearIssueOpen] = useState(false)
|
||
const [newLinearIssueTitle, setNewLinearIssueTitle] = useState('')
|
||
const [newLinearIssueBody, setNewLinearIssueBody] = useState('')
|
||
const [newLinearIssueTeamId, setNewLinearIssueTeamId] = useState<string | null>(null)
|
||
const [newLinearIssueSubmitting, setNewLinearIssueSubmitting] = useState(false)
|
||
|
||
const newLinearIssueTargetTeam = useMemo(
|
||
() => availableTeams.find((t) => t.id === newLinearIssueTeamId) ?? availableTeams[0] ?? null,
|
||
[availableTeams, newLinearIssueTeamId]
|
||
)
|
||
|
||
const [linearConnectOpen, setLinearConnectOpen] = useState(false)
|
||
const [linearApiKeyDraft, setLinearApiKeyDraft] = useState('')
|
||
const [linearConnectState, setLinearConnectState] = useState<'idle' | 'connecting' | 'error'>(
|
||
'idle'
|
||
)
|
||
const [linearConnectError, setLinearConnectError] = useState<string | null>(null)
|
||
|
||
// Why: defense-in-depth safety net applied to the current page's items.
|
||
// The server-side query now includes is:issue / is:pr qualifiers so this
|
||
// filter is a no-op in the happy path. Kept as a guard against parser
|
||
// regressions or stale cache contamination.
|
||
const applyTypeFilter = useCallback(
|
||
(items: GitHubWorkItem[]) => {
|
||
if (!activeTaskPreset) {
|
||
return items
|
||
}
|
||
return items.filter((item) => {
|
||
if (activeTaskPreset === 'issues' || activeTaskPreset === 'my-issues') {
|
||
return item.type === 'issue'
|
||
}
|
||
if (
|
||
activeTaskPreset === 'prs' ||
|
||
activeTaskPreset === 'my-prs' ||
|
||
activeTaskPreset === 'review'
|
||
) {
|
||
return item.type === 'pr'
|
||
}
|
||
return true
|
||
})
|
||
},
|
||
[activeTaskPreset]
|
||
)
|
||
|
||
const currentPageItems = useMemo(() => pages[currentPage] ?? [], [pages, currentPage])
|
||
|
||
const filteredWorkItems = useMemo(
|
||
() => applyTypeFilter(currentPageItems),
|
||
[applyTypeFilter, currentPageItems]
|
||
)
|
||
|
||
// Why: totalPages is derived from the search API count when available,
|
||
// so the pagination bar shows the full range (with ellipsis) upfront.
|
||
// Falls back to the loaded page count when the count hasn't returned yet.
|
||
const totalPages =
|
||
totalItemCount !== null
|
||
? Math.max(pages.length, Math.ceil(totalItemCount / CROSS_REPO_DISPLAY_LIMIT))
|
||
: pages.length
|
||
|
||
// Why: loads the next page using the oldest item's updatedAt as a cursor.
|
||
// When targetPage is provided (from clicking a numbered page beyond loaded
|
||
// pages), it chains fetches until that page is loaded.
|
||
const handleLoadNextPage = useCallback(
|
||
async (targetPage?: number) => {
|
||
if (paginationLoading || selectedRepos.length === 0) {
|
||
return
|
||
}
|
||
const lastPage = pages.at(-1)
|
||
if (!lastPage || lastPage.length === 0) {
|
||
return
|
||
}
|
||
const oldestItem = lastPage.at(-1)
|
||
if (!oldestItem?.updatedAt) {
|
||
return
|
||
}
|
||
const q = stripRepoQualifiers(appliedTaskSearch.trim())
|
||
const repoArgs = selectedRepos.map((r) => ({ repoId: r.id, path: r.path }))
|
||
|
||
const target = targetPage ?? pages.length
|
||
setPaginationLoading(true)
|
||
setLoadingTargetPage(target)
|
||
try {
|
||
let cursor = oldestItem.updatedAt
|
||
let loadedPages = pages.length
|
||
const newPages: GitHubWorkItem[][] = []
|
||
|
||
while (loadedPages <= target) {
|
||
const { items } = await fetchWorkItemsNextPage(
|
||
repoArgs,
|
||
PER_REPO_FETCH_LIMIT,
|
||
CROSS_REPO_DISPLAY_LIMIT,
|
||
q,
|
||
cursor
|
||
)
|
||
if (items.length === 0) {
|
||
break
|
||
}
|
||
newPages.push(items)
|
||
cursor = items.at(-1)!.updatedAt
|
||
loadedPages += 1
|
||
}
|
||
|
||
if (newPages.length > 0) {
|
||
setPages((prev) => [...prev, ...newPages])
|
||
setCurrentPage(target < loadedPages ? target : loadedPages - 1)
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to load next page:', err)
|
||
} finally {
|
||
setPaginationLoading(false)
|
||
setLoadingTargetPage(null)
|
||
}
|
||
},
|
||
[paginationLoading, selectedRepos, pages, appliedTaskSearch, fetchWorkItemsNextPage]
|
||
)
|
||
|
||
useEffect(() => {
|
||
if (!taskResumeApplied) {
|
||
return
|
||
}
|
||
const timeout = window.setTimeout(() => {
|
||
setAppliedTaskSearch(taskSearchInput)
|
||
}, TASK_SEARCH_DEBOUNCE_MS)
|
||
return () => window.clearTimeout(timeout)
|
||
}, [taskSearchInput, taskResumeApplied])
|
||
|
||
useEffect(() => {
|
||
if (!taskResumeApplied) {
|
||
return
|
||
}
|
||
if (!githubSearchPersistReadyRef.current) {
|
||
githubSearchPersistReadyRef.current = true
|
||
return
|
||
}
|
||
if (activeTaskPreset !== null) {
|
||
return
|
||
}
|
||
setTaskResumeState({
|
||
githubItemsPreset: null,
|
||
githubItemsQuery: appliedTaskSearch.trim()
|
||
})
|
||
}, [activeTaskPreset, appliedTaskSearch, setTaskResumeState, taskResumeApplied])
|
||
|
||
useEffect(() => {
|
||
if (!taskResumeApplied) {
|
||
return
|
||
}
|
||
// Why: both early-return branches must clear `retryingRepoPaths` — if the
|
||
// user clicks Retry and then switches `taskSource` away from 'github' (or
|
||
// somehow ends up with zero repos selected) before the fetch dispatches,
|
||
// neither the `.then` nor the `.catch` below will fire, and the Retry
|
||
// button would stay stuck in its disabled/Retrying state indefinitely.
|
||
if (taskSource !== 'github' || githubMode !== 'items') {
|
||
setRetryingRepoPaths(new Set())
|
||
return
|
||
}
|
||
if (selectedRepos.length === 0) {
|
||
setRetryingRepoPaths(new Set())
|
||
return
|
||
} // unreachable — multi-combobox forbids empty
|
||
|
||
// Why: `repo:owner/name` qualifiers are silently dropped before fan-out
|
||
// because in cross-repo mode they would pin every per-repo fetch to a
|
||
// single repo and zero out the rest. See stripRepoQualifiers.
|
||
const q = stripRepoQualifiers(appliedTaskSearch.trim())
|
||
let cancelled = false
|
||
|
||
// Why: paint cached rows synchronously before awaiting the fan-out so
|
||
// selection changes don't leave the previous selection's rows on screen
|
||
// for a frame. Any repo without a cache entry simply contributes nothing
|
||
// to this pre-paint; the fetch will fill it in.
|
||
const preMerged: GitHubWorkItem[] = []
|
||
let anyUncached = false
|
||
for (const r of selectedRepos) {
|
||
const cached = getCachedWorkItems(r.path, PER_REPO_FETCH_LIMIT, q)
|
||
if (cached === null) {
|
||
anyUncached = true
|
||
} else {
|
||
preMerged.push(...cached)
|
||
}
|
||
}
|
||
// Why: always replace — if preMerged is empty (e.g. query just changed and
|
||
// no repo has a cache entry for it), we clear the previous query's rows
|
||
// rather than leaving them on screen under the spinner.
|
||
const page0 =
|
||
preMerged.length > 0
|
||
? [...preMerged]
|
||
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
|
||
.slice(0, CROSS_REPO_DISPLAY_LIMIT)
|
||
: []
|
||
setPages([page0])
|
||
setCurrentPage(0)
|
||
setTotalItemCount(null)
|
||
setTasksError(null)
|
||
setFailedCount(0) // reset so a prior failure banner doesn't linger
|
||
setTasksLoading(anyUncached)
|
||
|
||
// Preserve the existing nonce-gated force behavior.
|
||
const forceRefresh = taskRefreshNonce !== lastFetchedNonceRef.current
|
||
lastFetchedNonceRef.current = taskRefreshNonce
|
||
// Why: a preference flip bumps `workItemsInvalidationNonce`. Treat that
|
||
// bump as a forced refresh so the fan-out bypasses the in-flight dedupe
|
||
// map — otherwise an overlapping request started before the flip could
|
||
// resolve the new fetch and repopulate the cache with pre-flip data.
|
||
const preferenceInvalidated =
|
||
workItemsInvalidationNonce !== lastFetchedInvalidationNonceRef.current
|
||
lastFetchedInvalidationNonceRef.current = workItemsInvalidationNonce
|
||
|
||
const repoArgs = selectedRepos.map((r) => ({ repoId: r.id, path: r.path }))
|
||
// Why: snapshot the retrying paths at effect-dispatch so overlapping
|
||
// retries don't clear each other's pending state. An earlier cancelled
|
||
// effect settling after a newer retry starts would otherwise wipe the
|
||
// newer retry's repo from the set. Clearing only the paths captured
|
||
// when this effect dispatched preserves later additions.
|
||
const dispatchedRetryPaths = retryingRepoPaths
|
||
void fetchWorkItemsAcrossRepos(repoArgs, PER_REPO_FETCH_LIMIT, CROSS_REPO_DISPLAY_LIMIT, q, {
|
||
force: (forceRefresh && taskRefreshNonce > 0) || preferenceInvalidated
|
||
})
|
||
.then(({ items, failedCount: failed }) => {
|
||
// Why: clear only the repos this effect was responsible for
|
||
// retrying (the snapshot captured at dispatch time). Overlapping
|
||
// retries — a second click while a prior fetch is still in flight
|
||
// — must not clear the newer repo from the set, so we can't just
|
||
// reset the whole set here. The early-return branches above reset
|
||
// the whole set because those branches won't dispatch a fetch.
|
||
setRetryingRepoPaths((prev) => {
|
||
if (dispatchedRetryPaths.size === 0) {
|
||
return prev
|
||
}
|
||
const next = new Set(prev)
|
||
for (const p of dispatchedRetryPaths) {
|
||
next.delete(p)
|
||
}
|
||
return next
|
||
})
|
||
if (cancelled) {
|
||
return
|
||
}
|
||
setPages([items])
|
||
setCurrentPage(0)
|
||
setFailedCount(failed)
|
||
setTasksLoading(false)
|
||
})
|
||
.catch((err) => {
|
||
// Why: fetchWorkItemsAcrossRepos swallows per-repo failures, so a
|
||
// reject here means an IPC-level or programmer error — surface it.
|
||
// Clear only the repos this effect was responsible for retrying
|
||
// (the snapshot captured at dispatch time). Overlapping retries —
|
||
// a second click while a prior fetch is still in flight — must
|
||
// not clear the newer repo from the set, so we can't just reset
|
||
// the whole set here. The early-return branches above reset the
|
||
// whole set because those branches won't dispatch a fetch.
|
||
setRetryingRepoPaths((prev) => {
|
||
if (dispatchedRetryPaths.size === 0) {
|
||
return prev
|
||
}
|
||
const next = new Set(prev)
|
||
for (const p of dispatchedRetryPaths) {
|
||
next.delete(p)
|
||
}
|
||
return next
|
||
})
|
||
if (cancelled) {
|
||
return
|
||
}
|
||
setTasksError(err instanceof Error ? err.message : 'Failed to load GitHub work.')
|
||
setFailedCount(0) // the per-repo banner would be misleading next to tasksError
|
||
setTasksLoading(false)
|
||
})
|
||
|
||
// Why: fire-and-forget count query in parallel with the items fetch.
|
||
// The search API is cached 120s server-side so this doesn't add
|
||
// meaningful latency or rate-limit pressure.
|
||
void countWorkItemsAcrossRepos(
|
||
selectedRepos.map((r) => ({ path: r.path })),
|
||
q
|
||
).then((count) => {
|
||
if (!cancelled) {
|
||
setTotalItemCount(count)
|
||
}
|
||
})
|
||
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
// Why: getCachedWorkItems and fetchWorkItemsAcrossRepos are stable zustand
|
||
// selectors; depending on them would re-run the effect on unrelated store
|
||
// updates. `workItemsInvalidationNonce` is explicitly included so a
|
||
// preference flip (which only evicts cache) re-dispatches this effect.
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [
|
||
selectedRepos,
|
||
appliedTaskSearch,
|
||
taskRefreshNonce,
|
||
taskSource,
|
||
githubMode,
|
||
workItemsInvalidationNonce,
|
||
taskResumeApplied
|
||
])
|
||
|
||
const handleApplyTaskSearch = useCallback((): void => {
|
||
const trimmed = taskSearchInput.trim()
|
||
setTaskSearchInput(trimmed)
|
||
setAppliedTaskSearch(trimmed)
|
||
setActiveTaskPreset(null)
|
||
setTaskResumeState({ githubItemsPreset: null, githubItemsQuery: trimmed })
|
||
setTaskRefreshNonce((current) => current + 1)
|
||
}, [setTaskResumeState, taskSearchInput])
|
||
|
||
const handleTaskSearchChange = useCallback((event: React.ChangeEvent<HTMLInputElement>): void => {
|
||
const next = event.target.value
|
||
setTaskSearchInput(next)
|
||
setActiveTaskPreset(null)
|
||
}, [])
|
||
|
||
const handleSetDefaultTaskPreset = useCallback(
|
||
(presetId: TaskViewPresetId): void => {
|
||
// Why: the default task view is a durable preference, so right-clicking a
|
||
// preset updates the persisted settings instead of only changing the
|
||
// current page state.
|
||
void updateSettings({ defaultTaskViewPreset: presetId }).catch(() => {
|
||
toast.error('Failed to save default task view.')
|
||
})
|
||
},
|
||
[updateSettings]
|
||
)
|
||
|
||
const handleTaskSearchKeyDown = useCallback(
|
||
(event: React.KeyboardEvent<HTMLInputElement>): void => {
|
||
if (event.key === 'Enter') {
|
||
// React SyntheticEvent does not expose isComposing; use nativeEvent.
|
||
if (
|
||
shouldSuppressEnterSubmit(
|
||
{ isComposing: event.nativeEvent.isComposing, shiftKey: event.shiftKey },
|
||
false
|
||
)
|
||
) {
|
||
return
|
||
}
|
||
event.preventDefault()
|
||
handleApplyTaskSearch()
|
||
}
|
||
},
|
||
[handleApplyTaskSearch]
|
||
)
|
||
|
||
const openComposerForItem = useCallback(
|
||
(item: GitHubWorkItem): void => {
|
||
const linkedWorkItem: LinkedWorkItemSummary = {
|
||
type: item.type,
|
||
number: item.number,
|
||
title: item.title,
|
||
url: item.url
|
||
}
|
||
openModal('new-workspace-composer', {
|
||
linkedWorkItem,
|
||
prefilledName: getLinkedWorkItemSuggestedName(item),
|
||
initialRepoId: item.repoId
|
||
})
|
||
},
|
||
[openModal]
|
||
)
|
||
|
||
const handleUseWorkItem = useCallback(
|
||
(item: GitHubWorkItem): void => {
|
||
// Why: open the unified New Workspace dialog pre-filled with the work
|
||
// item as the selected source so the user can confirm name / agent /
|
||
// setup before the worktree is created. Earlier the "Use" CTA created
|
||
// and activated the worktree synchronously, which was disorienting —
|
||
// the worktree appeared in the sidebar before the user had a chance
|
||
// to review it. The composer already owns the prefill flow.
|
||
openComposerForItem(item)
|
||
},
|
||
[openComposerForItem]
|
||
)
|
||
|
||
const handleCreateNewIssue = useCallback(async (): Promise<void> => {
|
||
if (!newIssueTargetRepo) {
|
||
return
|
||
}
|
||
const title = newIssueTitle.trim()
|
||
if (!title || newIssueSubmitting) {
|
||
return
|
||
}
|
||
setNewIssueSubmitting(true)
|
||
try {
|
||
const result = await window.api.gh.createIssue({
|
||
repoPath: newIssueTargetRepo.path,
|
||
title,
|
||
body: newIssueBody
|
||
})
|
||
if (!result.ok) {
|
||
toast.error(result.error || 'Failed to create issue.')
|
||
return
|
||
}
|
||
toast.success(`Opened issue #${result.number}`, {
|
||
action: result.url
|
||
? {
|
||
label: 'View',
|
||
onClick: () => window.open(result.url, '_blank')
|
||
}
|
||
: undefined
|
||
})
|
||
setNewIssueOpen(false)
|
||
setNewIssueTitle('')
|
||
setNewIssueBody('')
|
||
// Why: bump the nonce so the list refetches and shows the new issue.
|
||
setTaskRefreshNonce((current) => current + 1)
|
||
|
||
// Why: auto-open the new issue in the dialog so the user sees
|
||
// exactly what was filed. Use an optimistic stub first so the dialog
|
||
// has immediate content, then refine with the full `workItem` fetch.
|
||
const stub: GitHubWorkItem = {
|
||
id: `issue:${String(result.number)}`,
|
||
repoId: newIssueTargetRepo.id,
|
||
type: 'issue',
|
||
number: result.number,
|
||
title,
|
||
state: 'open',
|
||
url: result.url,
|
||
labels: [],
|
||
updatedAt: new Date().toISOString(),
|
||
author: null
|
||
}
|
||
setDialogWorkItem(stub)
|
||
const stubRepoId = newIssueTargetRepo.id
|
||
void window.api.gh
|
||
.workItem({ repoPath: newIssueTargetRepo.path, number: result.number, type: 'issue' })
|
||
.then((full) => {
|
||
if (full) {
|
||
// Why: `full` is `Omit<GitHubWorkItem, 'repoId'>` (IPC shape).
|
||
// Cast through unknown: spreading a discriminated union loses the
|
||
// discriminant, so `{ ...full, repoId }` doesn't typecheck as
|
||
// GitHubWorkItem. The runtime shape is correct by construction.
|
||
const withRepoId = { ...full, repoId: stubRepoId } as unknown as GitHubWorkItem
|
||
setDialogWorkItem(withRepoId)
|
||
}
|
||
})
|
||
.catch(() => {})
|
||
} finally {
|
||
setNewIssueSubmitting(false)
|
||
}
|
||
}, [newIssueBody, newIssueSubmitting, newIssueTargetRepo, newIssueTitle, setDialogWorkItem])
|
||
|
||
const handleCreateNewLinearIssue = useCallback(async (): Promise<void> => {
|
||
if (!newLinearIssueTargetTeam) {
|
||
return
|
||
}
|
||
const title = newLinearIssueTitle.trim()
|
||
if (!title || newLinearIssueSubmitting) {
|
||
return
|
||
}
|
||
setNewLinearIssueSubmitting(true)
|
||
try {
|
||
const result = await window.api.linear.createIssue({
|
||
teamId: newLinearIssueTargetTeam.id,
|
||
title,
|
||
description: newLinearIssueBody || undefined
|
||
})
|
||
if (!result.ok) {
|
||
toast.error(result.error || 'Failed to create issue.')
|
||
return
|
||
}
|
||
toast.success(`Created ${result.identifier}`, {
|
||
action: result.url
|
||
? {
|
||
label: 'View',
|
||
onClick: () => window.open(result.url, '_blank')
|
||
}
|
||
: undefined
|
||
})
|
||
setNewLinearIssueOpen(false)
|
||
setNewLinearIssueTitle('')
|
||
setNewLinearIssueBody('')
|
||
setLinearRefreshNonce((n) => n + 1)
|
||
|
||
// Why: auto-open the new issue in the side drawer so the user sees
|
||
// exactly what was filed, mirroring the GitHub create-issue flow.
|
||
void window.api.linear
|
||
.getIssue({ id: result.id })
|
||
.then((full) => {
|
||
if (full) {
|
||
setDrawerLinearIssue(full)
|
||
}
|
||
})
|
||
.catch(() => {})
|
||
} finally {
|
||
setNewLinearIssueSubmitting(false)
|
||
}
|
||
}, [
|
||
newLinearIssueBody,
|
||
newLinearIssueSubmitting,
|
||
newLinearIssueTargetTeam,
|
||
newLinearIssueTitle,
|
||
setDrawerLinearIssue
|
||
])
|
||
|
||
useEffect(() => {
|
||
// Why: when a modal is open, let it own Esc dismissal.
|
||
if (
|
||
dialogWorkItem ||
|
||
drawerLinearIssue ||
|
||
newIssueOpen ||
|
||
newLinearIssueOpen ||
|
||
activeModal !== 'none'
|
||
) {
|
||
return
|
||
}
|
||
|
||
const onKeyDown = (event: KeyboardEvent): void => {
|
||
if (event.key !== 'Escape') {
|
||
return
|
||
}
|
||
|
||
const target = event.target
|
||
if (!(target instanceof HTMLElement)) {
|
||
return
|
||
}
|
||
|
||
// Why: Esc should first dismiss the focused control so users can back
|
||
// out of text entry without accidentally closing the whole page.
|
||
// Once focus is already outside an input, Esc closes the tasks page.
|
||
if (
|
||
target instanceof HTMLInputElement ||
|
||
target instanceof HTMLTextAreaElement ||
|
||
target instanceof HTMLSelectElement ||
|
||
target.isContentEditable
|
||
) {
|
||
event.preventDefault()
|
||
target.blur()
|
||
return
|
||
}
|
||
|
||
event.preventDefault()
|
||
closeTaskPage()
|
||
}
|
||
|
||
window.addEventListener('keydown', onKeyDown, { capture: true })
|
||
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
|
||
}, [
|
||
activeModal,
|
||
closeTaskPage,
|
||
dialogWorkItem,
|
||
drawerLinearIssue,
|
||
newIssueOpen,
|
||
newLinearIssueOpen
|
||
])
|
||
|
||
// Why: check Linear connection status on mount so the UI can show the
|
||
// correct connected/disconnected state without requiring a settings visit.
|
||
useEffect(() => {
|
||
void checkLinearConnection()
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [])
|
||
|
||
// Why: debounce the Linear search input so we don't fire a request on every
|
||
// keystroke — matches the 300ms cadence used for GitHub search.
|
||
useEffect(() => {
|
||
if (!taskResumeApplied) {
|
||
return
|
||
}
|
||
const timeout = window.setTimeout(() => {
|
||
setAppliedLinearSearch(linearSearchInput)
|
||
}, TASK_SEARCH_DEBOUNCE_MS)
|
||
return () => window.clearTimeout(timeout)
|
||
}, [linearSearchInput, taskResumeApplied])
|
||
|
||
useEffect(() => {
|
||
if (!taskResumeApplied) {
|
||
return
|
||
}
|
||
if (!linearSearchPersistReadyRef.current) {
|
||
linearSearchPersistReadyRef.current = true
|
||
return
|
||
}
|
||
setTaskResumeState({ linearQuery: appliedLinearSearch.trim() })
|
||
}, [appliedLinearSearch, setTaskResumeState, taskResumeApplied])
|
||
|
||
// Why: fetch Linear issues when the tab is active and the account is
|
||
// connected. An empty search falls back to `listLinearIssues` (assigned
|
||
// issues) so the default view shows the user's own work.
|
||
useEffect(() => {
|
||
if (!taskResumeApplied) {
|
||
return
|
||
}
|
||
if (taskSource !== 'linear') {
|
||
return
|
||
}
|
||
if (!linearStatus.connected) {
|
||
return
|
||
}
|
||
|
||
let cancelled = false
|
||
setLinearLoading(true)
|
||
setLinearError(null)
|
||
|
||
const trimmed = appliedLinearSearch.trim()
|
||
const request =
|
||
trimmed.length > 0
|
||
? searchLinearIssues(trimmed, LINEAR_ITEM_LIMIT)
|
||
: listLinearIssues(activeLinearPreset, LINEAR_ITEM_LIMIT)
|
||
|
||
void request
|
||
.then((issues) => {
|
||
if (cancelled) {
|
||
return
|
||
}
|
||
setLinearIssues(issues)
|
||
setLinearLoading(false)
|
||
})
|
||
.catch((err) => {
|
||
if (cancelled) {
|
||
return
|
||
}
|
||
setLinearError(err instanceof Error ? err.message : 'Failed to load Linear issues.')
|
||
setLinearLoading(false)
|
||
})
|
||
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
// Why: searchLinearIssues and listLinearIssues are stable zustand selectors;
|
||
// depending on them would re-run the effect on unrelated store updates.
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [
|
||
taskSource,
|
||
linearStatus.connected,
|
||
appliedLinearSearch,
|
||
activeLinearPreset,
|
||
linearRefreshNonce,
|
||
taskResumeApplied
|
||
])
|
||
|
||
// Why: for Linear issues the "Use" flow opens the composer with the issue
|
||
// info adapted to the LinkedWorkItemSummary shape. Linear identifiers are
|
||
// strings (e.g. "ENG-123") so we use 0 as a placeholder number since the
|
||
// URL is the primary artifact the agent will act on.
|
||
const openComposerForLinearItem = useCallback(
|
||
(issue: LinearIssue): void => {
|
||
const linkedWorkItem: LinkedWorkItemSummary = {
|
||
type: 'issue',
|
||
number: 0,
|
||
title: issue.title,
|
||
url: issue.url
|
||
}
|
||
openModal('new-workspace-composer', {
|
||
linkedWorkItem,
|
||
prefilledName: getLinkedWorkItemSuggestedName(issue)
|
||
})
|
||
},
|
||
[openModal]
|
||
)
|
||
|
||
const handleUseLinearItem = useCallback(
|
||
(issue: LinearIssue): void => {
|
||
// Why: same rationale as handleUseWorkItem — open the New Workspace
|
||
// dialog pre-filled rather than yolo-creating the worktree, so the
|
||
// user can confirm name / agent / setup before the worktree lands in
|
||
// the sidebar.
|
||
openComposerForLinearItem(issue)
|
||
},
|
||
[openComposerForLinearItem]
|
||
)
|
||
|
||
const handleLinearConnect = useCallback(async (): Promise<void> => {
|
||
const key = linearApiKeyDraft.trim()
|
||
if (!key) {
|
||
return
|
||
}
|
||
setLinearConnectState('connecting')
|
||
setLinearConnectError(null)
|
||
try {
|
||
const result = await connectLinear(key)
|
||
if (result.ok) {
|
||
setLinearApiKeyDraft('')
|
||
setLinearConnectState('idle')
|
||
setLinearConnectOpen(false)
|
||
} else {
|
||
setLinearConnectState('error')
|
||
setLinearConnectError(result.error)
|
||
}
|
||
} catch (error) {
|
||
setLinearConnectState('error')
|
||
setLinearConnectError(error instanceof Error ? error.message : 'Connection failed')
|
||
}
|
||
}, [connectLinear, linearApiKeyDraft])
|
||
|
||
return (
|
||
<div className="relative flex h-full min-h-0 flex-1 overflow-hidden bg-background text-foreground">
|
||
{/* Why: no z-index here. App.tsx's floating titlebar-left (traffic lights
|
||
+ sidebar-expand toggle + agent badge) is absolutely positioned at
|
||
z-10 in the root stacking context when the sidebar is collapsed. If
|
||
this wrapper also sits at z-10 it ties with titlebar-left on
|
||
z-index and wins on DOM order (later sibling), so even though our
|
||
top-left spacer is pointer-events-none, the click still lands on
|
||
this wrapper behind the spacer instead of falling through to the
|
||
sidebar toggle. Keeping this at z-auto lets titlebar-left's z-10
|
||
paint above our content and receive the click cleanly. */}
|
||
<div className="relative flex min-h-0 flex-1 flex-col">
|
||
{/* Why: in workspace view App.tsx suppresses its full-width titlebar,
|
||
so render a matching 42px strip here to keep the top band
|
||
continuous with the sidebar header and tab rows. When the sidebar
|
||
is collapsed, App.tsx floats its titlebar-left controls (traffic
|
||
lights, sidebar toggle, agent badge) over the top-left of this
|
||
page at z-10, and the page wrapper stays at z-auto so that float
|
||
always paints above our content. Keep the reserved region
|
||
transparent so the floating titlebar-left's own bg + border-bottom
|
||
is what the user sees on the left — the two segments then read as
|
||
one continuous band. The painted remainder is a drag-region so the
|
||
window stays movable here, matching other top chrome. Skipped in
|
||
non-workspace mode because App.tsx already owns the top titlebar
|
||
and a second strip would produce a duplicate band. */}
|
||
{workspaceActive ? (
|
||
<div className="flex-none flex h-[42px]">
|
||
{reserveCollapsedHeaderSpace ? (
|
||
// Why: the floating titlebar-left hosts real interactive chrome
|
||
// (sidebar-expand toggle, agent badge) under this segment. Both
|
||
// pointer-events-none AND WebkitAppRegion='no-drag' are needed:
|
||
// without pointer-events-none, this transparent div absorbs
|
||
// clicks before they reach the toggle; without no-drag, Electron
|
||
// marks the area as window-drag and still consumes clicks even
|
||
// when the element itself is click-through.
|
||
<div
|
||
aria-hidden
|
||
className="h-full shrink-0 pointer-events-none"
|
||
style={
|
||
{
|
||
width: 'var(--collapsed-sidebar-header-width)',
|
||
WebkitAppRegion: 'no-drag'
|
||
} as React.CSSProperties
|
||
}
|
||
/>
|
||
) : null}
|
||
<div
|
||
className="flex h-full flex-1 items-center border-b border-border bg-card"
|
||
style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="mx-auto flex w-full flex-1 flex-col min-h-0 px-5 pt-3 pb-5 md:px-8 md:pt-3 md:pb-7">
|
||
<div className="flex-none flex flex-col gap-3">
|
||
<section className="flex flex-col gap-3">
|
||
<div className="flex flex-col gap-3">
|
||
<div className="flex items-center justify-between gap-2">
|
||
<div className="flex items-center gap-2">
|
||
{/* Why: Close is anchored left in the same row as the
|
||
source icons so the top chrome is one compact band.
|
||
Left-aligned keeps it clear of the app sidebar on the
|
||
right edge. */}
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="size-7 rounded-full"
|
||
onClick={closeTaskPage}
|
||
aria-label="Close tasks"
|
||
>
|
||
<X className="size-4" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="bottom" sideOffset={6}>
|
||
Close · Esc
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
<div className="mx-1 h-5 w-px bg-border/50" aria-hidden />
|
||
{SOURCE_OPTIONS.map((source) => {
|
||
const active = taskSource === source.id
|
||
return (
|
||
<Tooltip key={source.id}>
|
||
<TooltipTrigger asChild>
|
||
<button
|
||
type="button"
|
||
disabled={source.disabled}
|
||
onClick={() => {
|
||
setTaskSource(source.id)
|
||
void updateSettings({ defaultTaskSource: source.id }).catch(() => {
|
||
toast.error('Failed to save default task source.')
|
||
})
|
||
}}
|
||
aria-label={source.label}
|
||
className={cn(
|
||
'group flex h-8 w-8 items-center justify-center rounded-md border transition',
|
||
active
|
||
? 'border-foreground/40 bg-muted/70 text-foreground shadow-sm'
|
||
: 'border-border/40 bg-transparent text-muted-foreground hover:bg-muted/40 hover:text-foreground',
|
||
source.disabled && 'cursor-not-allowed opacity-55'
|
||
)}
|
||
>
|
||
<source.Icon className="size-3.5" />
|
||
</button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="bottom" sideOffset={6}>
|
||
{source.label}
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
)
|
||
})}
|
||
</div>
|
||
{taskSource === 'linear' && availableTeams.length > 0 ? (
|
||
<div className="w-[200px]">
|
||
<TeamMultiCombobox
|
||
teams={availableTeams}
|
||
selected={linearTeamSelection}
|
||
onChange={(next) => {
|
||
setLinearTeamSelection(next)
|
||
void updateSettings({ defaultLinearTeamSelection: [...next] }).catch(
|
||
() => {
|
||
toast.error('Failed to save team selection.')
|
||
}
|
||
)
|
||
}}
|
||
onSelectAll={() => {
|
||
setLinearTeamSelection(new Set(availableTeams.map((t) => t.id)))
|
||
void updateSettings({ defaultLinearTeamSelection: null }).catch(() => {
|
||
toast.error('Failed to save team selection.')
|
||
})
|
||
}}
|
||
triggerClassName="h-8 w-full rounded-md border border-border/50 bg-muted/50 px-2 text-xs font-medium shadow-sm transition hover:bg-muted/50 focus:ring-2 focus:ring-ring/20 focus:outline-none"
|
||
/>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
|
||
{taskSource === 'github' ? (
|
||
<div className="flex items-center gap-2">
|
||
{projectModeVisible ? (
|
||
<div className="flex items-center gap-1 text-xs">
|
||
{(['items', 'project'] as const).map((mode) => {
|
||
const active = githubMode === mode
|
||
return (
|
||
<button
|
||
key={mode}
|
||
type="button"
|
||
onClick={() => {
|
||
setGithubMode(mode)
|
||
setTaskResumeState({ githubMode: mode })
|
||
}}
|
||
className={cn(
|
||
'rounded-md border px-2 py-1 text-xs transition',
|
||
active
|
||
? 'border-border/50 bg-foreground/90 text-background'
|
||
: 'border-border/50 bg-transparent text-foreground hover:bg-muted/50'
|
||
)}
|
||
>
|
||
{mode === 'items' ? 'Issues/PRs' : 'Project'}
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
) : null}
|
||
{/* Why: the repo combobox filters Items mode by repo. In
|
||
Project mode the row set comes from the project's
|
||
view filter (server-side), so this control would be
|
||
inert — hide it to avoid suggesting it does
|
||
something. */}
|
||
{githubMode !== 'project' && (
|
||
<div className="w-[200px]">
|
||
<RepoMultiCombobox
|
||
repos={eligibleRepos}
|
||
selected={repoSelection}
|
||
onChange={(next) => {
|
||
setRepoSelection(next)
|
||
void updateSettings({ defaultRepoSelection: [...next] }).catch(() => {
|
||
toast.error('Failed to save repo selection.')
|
||
})
|
||
}}
|
||
onSelectAll={() => {
|
||
const allIds = new Set(eligibleRepos.map((r) => r.id))
|
||
setRepoSelection(allIds)
|
||
void updateSettings({ defaultRepoSelection: null }).catch(() => {
|
||
toast.error('Failed to save repo selection.')
|
||
})
|
||
}}
|
||
triggerClassName="h-8 w-full rounded-md border border-border/50 bg-muted/50 px-2 text-xs font-medium shadow-sm transition hover:bg-muted/50 focus:ring-2 focus:ring-ring/20 focus:outline-none"
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : null}
|
||
|
||
{taskSource === 'github' && githubMode === 'items' ? (
|
||
<div className="rounded-md rounded-b-none border border-border/50 bg-muted/50 p-3 shadow-sm">
|
||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||
<div className="flex flex-wrap gap-2">
|
||
{TASK_QUERY_PRESETS.map((option) => {
|
||
const active = activeTaskPreset === option.id
|
||
return (
|
||
<button
|
||
key={option.id}
|
||
type="button"
|
||
onClick={() => {
|
||
const query = option.query
|
||
setTaskSearchInput(query)
|
||
setAppliedTaskSearch(query)
|
||
setActiveTaskPreset(option.id)
|
||
setTaskResumeState({
|
||
githubItemsPreset: option.id,
|
||
githubItemsQuery: query
|
||
})
|
||
setTaskRefreshNonce((current) => current + 1)
|
||
}}
|
||
onContextMenu={(event) => {
|
||
event.preventDefault()
|
||
handleSetDefaultTaskPreset(option.id)
|
||
}}
|
||
className={cn(
|
||
'rounded-md border px-2 py-1 text-xs transition',
|
||
active
|
||
? 'border-border/50 bg-foreground/90 text-background backdrop-blur-md'
|
||
: 'border-border/50 bg-transparent text-foreground hover:bg-muted/50'
|
||
)}
|
||
>
|
||
{option.label}
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
<div className="flex shrink-0 items-center gap-2">
|
||
{/* Why: GitHub API budget pill is anchored next to the
|
||
Refresh button so the "maybe I shouldn't click
|
||
refresh again" decision is one glance away. Only
|
||
rendered in the GitHub section because Linear has
|
||
its own SDK-based quota and doesn't consume gh
|
||
budget. */}
|
||
<GitHubRateLimitPill />
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
variant="outline"
|
||
size="icon"
|
||
onClick={() => {
|
||
setNewIssueTitle('')
|
||
setNewIssueBody('')
|
||
setNewIssueRepoId(primaryRepo?.id ?? null)
|
||
setNewIssueOpen(true)
|
||
}}
|
||
disabled={!newIssueTargetRepo}
|
||
aria-label="New GitHub issue"
|
||
className="border-border/50 bg-transparent hover:bg-muted/50 backdrop-blur-md supports-[backdrop-filter]:bg-transparent"
|
||
>
|
||
<Plus className="size-4" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="bottom" sideOffset={6}>
|
||
New GitHub issue
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
variant="outline"
|
||
size="icon"
|
||
onClick={() => setTaskRefreshNonce((current) => current + 1)}
|
||
disabled={tasksLoading}
|
||
aria-label="Refresh GitHub work"
|
||
className="border-border/50 bg-transparent hover:bg-muted/50 backdrop-blur-md supports-[backdrop-filter]:bg-transparent"
|
||
>
|
||
{tasksLoading ? (
|
||
<LoaderCircle className="size-4 animate-spin" />
|
||
) : (
|
||
<RefreshCw className="size-4" />
|
||
)}
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="bottom" sideOffset={6}>
|
||
Refresh GitHub work
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||
<div className="relative min-w-[320px] flex-1">
|
||
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||
<Input
|
||
value={taskSearchInput}
|
||
onChange={handleTaskSearchChange}
|
||
onKeyDown={handleTaskSearchKeyDown}
|
||
placeholder="GitHub search, e.g. assignee:@me is:open"
|
||
className="h-8 rounded-md border-border/50 bg-background pl-8 pr-8 text-xs"
|
||
/>
|
||
{taskSearchInput || appliedTaskSearch ? (
|
||
<button
|
||
type="button"
|
||
aria-label="Clear search"
|
||
onClick={() => {
|
||
setTaskSearchInput('')
|
||
setAppliedTaskSearch('')
|
||
setActiveTaskPreset(null)
|
||
setTaskResumeState({ githubItemsPreset: null, githubItemsQuery: '' })
|
||
setTaskRefreshNonce((current) => current + 1)
|
||
}}
|
||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground transition hover:text-foreground"
|
||
>
|
||
<X className="size-4" />
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
|
||
{(() => {
|
||
// Why: unify feature 1 (indicator) and feature 2 (selector)
|
||
// into a single chip per repo. Rendering both separately
|
||
// produced visually redundant output — two local-repo
|
||
// dot-labels, duplicate slugs. The selector's active pill
|
||
// + tooltip already announce the source, so the "Issues
|
||
// from {slug}" chip is only shown when the selector does
|
||
// not render (no upstream remote — nothing to toggle).
|
||
const rows = perRepoSourceState.filter(
|
||
(s) => hasUpstreamCandidateDivergence(s) || hasDivergentSources(s)
|
||
)
|
||
if (rows.length === 0) {
|
||
return null
|
||
}
|
||
return (
|
||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||
{rows.map((s) => {
|
||
const repo = selectedRepos.find((r) => r.id === s.repoId)
|
||
const showDotLabel = selectedRepos.length > 1 && repo
|
||
const selectorRenderable = hasUpstreamCandidateDivergence(s)
|
||
// Why: the static indicator has its own wrapping
|
||
// chip styles, so we render it standalone and don't
|
||
// nest it inside our own chip — nesting would
|
||
// double-border it.
|
||
if (!selectorRenderable && hasDivergentSources(s)) {
|
||
return (
|
||
<IssueSourceIndicator
|
||
key={s.repoId}
|
||
issues={s.sources.issues}
|
||
prs={s.sources.prs}
|
||
localRepo={
|
||
showDotLabel && repo
|
||
? { displayName: repo.displayName, color: repo.badgeColor }
|
||
: undefined
|
||
}
|
||
/>
|
||
)
|
||
}
|
||
if (!selectorRenderable || !repo) {
|
||
return null
|
||
}
|
||
// Why: must be a <div> (not <span>) because the child
|
||
// <IssueSourceSelector> renders a <div role="group">, and
|
||
// a block-level <div> nested inside an inline <span> is
|
||
// invalid HTML — React emits a hydration warning and
|
||
// browsers may auto-close the span. `issueSourceChipClass`
|
||
// uses `inline-flex`, so the visual rendering is identical.
|
||
return (
|
||
<div key={s.repoId} className={issueSourceChipClass}>
|
||
{showDotLabel ? (
|
||
<RepoDotLabel
|
||
name={repo.displayName}
|
||
color={repo.badgeColor}
|
||
dotClassName="size-1.5"
|
||
className="text-[10px] text-muted-foreground"
|
||
/>
|
||
) : null}
|
||
<IssueSourceSelector
|
||
preference={repo.issueSourcePreference}
|
||
origin={s.sources.prs}
|
||
upstream={s.sources.upstreamCandidate}
|
||
onChange={(next) => {
|
||
void setIssueSourcePreference(repo.id, repo.path, next)
|
||
}}
|
||
/>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)
|
||
})()}
|
||
</div>
|
||
) : taskSource === 'linear' && linearStatus.connected ? (
|
||
<div className="rounded-md rounded-b-none border border-border/50 bg-muted/50 p-3 shadow-sm">
|
||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||
<div className="flex flex-wrap gap-2">
|
||
{LINEAR_PRESETS.map((preset) => {
|
||
const active = !linearSearchInput && activeLinearPreset === preset.id
|
||
return (
|
||
<button
|
||
key={preset.id}
|
||
type="button"
|
||
onClick={() => {
|
||
setLinearSearchInput('')
|
||
setAppliedLinearSearch('')
|
||
setActiveLinearPreset(preset.id)
|
||
setTaskResumeState({ linearPreset: preset.id, linearQuery: '' })
|
||
setLinearRefreshNonce((n) => n + 1)
|
||
}}
|
||
className={cn(
|
||
'rounded-md border px-2 py-1 text-xs transition',
|
||
active
|
||
? 'border-border/50 bg-foreground/90 text-background backdrop-blur-md'
|
||
: 'border-border/50 bg-transparent text-foreground hover:bg-muted/50'
|
||
)}
|
||
>
|
||
{preset.label}
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
<div className="flex shrink-0 items-center gap-2">
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
variant="outline"
|
||
size="icon"
|
||
onClick={() => {
|
||
setNewLinearIssueTitle('')
|
||
setNewLinearIssueBody('')
|
||
setNewLinearIssueTeamId(availableTeams[0]?.id ?? null)
|
||
setNewLinearIssueOpen(true)
|
||
}}
|
||
disabled={availableTeams.length === 0}
|
||
aria-label="New Linear issue"
|
||
className="border-border/50 bg-transparent hover:bg-muted/50 backdrop-blur-md supports-[backdrop-filter]:bg-transparent"
|
||
>
|
||
<Plus className="size-4" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="bottom" sideOffset={6}>
|
||
New Linear issue
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
variant="outline"
|
||
size="icon"
|
||
onClick={() => setLinearRefreshNonce((n) => n + 1)}
|
||
disabled={linearLoading}
|
||
aria-label="Refresh Linear issues"
|
||
className="border-border/50 bg-transparent hover:bg-muted/50 backdrop-blur-md supports-[backdrop-filter]:bg-transparent"
|
||
>
|
||
{linearLoading ? (
|
||
<LoaderCircle className="size-4 animate-spin" />
|
||
) : (
|
||
<RefreshCw className="size-4" />
|
||
)}
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="bottom" sideOffset={6}>
|
||
Refresh Linear issues
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
</div>
|
||
</div>
|
||
<div className="mt-3 flex items-center gap-3">
|
||
<div className="relative min-w-[320px] flex-1">
|
||
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||
<Input
|
||
value={linearSearchInput}
|
||
onChange={(e) => setLinearSearchInput(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter') {
|
||
if (
|
||
shouldSuppressEnterSubmit(
|
||
{ isComposing: e.nativeEvent.isComposing, shiftKey: e.shiftKey },
|
||
false
|
||
)
|
||
) {
|
||
return
|
||
}
|
||
e.preventDefault()
|
||
const trimmed = linearSearchInput.trim()
|
||
setLinearSearchInput(trimmed)
|
||
setAppliedLinearSearch(trimmed)
|
||
setTaskResumeState({ linearQuery: trimmed })
|
||
setLinearRefreshNonce((n) => n + 1)
|
||
}
|
||
}}
|
||
placeholder="Search Linear issues..."
|
||
className="h-8 rounded-md border-border/50 bg-background pl-8 pr-8 text-xs"
|
||
/>
|
||
{linearSearchInput ? (
|
||
<button
|
||
type="button"
|
||
aria-label="Clear search"
|
||
onClick={() => {
|
||
setLinearSearchInput('')
|
||
setAppliedLinearSearch('')
|
||
setTaskResumeState({ linearQuery: '' })
|
||
setLinearRefreshNonce((n) => n + 1)
|
||
}}
|
||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground transition hover:text-foreground"
|
||
>
|
||
<X className="size-4" />
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
{taskSource === 'github' && githubMode === 'project' ? (
|
||
<div className="mt-3 flex min-h-0 max-h-full flex-col rounded-md border border-border/50 bg-muted/50 overflow-hidden shadow-sm">
|
||
<ProjectViewWrapper />
|
||
</div>
|
||
) : taskSource === 'github' ? (
|
||
<div className="flex min-h-0 max-h-full flex-col rounded-md border border-t-0 border-border/50 bg-muted/50 overflow-hidden rounded-t-none shadow-sm">
|
||
<div className="flex-none grid grid-cols-[80px_minmax(0,3fr)_minmax(110px,0.8fr)_100px_110px_112px] gap-3 border-b border-border/50 px-3 py-2 text-[10px] font-medium uppercase tracking-[0.16em] text-muted-foreground">
|
||
<span>ID</span>
|
||
<span>Title / Context</span>
|
||
<span>Source Branch</span>
|
||
<span>Status</span>
|
||
<span>Updated</span>
|
||
<span />
|
||
</div>
|
||
|
||
<div
|
||
className="min-h-0 flex-initial overflow-y-auto scrollbar-sleek"
|
||
style={{ scrollbarGutter: 'stable' }}
|
||
>
|
||
{tasksError ? (
|
||
<div className="border-b border-border px-4 py-4 text-sm text-destructive">
|
||
{tasksError}
|
||
</div>
|
||
) : null}
|
||
|
||
{!tasksError && failedCount > 0 ? (
|
||
// Why: per-repo partial-failure signal — distinct from a hard
|
||
// IPC reject (tasksError). The two are mutually exclusive.
|
||
<div className="border-b border-border/50 bg-amber-500/10 px-4 py-3 text-sm text-amber-700 dark:text-amber-200">
|
||
{failedCount} of {selectedRepos.length} repos failed to load
|
||
</div>
|
||
) : null}
|
||
|
||
{perRepoSourceState
|
||
.filter((s) => s.error)
|
||
.map((s) => {
|
||
const err = s.error!
|
||
// Why: parent design doc §2 — when the issue fetch fails
|
||
// (e.g. a 403 on a private upstream) we render a retryable
|
||
// banner with slug-qualified copy instead of a silent
|
||
// empty list. The [Retry] action re-invokes the fetch
|
||
// with force=true via the shared refresh nonce so any
|
||
// still-failing in-flight request is invalidated first.
|
||
return (
|
||
<div
|
||
key={`source-err-${s.repoId}`}
|
||
role="alert"
|
||
// Why: aria-atomic ensures screen readers re-announce the full banner
|
||
// when retry produces a new error on the same repo. Without it, React's
|
||
// reconciliation (stable key per repo) may diff-only the changed text
|
||
// node and some assistive tech will miss the update.
|
||
aria-atomic="true"
|
||
className="flex items-center justify-between gap-3 border-b border-border/50 bg-destructive/10 px-4 py-3 text-sm text-destructive"
|
||
>
|
||
<span>
|
||
Couldn't load issues from{' '}
|
||
<span className="font-mono">
|
||
{err.source.owner}/{err.source.repo}
|
||
</span>{' '}
|
||
— {err.message}
|
||
</span>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => handleRetryIssuesFetch(s.repoPath)}
|
||
disabled={tasksLoading || retryingRepoPaths.has(s.repoPath)}
|
||
>
|
||
{retryingRepoPaths.has(s.repoPath) ? (
|
||
<span className="flex items-center gap-1">
|
||
<LoaderCircle className="h-3 w-3 animate-spin" />
|
||
Retrying…
|
||
</span>
|
||
) : (
|
||
'Retry'
|
||
)}
|
||
</Button>
|
||
</div>
|
||
)
|
||
})}
|
||
|
||
{tasksLoading && filteredWorkItems.length === 0 ? (
|
||
// Why: shimmer skeleton stands in for the first ~3 rows while
|
||
// the initial fetch is in flight, so the card is never empty
|
||
// or collapsed during load. Only shown when we have no cached
|
||
// items — on revalidate we keep the stale list visible.
|
||
<div className="divide-y divide-border/50">
|
||
{Array.from({ length: 3 }).map((_, i) => (
|
||
<div
|
||
key={i}
|
||
className="grid w-full gap-2 px-3 py-2 grid-cols-[80px_minmax(0,3fr)_minmax(110px,0.8fr)_100px_110px_112px]"
|
||
>
|
||
<div className="flex items-center">
|
||
<div className="h-7 w-16 animate-pulse rounded-lg bg-muted/70" />
|
||
</div>
|
||
<div className="min-w-0">
|
||
<div className="h-4 w-3/5 animate-pulse rounded bg-muted/70" />
|
||
<div className="mt-2 h-3 w-2/5 animate-pulse rounded bg-muted/60" />
|
||
</div>
|
||
<div className="flex items-center">
|
||
<div className="h-3 w-24 animate-pulse rounded bg-muted/60" />
|
||
</div>
|
||
<div className="flex items-center">
|
||
<div className="h-5 w-14 animate-pulse rounded-full bg-muted/70" />
|
||
</div>
|
||
<div className="flex items-center">
|
||
<div className="h-3 w-20 animate-pulse rounded bg-muted/60" />
|
||
</div>
|
||
<div className="flex items-center justify-start lg:justify-end">
|
||
<div className="h-7 w-16 animate-pulse rounded-xl bg-muted/70" />
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
|
||
{/* Why: suppress the generic empty state when any error banner is
|
||
visible (IPC reject via tasksError, cross-repo partial failure
|
||
via failedCount, or per-repo issue-side error). Showing
|
||
"No matching GitHub work" next to "Couldn't load issues from X/Y"
|
||
is contradictory and misleads the user into thinking they
|
||
typed the wrong query. */}
|
||
{!tasksLoading &&
|
||
filteredWorkItems.length === 0 &&
|
||
!tasksError &&
|
||
failedCount === 0 &&
|
||
perRepoSourceState.every((s) => !s.error) ? (
|
||
<div className="px-4 py-10 text-center">
|
||
<p className="text-base font-medium text-foreground">No matching GitHub work</p>
|
||
<p className="mt-2 text-sm text-muted-foreground">
|
||
Change the query or clear it.
|
||
</p>
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="divide-y divide-border/50">
|
||
{filteredWorkItems.map((item) => {
|
||
const itemRepo = repoMap.get(item.repoId) ?? null
|
||
return (
|
||
// Why: the row is a clickable container rather than a
|
||
// <button> because it holds nested interactive elements
|
||
// (Use button, ellipsis DropdownMenuTrigger, Radix
|
||
// TooltipTrigger). A <button> ancestor of another
|
||
// <button> is invalid HTML and triggers React hydration
|
||
// errors that break rendering of the whole page.
|
||
<div
|
||
// Why: combine repoId with item.id because two selected repos
|
||
// that route issues through the same upstream (e.g. fork +
|
||
// non-fork both resolving to stablyai/orca) surface the same
|
||
// item.id under different repoIds. React treats a bare id as
|
||
// a collision and warns + silently drops rows otherwise.
|
||
key={`${item.repoId}:${item.id}`}
|
||
role="button"
|
||
tabIndex={0}
|
||
onClick={() => setDialogWorkItem(item)}
|
||
onKeyDown={(event) => {
|
||
if (event.key === 'Enter' || event.key === ' ') {
|
||
event.preventDefault()
|
||
setDialogWorkItem(item)
|
||
}
|
||
}}
|
||
className="grid w-full cursor-pointer gap-2 px-3 py-2 text-left transition hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 grid-cols-[80px_minmax(0,3fr)_minmax(110px,0.8fr)_100px_110px_112px]"
|
||
>
|
||
<div className="flex items-center">
|
||
<span className="inline-flex items-center gap-1 rounded-md border border-border/50 bg-muted/40 px-1.5 py-0.5 text-muted-foreground">
|
||
{item.type === 'pr' ? (
|
||
<GitPullRequest className="size-3" />
|
||
) : (
|
||
<CircleDot className="size-3" />
|
||
)}
|
||
<span className="font-mono text-[11px] font-normal">
|
||
#{item.number}
|
||
</span>
|
||
</span>
|
||
</div>
|
||
|
||
<div className="min-w-0">
|
||
<div className="flex items-center gap-2">
|
||
<h3 className="truncate text-[15px] font-semibold text-foreground">
|
||
{item.title}
|
||
</h3>
|
||
{selectedRepos.length > 1 && itemRepo ? (
|
||
// Why: disambiguate rows when multiple repos are in
|
||
// the merged list — a single-repo view doesn't need it.
|
||
<RepoDotLabel
|
||
name={itemRepo.displayName}
|
||
color={itemRepo.badgeColor}
|
||
dotClassName="size-1.5"
|
||
className="shrink-0 text-[11px] text-muted-foreground"
|
||
/>
|
||
) : null}
|
||
</div>
|
||
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-muted-foreground">
|
||
<span>{item.author ?? 'unknown author'}</span>
|
||
{selectedRepos.length === 1 && itemRepo ? (
|
||
<span>{itemRepo.displayName}</span>
|
||
) : null}
|
||
{item.labels.slice(0, 3).map((label) => (
|
||
<span
|
||
key={label}
|
||
className="rounded-full border border-border/50 bg-background/80 px-1.5 py-0 text-[10px] text-muted-foreground"
|
||
>
|
||
{label}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="min-w-0 flex items-center text-xs text-muted-foreground">
|
||
<span className="truncate">
|
||
{item.branchName || item.baseRefName || 'workspace/default'}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="flex items-center">
|
||
<GHStatusCell item={item} repoPath={itemRepo?.path ?? null} />
|
||
</div>
|
||
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<div className="flex items-center text-[11px] text-muted-foreground">
|
||
{formatRelativeTime(item.updatedAt)}
|
||
</div>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="bottom" sideOffset={6}>
|
||
{new Date(item.updatedAt).toLocaleString()}
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
|
||
<div className="flex items-center justify-start gap-1 lg:justify-end">
|
||
<button
|
||
type="button"
|
||
onClick={(event) => {
|
||
event.stopPropagation()
|
||
handleUseWorkItem(item)
|
||
}}
|
||
className="inline-flex items-center gap-1 rounded-md border border-border/50 bg-background/80 px-2 py-1 text-[11px] text-foreground transition hover:bg-muted/60"
|
||
>
|
||
Use
|
||
<ArrowRight className="size-3" />
|
||
</button>
|
||
<DropdownMenu modal={false}>
|
||
<DropdownMenuTrigger asChild>
|
||
<button
|
||
type="button"
|
||
onClick={(e) => e.stopPropagation()}
|
||
className="rounded-lg p-1.5 text-muted-foreground transition hover:bg-muted/60 hover:text-foreground"
|
||
aria-label="More actions"
|
||
>
|
||
<EllipsisVertical className="size-4" />
|
||
</button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="end" onClick={(e) => e.stopPropagation()}>
|
||
<DropdownMenuItem onSelect={() => window.api.shell.openUrl(item.url)}>
|
||
<ExternalLink className="size-4" />
|
||
Open in browser
|
||
</DropdownMenuItem>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
{/* Pagination controls — GitHub-style with ellipsis */}
|
||
{filteredWorkItems.length > 0 && !tasksLoading && totalPages > 1 ? (
|
||
<PaginationBar
|
||
currentPage={currentPage}
|
||
totalPages={totalPages}
|
||
loadingTarget={loadingTargetPage}
|
||
onPageChange={(page) => {
|
||
if (page < pages.length) {
|
||
setCurrentPage(page)
|
||
} else {
|
||
void handleLoadNextPage(page)
|
||
}
|
||
}}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
) : !linearStatusChecked ? (
|
||
<div className="mt-4 flex items-center justify-center py-14">
|
||
<LoaderCircle className="size-5 animate-spin text-muted-foreground" />
|
||
</div>
|
||
) : !linearStatus.connected ? (
|
||
<div className="mt-4 flex flex-col items-center justify-center rounded-md border border-border/50 bg-muted/50 px-6 py-14 text-center shadow-sm">
|
||
<LinearIcon className="mb-4 size-8 text-muted-foreground/60" />
|
||
<p className="text-base font-medium text-foreground">Connect your Linear account</p>
|
||
<p className="mt-2 max-w-sm text-sm text-muted-foreground">
|
||
Browse and start work on your assigned Linear issues directly from here.
|
||
</p>
|
||
<Button
|
||
className="mt-5"
|
||
onClick={() => {
|
||
setLinearApiKeyDraft('')
|
||
setLinearConnectState('idle')
|
||
setLinearConnectError(null)
|
||
setLinearConnectOpen(true)
|
||
}}
|
||
>
|
||
Connect Linear
|
||
</Button>
|
||
</div>
|
||
) : (
|
||
/* Connected state: Linear issues table */
|
||
<div className="flex min-h-0 max-h-full flex-col rounded-md border border-t-0 border-border/50 bg-muted/50 overflow-hidden rounded-t-none shadow-sm">
|
||
<div className="flex-none grid grid-cols-[90px_minmax(0,3fr)_100px_120px_80px_90px_80px] gap-3 border-b border-border/50 px-3 py-2 text-[10px] font-medium uppercase tracking-[0.16em] text-muted-foreground">
|
||
<span>Identifier</span>
|
||
<span>Title</span>
|
||
<span>Team</span>
|
||
<span>Status</span>
|
||
<span>Priority</span>
|
||
<span>Updated</span>
|
||
<span />
|
||
</div>
|
||
|
||
<div
|
||
className="min-h-0 flex-initial overflow-y-auto scrollbar-sleek"
|
||
style={{ scrollbarGutter: 'stable' }}
|
||
>
|
||
{linearError ? (
|
||
<div className="border-b border-border px-4 py-4 text-sm text-destructive">
|
||
{linearError}
|
||
</div>
|
||
) : null}
|
||
|
||
{linearLoading && linearIssues.length === 0 ? (
|
||
// Why: shimmer skeleton matches the GitHub tab pattern — 3 placeholder
|
||
// rows while the initial fetch is in flight so the card never flashes empty.
|
||
<div className="divide-y divide-border/50">
|
||
{Array.from({ length: 3 }).map((_, i) => (
|
||
<div
|
||
key={i}
|
||
className="grid w-full gap-2 px-3 py-2 grid-cols-[90px_minmax(0,3fr)_100px_120px_80px_90px_80px]"
|
||
>
|
||
<div className="flex items-center">
|
||
<div className="h-7 w-16 animate-pulse rounded-lg bg-muted/70" />
|
||
</div>
|
||
<div className="min-w-0">
|
||
<div className="h-4 w-3/5 animate-pulse rounded bg-muted/70" />
|
||
<div className="mt-2 h-3 w-2/5 animate-pulse rounded bg-muted/60" />
|
||
</div>
|
||
<div className="flex items-center">
|
||
<div className="h-3 w-16 animate-pulse rounded bg-muted/60" />
|
||
</div>
|
||
<div className="flex items-center">
|
||
<div className="h-5 w-16 animate-pulse rounded-full bg-muted/70" />
|
||
</div>
|
||
<div className="flex items-center">
|
||
<div className="h-3 w-12 animate-pulse rounded bg-muted/60" />
|
||
</div>
|
||
<div className="flex items-center">
|
||
<div className="h-3 w-16 animate-pulse rounded bg-muted/60" />
|
||
</div>
|
||
<div className="flex items-center justify-start lg:justify-end">
|
||
<div className="h-7 w-16 animate-pulse rounded-xl bg-muted/70" />
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
|
||
{!linearLoading && linearIssues.length === 0 && !linearError ? (
|
||
<div className="px-4 py-10 text-center">
|
||
<p className="text-base font-medium text-foreground">No Linear issues found</p>
|
||
<p className="mt-2 text-sm text-muted-foreground">
|
||
{linearSearchInput
|
||
? 'Try a different search query.'
|
||
: 'No assigned issues. Try searching for something.'}
|
||
</p>
|
||
</div>
|
||
) : null}
|
||
|
||
{!linearLoading && linearIssues.length > 0 && filteredLinearIssues.length === 0 ? (
|
||
<div className="px-4 py-10 text-center">
|
||
<p className="text-base font-medium text-foreground">
|
||
No issues match the selected teams
|
||
</p>
|
||
<p className="mt-2 text-sm text-muted-foreground">
|
||
Try selecting more teams or click “All teams”.
|
||
</p>
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="divide-y divide-border/50">
|
||
{filteredLinearIssues.map((issue) => (
|
||
<div
|
||
key={issue.id}
|
||
role="button"
|
||
tabIndex={0}
|
||
onClick={() => setDrawerLinearIssue(issue)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter' || e.key === ' ') {
|
||
e.preventDefault()
|
||
setDrawerLinearIssue(issue)
|
||
}
|
||
}}
|
||
className="cursor-pointer grid w-full gap-2 px-3 py-2 text-left transition hover:bg-muted/40 grid-cols-[90px_minmax(0,3fr)_100px_120px_80px_90px_80px]"
|
||
>
|
||
<div className="flex items-center">
|
||
<span className="inline-flex items-center gap-1 rounded-md border border-border/50 bg-muted/40 px-1.5 py-0.5 text-muted-foreground">
|
||
<span className="font-mono text-[11px] font-normal">
|
||
{issue.identifier}
|
||
</span>
|
||
</span>
|
||
</div>
|
||
|
||
<div className="min-w-0">
|
||
<h3 className="truncate text-[15px] font-semibold text-foreground">
|
||
{issue.title}
|
||
</h3>
|
||
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-muted-foreground">
|
||
{issue.assignee ? <span>{issue.assignee.displayName}</span> : null}
|
||
{issue.labels.slice(0, 3).map((label) => (
|
||
<span
|
||
key={label}
|
||
className="rounded-full border border-border/50 bg-background/80 px-1.5 py-0 text-[10px] text-muted-foreground"
|
||
>
|
||
{label}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="min-w-0 flex items-center text-xs text-muted-foreground">
|
||
<span className="truncate">{issue.team.name}</span>
|
||
</div>
|
||
|
||
<div className="flex items-center">
|
||
<LinearStatusCell issue={issue} />
|
||
</div>
|
||
|
||
<div className="flex items-center">
|
||
<LinearPriorityCell issue={issue} />
|
||
</div>
|
||
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<div className="flex items-center text-[11px] text-muted-foreground">
|
||
{formatRelativeTime(issue.updatedAt)}
|
||
</div>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="bottom" sideOffset={6}>
|
||
{new Date(issue.updatedAt).toLocaleString()}
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
|
||
<div className="flex items-center justify-start gap-1 lg:justify-end">
|
||
<button
|
||
type="button"
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
handleUseLinearItem(issue)
|
||
}}
|
||
className="inline-flex items-center gap-1 rounded-md border border-border/50 bg-background/80 px-2 py-1 text-[11px] text-foreground transition hover:bg-muted/60"
|
||
>
|
||
Use
|
||
<ArrowRight className="size-3" />
|
||
</button>
|
||
<DropdownMenu modal={false}>
|
||
<DropdownMenuTrigger asChild>
|
||
<button
|
||
type="button"
|
||
onClick={(e) => e.stopPropagation()}
|
||
className="rounded-lg p-1.5 text-muted-foreground transition hover:bg-muted/60 hover:text-foreground"
|
||
aria-label="More actions"
|
||
>
|
||
<EllipsisVertical className="size-4" />
|
||
</button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="end" onClick={(e) => e.stopPropagation()}>
|
||
<DropdownMenuItem onSelect={() => window.api.shell.openUrl(issue.url)}>
|
||
<ExternalLink className="size-4" />
|
||
Open in browser
|
||
</DropdownMenuItem>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<Dialog
|
||
open={newIssueOpen}
|
||
onOpenChange={(open) => {
|
||
if (!newIssueSubmitting) {
|
||
setNewIssueOpen(open)
|
||
}
|
||
}}
|
||
>
|
||
<DialogContent
|
||
className="sm:max-w-lg"
|
||
onKeyDown={(event) => {
|
||
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
|
||
event.preventDefault()
|
||
void handleCreateNewIssue()
|
||
}
|
||
}}
|
||
>
|
||
<DialogHeader>
|
||
<DialogTitle>New GitHub issue</DialogTitle>
|
||
{(() => {
|
||
// Why: parent design doc §1 surface 2 — the composer is the
|
||
// non-negotiable surface because User D's regression (filing a
|
||
// personal TODO against upstream/fork after #1076 changed
|
||
// routing) is specifically about this dialog. The description
|
||
// line doubles as the source indicator: inlining the resolved
|
||
// `{owner}/{repo}` slug (e.g. "stablyai/orca") means the
|
||
// destination is impossible to miss before the user submits,
|
||
// without needing a secondary chip that duplicates the info.
|
||
// Falls back to the local displayName when the slug isn't
|
||
// resolved yet (pre-IPC cache hit, or non-GitHub remote). The
|
||
// multi-repo case uses the same computation — the Select below
|
||
// drives `newIssueTargetRepo`, so the active target is known.
|
||
const entry = newIssueTargetRepo
|
||
? perRepoSourceState.find((s) => s.repoId === newIssueTargetRepo.id)
|
||
: undefined
|
||
const issuesSlug = entry?.sources?.issues
|
||
? `${entry.sources.issues.owner}/${entry.sources.issues.repo}`
|
||
: null
|
||
const fallback = newIssueTargetRepo?.displayName ?? 'this repository'
|
||
return <DialogDescription>Filing in {issuesSlug ?? fallback}</DialogDescription>
|
||
})()}
|
||
{(() => {
|
||
// Why: mirror the Tasks-view selector in the composer so User D
|
||
// (fork contributor filing a personal TODO against their own
|
||
// fork) can flip the target *at the moment of filing* — the
|
||
// only moment that matters for this regression. Reuses the
|
||
// same cache entry the description line reads so no extra
|
||
// IPC round-trip is needed.
|
||
//
|
||
// Why sibling of DialogDescription (not nested inside it):
|
||
// DialogDescription renders a <p>, and `IssueSourceSelector`
|
||
// renders a <div role="group"> with <button>s inside. Nesting
|
||
// a div inside a <p> is invalid HTML — React emits a hydration
|
||
// warning and some a11y tools flag it. Rendering the selector
|
||
// as a sibling keeps both surfaces in the same header band
|
||
// without the nesting violation.
|
||
if (!newIssueTargetRepo) {
|
||
return null
|
||
}
|
||
const entry = perRepoSourceState.find((s) => s.repoId === newIssueTargetRepo.id)
|
||
if (!entry || !entry.sources?.upstreamCandidate || !entry.sources?.prs) {
|
||
return null
|
||
}
|
||
if (sameGitHubOwnerRepo(entry.sources.prs, entry.sources.upstreamCandidate)) {
|
||
return null
|
||
}
|
||
return (
|
||
<div className="mt-1">
|
||
<IssueSourceSelector
|
||
preference={newIssueTargetRepo.issueSourcePreference}
|
||
origin={entry.sources.prs}
|
||
upstream={entry.sources.upstreamCandidate}
|
||
disabled={newIssueSubmitting}
|
||
// Why: the composer only files issues, so the "Issues from
|
||
// <slug>" tooltip restates what the surrounding form already
|
||
// implies. Keep it on the Tasks header (that page also lists
|
||
// PRs, which the selector doesn't affect).
|
||
suppressTooltip
|
||
onChange={(next) => {
|
||
void setIssueSourcePreference(
|
||
newIssueTargetRepo.id,
|
||
newIssueTargetRepo.path,
|
||
next
|
||
)
|
||
}}
|
||
/>
|
||
</div>
|
||
)
|
||
})()}
|
||
</DialogHeader>
|
||
<div className="flex flex-col gap-3">
|
||
{selectedRepos.length > 1 ? (
|
||
<div className="flex flex-col gap-1">
|
||
<label className="text-[11px] font-medium text-muted-foreground">Repository</label>
|
||
<Select
|
||
value={newIssueRepoId ?? undefined}
|
||
onValueChange={(v) => setNewIssueRepoId(v)}
|
||
disabled={newIssueSubmitting}
|
||
>
|
||
<SelectTrigger>
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{selectedRepos.map((r) => (
|
||
<SelectItem key={r.id} value={r.id}>
|
||
<RepoDotLabel name={r.displayName} color={r.badgeColor} />
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
) : null}
|
||
<div className="flex flex-col gap-1">
|
||
<label className="text-[11px] font-medium text-muted-foreground">Title</label>
|
||
<Input
|
||
autoFocus
|
||
value={newIssueTitle}
|
||
onChange={(e) => setNewIssueTitle(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter' && !e.nativeEvent.isComposing) {
|
||
e.preventDefault()
|
||
void handleCreateNewIssue()
|
||
}
|
||
}}
|
||
placeholder="Short summary"
|
||
disabled={newIssueSubmitting}
|
||
/>
|
||
</div>
|
||
<div className="flex flex-col gap-1">
|
||
<label className="text-[11px] font-medium text-muted-foreground">
|
||
Description (optional, markdown)
|
||
</label>
|
||
<textarea
|
||
value={newIssueBody}
|
||
onChange={(e) => setNewIssueBody(e.target.value)}
|
||
placeholder="What's going on?"
|
||
rows={6}
|
||
disabled={newIssueSubmitting}
|
||
className="w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 resize-none max-h-60 overflow-y-auto"
|
||
/>
|
||
</div>
|
||
<p className="text-[10px] text-muted-foreground">Cmd/Ctrl+Enter to submit.</p>
|
||
</div>
|
||
<DialogFooter>
|
||
<Button
|
||
variant="outline"
|
||
onClick={() => setNewIssueOpen(false)}
|
||
disabled={newIssueSubmitting}
|
||
>
|
||
Cancel
|
||
</Button>
|
||
<Button
|
||
onClick={() => void handleCreateNewIssue()}
|
||
disabled={!newIssueTargetRepo || !newIssueTitle.trim() || newIssueSubmitting}
|
||
>
|
||
{newIssueSubmitting ? (
|
||
<>
|
||
<LoaderCircle className="size-4 animate-spin" />
|
||
Creating…
|
||
</>
|
||
) : (
|
||
'Create issue'
|
||
)}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
<Dialog
|
||
open={newLinearIssueOpen}
|
||
onOpenChange={(open) => {
|
||
if (!newLinearIssueSubmitting) {
|
||
setNewLinearIssueOpen(open)
|
||
}
|
||
}}
|
||
>
|
||
<DialogContent
|
||
className="sm:max-w-lg"
|
||
onKeyDown={(event) => {
|
||
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
|
||
event.preventDefault()
|
||
void handleCreateNewLinearIssue()
|
||
}
|
||
}}
|
||
>
|
||
<DialogHeader>
|
||
<DialogTitle>New Linear issue</DialogTitle>
|
||
<DialogDescription>
|
||
{availableTeams.length > 1
|
||
? 'Creates a new issue in the selected team.'
|
||
: `Creates a new issue in ${newLinearIssueTargetTeam?.name ?? 'your team'}.`}
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<div className="flex flex-col gap-3">
|
||
{availableTeams.length > 1 ? (
|
||
<div className="flex flex-col gap-1">
|
||
<label className="text-[11px] font-medium text-muted-foreground">Team</label>
|
||
<Select
|
||
value={newLinearIssueTeamId ?? undefined}
|
||
onValueChange={(v) => setNewLinearIssueTeamId(v)}
|
||
disabled={newLinearIssueSubmitting}
|
||
>
|
||
<SelectTrigger>
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{availableTeams.map((t) => (
|
||
<SelectItem key={t.id} value={t.id}>
|
||
{t.key} — {t.name}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
) : null}
|
||
<div className="flex flex-col gap-1">
|
||
<label className="text-[11px] font-medium text-muted-foreground">Title</label>
|
||
<Input
|
||
autoFocus
|
||
value={newLinearIssueTitle}
|
||
onChange={(e) => setNewLinearIssueTitle(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter' && !e.nativeEvent.isComposing) {
|
||
e.preventDefault()
|
||
void handleCreateNewLinearIssue()
|
||
}
|
||
}}
|
||
placeholder="Short summary"
|
||
disabled={newLinearIssueSubmitting}
|
||
/>
|
||
</div>
|
||
<div className="flex flex-col gap-1">
|
||
<label className="text-[11px] font-medium text-muted-foreground">
|
||
Description (optional, markdown)
|
||
</label>
|
||
<textarea
|
||
value={newLinearIssueBody}
|
||
onChange={(e) => setNewLinearIssueBody(e.target.value)}
|
||
placeholder="What's going on?"
|
||
rows={6}
|
||
disabled={newLinearIssueSubmitting}
|
||
className="w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 resize-none max-h-60 overflow-y-auto"
|
||
/>
|
||
</div>
|
||
<p className="text-[10px] text-muted-foreground">Cmd/Ctrl+Enter to submit.</p>
|
||
</div>
|
||
<DialogFooter>
|
||
<Button
|
||
variant="outline"
|
||
onClick={() => setNewLinearIssueOpen(false)}
|
||
disabled={newLinearIssueSubmitting}
|
||
>
|
||
Cancel
|
||
</Button>
|
||
<Button
|
||
onClick={() => void handleCreateNewLinearIssue()}
|
||
disabled={
|
||
!newLinearIssueTargetTeam || !newLinearIssueTitle.trim() || newLinearIssueSubmitting
|
||
}
|
||
>
|
||
{newLinearIssueSubmitting ? (
|
||
<>
|
||
<LoaderCircle className="size-4 animate-spin" />
|
||
Creating…
|
||
</>
|
||
) : (
|
||
'Create issue'
|
||
)}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
<GitHubItemDialog
|
||
workItem={dialogWorkItem}
|
||
repoPath={
|
||
// Why: the dialog is for a single item — resolve its repoPath from the
|
||
// item's own repoId (set when fan-out merged the list) so it works in
|
||
// cross-repo mode too. Reusing the memoized repo map avoids an O(n)
|
||
// scan on every render while the dialog is open.
|
||
dialogWorkItem ? (repoMap.get(dialogWorkItem.repoId)?.path ?? null) : null
|
||
}
|
||
onUse={(item) => {
|
||
setDialogWorkItem(null)
|
||
handleUseWorkItem(item)
|
||
}}
|
||
onClose={() => setDialogWorkItem(null)}
|
||
/>
|
||
|
||
<LinearItemDrawer
|
||
issue={drawerLinearIssue}
|
||
onUse={(issue) => {
|
||
setDrawerLinearIssue(null)
|
||
handleUseLinearItem(issue)
|
||
}}
|
||
onClose={() => setDrawerLinearIssue(null)}
|
||
/>
|
||
|
||
<Dialog
|
||
open={linearConnectOpen}
|
||
onOpenChange={(open) => {
|
||
if (linearConnectState !== 'connecting') {
|
||
setLinearConnectOpen(open)
|
||
}
|
||
}}
|
||
>
|
||
<DialogContent
|
||
className="sm:max-w-md"
|
||
onKeyDown={(e) => {
|
||
if (
|
||
e.key === 'Enter' &&
|
||
linearApiKeyDraft.trim() &&
|
||
linearConnectState !== 'connecting'
|
||
) {
|
||
e.preventDefault()
|
||
void handleLinearConnect()
|
||
}
|
||
}}
|
||
>
|
||
<DialogHeader className="gap-3">
|
||
<DialogTitle className="leading-tight">Connect Linear</DialogTitle>
|
||
<DialogDescription>
|
||
Paste a <strong className="font-semibold text-foreground">Personal API key</strong> to
|
||
browse your assigned issues.
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<div className="flex flex-col gap-3">
|
||
<Input
|
||
autoFocus
|
||
type="password"
|
||
placeholder="lin_api_..."
|
||
value={linearApiKeyDraft}
|
||
onChange={(e) => {
|
||
setLinearApiKeyDraft(e.target.value)
|
||
if (linearConnectState === 'error') {
|
||
setLinearConnectState('idle')
|
||
setLinearConnectError(null)
|
||
}
|
||
}}
|
||
disabled={linearConnectState === 'connecting'}
|
||
/>
|
||
{linearConnectState === 'error' && linearConnectError && (
|
||
<p className="text-xs text-destructive">{linearConnectError}</p>
|
||
)}
|
||
<p className="text-xs text-muted-foreground">
|
||
Create one in{' '}
|
||
<button
|
||
className="text-primary underline-offset-2 hover:underline"
|
||
onClick={() =>
|
||
window.api.shell.openUrl('https://linear.app/settings/account/security')
|
||
}
|
||
>
|
||
Linear Settings → Security
|
||
</button>{' '}
|
||
→ <strong className="font-semibold text-foreground">New API key</strong> (not{' '}
|
||
<span className="text-foreground">New passkey</span>).
|
||
</p>
|
||
<p className="flex items-center gap-1.5 text-[11px] text-muted-foreground/70">
|
||
<Lock className="size-3 shrink-0" />
|
||
Your key is encrypted via the OS keychain and stored locally.
|
||
</p>
|
||
</div>
|
||
<DialogFooter>
|
||
<Button
|
||
variant="outline"
|
||
onClick={() => setLinearConnectOpen(false)}
|
||
disabled={linearConnectState === 'connecting'}
|
||
>
|
||
Cancel
|
||
</Button>
|
||
<Button
|
||
onClick={() => void handleLinearConnect()}
|
||
disabled={!linearApiKeyDraft.trim() || linearConnectState === 'connecting'}
|
||
>
|
||
{linearConnectState === 'connecting' ? (
|
||
<>
|
||
<LoaderCircle className="size-4 animate-spin" />
|
||
Verifying…
|
||
</>
|
||
) : (
|
||
'Connect'
|
||
)}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
)
|
||
}
|