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:
parent
76f5d615e8
commit
409cd43b42
|
|
@ -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'))
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@ export async function mapLinearIssue(
|
|||
avatarUrl: assignee.avatarUrl ?? undefined
|
||||
}
|
||||
: undefined,
|
||||
estimate: issue.estimate ?? null,
|
||||
priority: issue.priority,
|
||||
updatedAt: issue.updatedAt.toISOString()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}, [])
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue