Add Linear issue estimate editing from details views (#2226)

- Fetch, map, validate, and persist Linear estimates through IPC and RPC
- Add estimate controls with optimistic updates in Linear drawers/workspaces
- Keep user-opened sub-issues visible even when they are outside the list filter
This commit is contained in:
Jinjing 2026-05-17 23:11:48 -07:00 committed by GitHub
parent 76f5d615e8
commit 409cd43b42
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 321 additions and 35 deletions

View File

@ -148,6 +148,13 @@ export function registerLinearHandlers(): void {
) {
return { ok: false, error: 'Priority must be an integer 0-4' }
}
if (
u.estimate !== undefined &&
u.estimate !== null &&
(!Number.isInteger(u.estimate) || u.estimate < 0)
) {
return { ok: false, error: 'Estimate must be a non-negative integer' }
}
if (
u.labelIds !== undefined &&
(!Array.isArray(u.labelIds) || !u.labelIds.every((id: unknown) => typeof id === 'string'))

View File

@ -35,6 +35,7 @@ function rawIssue(id: string, updatedAt = '2026-01-01T00:00:00.000Z') {
title: id,
description: 'Description',
url: `https://linear.app/${id}`,
estimate: 3,
priority: 2,
updatedAt,
labelIds: ['label-1'],
@ -63,12 +64,14 @@ describe('Linear issue queries', () => {
labels: ['Bug'],
labelIds: ['label-1'],
workspaceId: 'workspace-1',
team: { id: 'team-1' }
team: { id: 'team-1' },
estimate: 3
}
])
expect(rawRequest).toHaveBeenCalledTimes(1)
expect(rawRequest.mock.calls[0][0]).toContain('query OrcaLinearIssues')
expect(rawRequest.mock.calls[0][0]).toContain('estimate')
})
it('keeps single-workspace search results in Linear relevance order', async () => {
@ -119,4 +122,23 @@ describe('Linear issue queries', () => {
}
])
})
it('sends estimate updates through to Linear', async () => {
const updateIssue = vi.fn().mockResolvedValue({ success: true })
getClients.mockReturnValue([
{
...makeEntry(),
client: {
updateIssue
}
}
])
const { updateIssue: updateLinearIssue } = await import('./issues')
await expect(updateLinearIssue('issue-1', { estimate: 5 }, 'workspace-1')).resolves.toEqual({
ok: true
})
expect(updateIssue).toHaveBeenCalledWith('issue-1', { estimate: 5 })
})
})

View File

@ -23,6 +23,7 @@ type LinearIssueNode = {
title: string
description?: string | null
url: string
estimate?: number | null
priority: number
updatedAt: string
labelIds?: string[] | null
@ -64,6 +65,7 @@ const LINEAR_ISSUE_NODE_FIELDS = `
description
url
priority
estimate
updatedAt
labelIds
state {
@ -192,6 +194,7 @@ function mapRawIssueForWorkspace(
avatarUrl: issue.assignee.avatarUrl ?? undefined
}
: undefined,
estimate: issue.estimate ?? null,
priority: issue.priority,
updatedAt: issue.updatedAt,
workspaceId: entry.workspace.id,
@ -436,6 +439,9 @@ export async function updateIssue(
if (updates.assigneeId !== undefined) {
payload.assigneeId = updates.assigneeId
}
if (updates.estimate !== undefined) {
payload.estimate = updates.estimate
}
if (updates.priority !== undefined) {
payload.priority = updates.priority
}

View File

@ -93,6 +93,7 @@ export async function mapLinearIssue(
avatarUrl: assignee.avatarUrl ?? undefined
}
: undefined,
estimate: issue.estimate ?? null,
priority: issue.priority,
updatedAt: issue.updatedAt.toISOString()
}

View File

@ -74,6 +74,7 @@ describe('linear RPC methods', () => {
updates: {
stateId: 'state-1',
assigneeId: null,
estimate: 5,
priority: 2,
labelIds: ['label-1'],
projectId: 'project-1'
@ -124,6 +125,7 @@ describe('linear RPC methods', () => {
{
stateId: 'state-1',
assigneeId: null,
estimate: 5,
priority: 2,
labelIds: ['label-1'],
projectId: 'project-1'

View File

@ -72,6 +72,7 @@ const IssueUpdate = z.object({
stateId: OptionalString,
title: OptionalString,
assigneeId: z.union([z.string(), z.null()]).optional(),
estimate: z.union([z.number().int().min(0), z.null()]).optional(),
priority: z.number().int().min(0).max(4).optional(),
labelIds: z.array(z.string()).optional(),
projectId: z.union([z.string(), z.null()]).optional()

View File

@ -56,6 +56,7 @@ import type {
type LinearIssueWorkspaceProps = {
issue: LinearIssue | null
onUse: (issue: LinearIssue) => void
onOpenIssue: (issue: LinearIssue) => void
onClose: () => void
}
@ -92,17 +93,44 @@ function LinearIssueAvatar({
)
}
function LinearIssueSubIssueButton({ issue }: { issue: LinearIssue }): React.JSX.Element {
function LinearIssueSubIssueButton({
issue,
onOpenIssue
}: {
issue: LinearIssue
onOpenIssue: (issue: LinearIssue) => void
}): React.JSX.Element {
const settings = useAppStore((s) => s.settings)
const fetchLinearIssue = useAppStore((s) => s.fetchLinearIssue)
const [open, setOpen] = useState(false)
const [title, setTitle] = useState('')
const [subIssues, setSubIssues] = useState<LinearIssueChildSummary[]>(issue.subIssues ?? [])
const [submitting, setSubmitting] = useState(false)
const [openingSubIssueId, setOpeningSubIssueId] = useState<string | null>(null)
useEffect(() => {
setSubIssues(issue.subIssues ?? [])
}, [issue.id, issue.subIssues])
const handleOpenSubIssue = useCallback(
async (subIssue: LinearIssueChildSummary) => {
setOpeningSubIssueId(subIssue.id)
try {
const fullIssue = await fetchLinearIssue(subIssue.id, issue.workspaceId)
if (fullIssue) {
onOpenIssue(fullIssue)
} else {
toast.error('Failed to load sub-issue')
}
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to load sub-issue')
} finally {
setOpeningSubIssueId(null)
}
},
[fetchLinearIssue, issue.workspaceId, onOpenIssue]
)
const handleCreate = useCallback(async () => {
const trimmed = title.trim()
if (!trimmed) {
@ -148,12 +176,17 @@ function LinearIssueSubIssueButton({ issue }: { issue: LinearIssue }): React.JSX
<button
key={subIssue.id}
type="button"
onClick={() => window.api.shell.openUrl(subIssue.url)}
onClick={() => void handleOpenSubIssue(subIssue)}
disabled={openingSubIssueId !== null}
className="flex min-h-8 w-full min-w-0 items-center gap-2 rounded-md px-1.5 py-1 text-left text-sm text-muted-foreground transition hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<span className="shrink-0 font-mono text-xs">{subIssue.identifier}</span>
<span className="min-w-0 flex-1 truncate">{subIssue.title}</span>
<ArrowRight className="size-3.5 shrink-0" />
{openingSubIssueId === subIssue.id ? (
<LoaderCircle className="size-3.5 shrink-0 animate-spin" />
) : (
<ArrowRight className="size-3.5 shrink-0" />
)}
</button>
))}
</div>
@ -342,6 +375,7 @@ function LinearIssueSidebarProjectCard({
export default function LinearIssueWorkspace({
issue,
onUse,
onOpenIssue,
onClose
}: LinearIssueWorkspaceProps): React.JSX.Element {
const settings = useAppStore((s) => s.settings)
@ -443,6 +477,7 @@ export default function LinearIssueWorkspace({
state: prev.state,
priority: prev.priority,
assignee: prev.assignee,
estimate: prev.estimate,
labelIds: prev.labelIds,
labels: prev.labels
}
@ -631,7 +666,7 @@ export default function LinearIssueWorkspace({
)}
</section>
<LinearIssueSubIssueButton issue={displayed} />
<LinearIssueSubIssueButton issue={displayed} onOpenIssue={onOpenIssue} />
<section className="mt-12 border-t border-border/60 pt-9">
<div className="mb-8 flex items-center justify-between gap-3">

View File

@ -6,6 +6,7 @@ import {
ChevronDown,
Circle,
ExternalLink,
Gauge,
LoaderCircle,
Send,
Tag,
@ -15,6 +16,7 @@ import {
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Sheet, SheetContent, SheetDescription, SheetTitle } from '@/components/ui/sheet'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
@ -68,6 +70,12 @@ const LINEAR_EDIT_MENU_ITEM_CLASS =
const LINEAR_EDIT_MENU_ITEM_WITH_ICON_CLASS =
'flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-[12px] hover:bg-accent'
const LINEAR_ESTIMATE_PRESETS = [1, 2, 3, 5, 8] as const
export function formatLinearEstimateLabel(estimate: number | null | undefined): string {
return estimate === null || estimate === undefined ? 'Set estimate' : `Estimate ${estimate}`
}
function LinearEditChipAdornment({
loading,
pending
@ -110,6 +118,7 @@ type LinearItemDrawerProps = {
export type LinearEditState = {
state: LinearIssue['state']
priority: number
estimate: number | null | undefined
assignee: LinearIssue['assignee']
labelIds: string[]
labels: string[]
@ -129,6 +138,8 @@ export function LinearIssueEditSection({
layout = 'chips'
}: EditSectionProps): React.JSX.Element {
const [labelPopoverOpen, setLabelPopoverOpen] = useState(false)
const [estimatePopoverOpen, setEstimatePopoverOpen] = useState(false)
const [estimateInput, setEstimateInput] = useState('')
const patchLinearIssue = useAppStore((s) => s.patchLinearIssue)
const settings = useAppStore((s) => s.settings)
const { isPending, run } = useImmediateMutation()
@ -136,6 +147,7 @@ export function LinearIssueEditSection({
const {
state: localState,
priority: localPriority,
estimate: localEstimate,
assignee: localAssignee,
labelIds: localLabelIds,
labels: localLabels
@ -146,6 +158,14 @@ export function LinearIssueEditSection({
const labels = useTeamLabels(teamId, settings, issue.workspaceId)
const members = useTeamMembers(teamId, settings, issue.workspaceId)
useEffect(() => {
if (!estimatePopoverOpen) {
setEstimateInput(
localEstimate === null || localEstimate === undefined ? '' : String(localEstimate)
)
}
}, [estimatePopoverOpen, localEstimate])
const handleStateChange = useCallback(
(stateId: string) => {
const newState = states.data.find((s) => s.id === stateId)
@ -201,6 +221,42 @@ export function LinearIssueEditSection({
[issue.id, issue.workspaceId, localPriority, settings, patchLinearIssue, run, onEditStateChange]
)
const handleEstimateChange = useCallback(
(estimate: number | null) => {
const prevEstimate = localEstimate
run('estimate', {
mutate: () => linearUpdateIssue(settings, issue.id, { estimate }, issue.workspaceId),
onOptimistic: () => {
onEditStateChange({ estimate })
patchLinearIssue(issue.id, { estimate })
setEstimatePopoverOpen(false)
},
onRevert: () => {
onEditStateChange({ estimate: prevEstimate })
patchLinearIssue(issue.id, { estimate: prevEstimate })
},
onError: (err) => toast.error(err)
})
},
[issue.id, issue.workspaceId, localEstimate, settings, patchLinearIssue, run, onEditStateChange]
)
const handleEstimateSubmit = useCallback(() => {
const trimmed = estimateInput.trim()
if (!trimmed) {
handleEstimateChange(null)
return
}
const estimate = Number(trimmed)
if (!Number.isInteger(estimate) || estimate < 0) {
toast.error('Estimate must be a non-negative integer')
return
}
handleEstimateChange(estimate)
}, [estimateInput, handleEstimateChange])
const handleAssigneeChange = useCallback(
(memberId: string) => {
const assigneeId = memberId === '__unassign__' ? null : memberId
@ -278,6 +334,7 @@ export function LinearIssueEditSection({
)?.id
const statePending = isPending('state')
const priorityPending = isPending('priority')
const estimatePending = isPending('estimate')
const assigneePending = isPending('assignee')
const labelsPending = isPending('labels')
const labelSummary =
@ -468,14 +525,73 @@ export function LinearIssueEditSection({
</PopoverContent>
</Popover>
<button
type="button"
className="flex min-h-9 w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-muted-foreground"
disabled
>
<AlertTriangle className={propertyIconClass} />
<span className="min-w-0 flex-1 truncate">Set estimate</span>
</button>
<Popover open={estimatePopoverOpen} onOpenChange={setEstimatePopoverOpen}>
<PopoverTrigger asChild>
<button
type="button"
disabled={estimatePending}
className={propertyRowClass}
aria-busy={estimatePending}
>
<Gauge className={propertyIconClass} />
<span className="min-w-0 flex-1 truncate">
{formatLinearEstimateLabel(localEstimate)}
</span>
<LinearEditChipAdornment pending={estimatePending} />
</button>
</PopoverTrigger>
<PopoverContent className="w-64 p-3" align="start">
<div className="space-y-3">
<div className="grid grid-cols-5 gap-1.5">
{LINEAR_ESTIMATE_PRESETS.map((estimate) => (
<button
key={estimate}
type="button"
onClick={() => handleEstimateChange(estimate)}
className={cn(
'flex h-8 items-center justify-center rounded-md border border-border text-sm hover:bg-accent',
localEstimate === estimate && 'border-primary bg-accent text-foreground'
)}
>
{estimate}
</button>
))}
</div>
<Input
value={estimateInput}
onChange={(event) => setEstimateInput(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
handleEstimateSubmit()
}
}}
inputMode="numeric"
placeholder="Custom estimate"
className="h-8 text-sm"
/>
<div className="flex items-center justify-between gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => handleEstimateChange(null)}
>
Clear
</Button>
<Button
type="button"
size="sm"
onClick={handleEstimateSubmit}
disabled={estimatePending}
>
{estimatePending ? <LoaderCircle className="size-3.5 animate-spin" /> : null}
Save
</Button>
</div>
</div>
</PopoverContent>
</Popover>
</div>
</section>
@ -641,6 +757,72 @@ export function LinearIssueEditSection({
</PopoverContent>
</Popover>
{/* Estimate */}
<Popover open={estimatePopoverOpen} onOpenChange={setEstimatePopoverOpen}>
<PopoverTrigger asChild>
<button
type="button"
disabled={estimatePending}
className={LINEAR_EDIT_CHIP_CLASS}
aria-busy={estimatePending}
>
<span className="truncate">{formatLinearEstimateLabel(localEstimate)}</span>
<LinearEditChipAdornment pending={estimatePending} />
</button>
</PopoverTrigger>
<PopoverContent className="w-64 p-3" align="start">
<div className="space-y-3">
<div className="grid grid-cols-5 gap-1.5">
{LINEAR_ESTIMATE_PRESETS.map((estimate) => (
<button
key={estimate}
type="button"
onClick={() => handleEstimateChange(estimate)}
className={cn(
'flex h-8 items-center justify-center rounded-md border border-border text-sm hover:bg-accent',
localEstimate === estimate && 'border-primary bg-accent text-foreground'
)}
>
{estimate}
</button>
))}
</div>
<Input
value={estimateInput}
onChange={(event) => setEstimateInput(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
handleEstimateSubmit()
}
}}
inputMode="numeric"
placeholder="Custom estimate"
className="h-8 text-sm"
/>
<div className="flex items-center justify-between gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => handleEstimateChange(null)}
>
Clear
</Button>
<Button
type="button"
size="sm"
onClick={handleEstimateSubmit}
disabled={estimatePending}
>
{estimatePending ? <LoaderCircle className="size-3.5 animate-spin" /> : null}
Save
</Button>
</div>
</div>
</PopoverContent>
</Popover>
{/* Assignee */}
<Popover>
<PopoverTrigger asChild>
@ -888,6 +1070,7 @@ export function initLinearIssueEditState(issue: LinearIssue): LinearEditState {
return {
state: issue.state,
priority: issue.priority,
estimate: issue.estimate,
assignee: issue.assignee,
labelIds: issue.labelIds,
labels: issue.labels
@ -910,6 +1093,7 @@ export default function LinearItemDrawer({
const handleEditStateChange = useCallback((patch: Partial<LinearEditState>) => {
hasEditedRef.current = true
setFullIssue((prev) => (prev ? { ...prev, ...patch } : prev))
setEditState((prev) => (prev ? { ...prev, ...patch } : prev))
}, [])

View File

@ -1972,6 +1972,7 @@ export default function TaskPage(): React.JSX.Element {
const [selectedLinearIssueId, setSelectedLinearIssueId] = useState<string | null>(null)
const [selectedLinearIssueFallback, setSelectedLinearIssueFallback] =
useState<LinearIssue | null>(null)
const [selectedLinearIssueCanFloat, setSelectedLinearIssueCanFloat] = useState(false)
// Why: the Linear list keeps its own fetched array, while cell edits patch
// the shared caches. Subscribing to just the Linear caches lets the list and
@ -1991,9 +1992,30 @@ export default function TaskPage(): React.JSX.Element {
? (cachedSelectedLinearIssue ?? selectedLinearIssueFallback)
: null
const setSelectedLinearIssue = useCallback((issue: LinearIssue | null) => {
setSelectedLinearIssueId(issue?.id ?? null)
setSelectedLinearIssueFallback(issue)
const setSelectedLinearIssue = useCallback(
(issue: LinearIssue | null, options?: { allowOutsideList?: boolean }) => {
setSelectedLinearIssueCanFloat(Boolean(issue && options?.allowOutsideList))
setSelectedLinearIssueId(issue?.id ?? null)
setSelectedLinearIssueFallback(issue)
},
[]
)
const openRelatedLinearIssue = useCallback(
(issue: LinearIssue) => {
setSelectedLinearIssue(issue, { allowOutsideList: true })
},
[setSelectedLinearIssue]
)
const closeSelectedLinearIssue = useCallback(() => {
setSelectedLinearIssue(null)
}, [setSelectedLinearIssue])
const clearSelectedLinearIssue = useCallback(() => {
setSelectedLinearIssueCanFloat(false)
setSelectedLinearIssueId(null)
setSelectedLinearIssueFallback(null)
}, [])
// Linear tab state
@ -3096,34 +3118,34 @@ export default function TaskPage(): React.JSX.Element {
return
}
if (!linearStatus.connected || filteredLinearIssues.length === 0) {
if (selectedLinearIssueId !== null) {
setSelectedLinearIssueId(null)
}
if (selectedLinearIssueFallback !== null) {
setSelectedLinearIssueFallback(null)
if (!linearStatus.connected) {
clearSelectedLinearIssue()
return
}
if (filteredLinearIssues.length === 0) {
if (!selectedLinearIssueCanFloat) {
clearSelectedLinearIssue()
}
return
}
// Why: the corrected Linear surface is list-first. Keep an open inspector
// only while its issue remains in the current filter instead of auto-opening
// the first row and turning the list back into navigation chrome.
// the first row and turning the list back into navigation chrome. Related
// sub-issue navigation is allowed to stay open because it is user-directed.
if (
selectedLinearIssueId &&
!selectedLinearIssueCanFloat &&
!filteredLinearIssues.some((issue) => issue.id === selectedLinearIssueId)
) {
if (selectedLinearIssueId !== null) {
setSelectedLinearIssueId(null)
}
if (selectedLinearIssueFallback !== null) {
setSelectedLinearIssueFallback(null)
}
clearSelectedLinearIssue()
}
}, [
clearSelectedLinearIssue,
filteredLinearIssues,
linearStatus.connected,
selectedLinearIssueFallback,
selectedLinearIssueCanFloat,
selectedLinearIssueId,
taskResumeApplied,
taskSource
@ -3261,8 +3283,7 @@ export default function TaskPage(): React.JSX.Element {
<Select
value={selectedLinearWorkspaceId ?? undefined}
onValueChange={(value) => {
setSelectedLinearIssueId(null)
setSelectedLinearIssueFallback(null)
clearSelectedLinearIssue()
setLinearIssues([])
setLinearError(null)
setLinearLoading(true)
@ -4798,7 +4819,8 @@ export default function TaskPage(): React.JSX.Element {
<LinearIssueWorkspace
issue={selectedLinearIssue}
onUse={handleUseLinearItem}
onClose={() => setSelectedLinearIssue(null)}
onOpenIssue={openRelatedLinearIssue}
onClose={closeSelectedLinearIssue}
/>
</div>
)}

View File

@ -193,7 +193,7 @@ describe('runtime linear client', () => {
await linearUpdateIssue(
{ activeRuntimeEnvironmentId: 'env-1' },
'issue-1',
{ priority: 2 },
{ estimate: 5, priority: 2 },
'workspace-1'
)
await linearCreateSubIssue(
@ -219,7 +219,11 @@ describe('runtime linear client', () => {
expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(2, {
selector: 'env-1',
method: 'linear.updateIssue',
params: { id: 'issue-1', updates: { priority: 2 }, workspaceId: 'workspace-1' },
params: {
id: 'issue-1',
updates: { estimate: 5, priority: 2 },
workspaceId: 'workspace-1'
},
timeoutMs: 30_000
})
expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(3, {

View File

@ -837,6 +837,7 @@ export type LinearIssue = {
displayName: string
avatarUrl?: string
}
estimate?: number | null
priority: number
updatedAt: string
}
@ -889,6 +890,7 @@ export type LinearIssueUpdate = {
stateId?: string
title?: string
assigneeId?: string | null
estimate?: number | null
priority?: number
labelIds?: string[]
projectId?: string | null