From 409cd43b422384e8c8ec55b7c24387fd21bcee07 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 17 May 2026 23:11:48 -0700 Subject: [PATCH] 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 --- src/main/ipc/linear.ts | 7 + src/main/linear/issues.test.ts | 24 ++- src/main/linear/issues.ts | 6 + src/main/linear/mappers.ts | 1 + src/main/runtime/rpc/methods/linear.test.ts | 2 + src/main/runtime/rpc/methods/linear.ts | 1 + .../src/components/LinearIssueWorkspace.tsx | 43 +++- .../src/components/LinearItemDrawer.tsx | 200 +++++++++++++++++- src/renderer/src/components/TaskPage.tsx | 62 ++++-- .../src/runtime/runtime-linear-client.test.ts | 8 +- src/shared/types.ts | 2 + 11 files changed, 321 insertions(+), 35 deletions(-) diff --git a/src/main/ipc/linear.ts b/src/main/ipc/linear.ts index f08a49653..f3df09c84 100644 --- a/src/main/ipc/linear.ts +++ b/src/main/ipc/linear.ts @@ -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')) diff --git a/src/main/linear/issues.test.ts b/src/main/linear/issues.test.ts index ebc49628f..f6d0b0e60 100644 --- a/src/main/linear/issues.test.ts +++ b/src/main/linear/issues.test.ts @@ -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 }) + }) }) diff --git a/src/main/linear/issues.ts b/src/main/linear/issues.ts index 4bade32ba..1c10d51fa 100644 --- a/src/main/linear/issues.ts +++ b/src/main/linear/issues.ts @@ -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 } diff --git a/src/main/linear/mappers.ts b/src/main/linear/mappers.ts index 638d9a19f..752c60f47 100644 --- a/src/main/linear/mappers.ts +++ b/src/main/linear/mappers.ts @@ -93,6 +93,7 @@ export async function mapLinearIssue( avatarUrl: assignee.avatarUrl ?? undefined } : undefined, + estimate: issue.estimate ?? null, priority: issue.priority, updatedAt: issue.updatedAt.toISOString() } diff --git a/src/main/runtime/rpc/methods/linear.test.ts b/src/main/runtime/rpc/methods/linear.test.ts index 3f0a905cb..e542ce426 100644 --- a/src/main/runtime/rpc/methods/linear.test.ts +++ b/src/main/runtime/rpc/methods/linear.test.ts @@ -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' diff --git a/src/main/runtime/rpc/methods/linear.ts b/src/main/runtime/rpc/methods/linear.ts index d9e4c0588..a16e8637e 100644 --- a/src/main/runtime/rpc/methods/linear.ts +++ b/src/main/runtime/rpc/methods/linear.ts @@ -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() diff --git a/src/renderer/src/components/LinearIssueWorkspace.tsx b/src/renderer/src/components/LinearIssueWorkspace.tsx index 984020e2b..27de413ae 100644 --- a/src/renderer/src/components/LinearIssueWorkspace.tsx +++ b/src/renderer/src/components/LinearIssueWorkspace.tsx @@ -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(issue.subIssues ?? []) const [submitting, setSubmitting] = useState(false) + const [openingSubIssueId, setOpeningSubIssueId] = useState(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 ))} @@ -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({ )} - +
diff --git a/src/renderer/src/components/LinearItemDrawer.tsx b/src/renderer/src/components/LinearItemDrawer.tsx index 461025a70..e1fc8e6ee 100644 --- a/src/renderer/src/components/LinearItemDrawer.tsx +++ b/src/renderer/src/components/LinearItemDrawer.tsx @@ -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({ - + + + + + +
+
+ {LINEAR_ESTIMATE_PRESETS.map((estimate) => ( + + ))} +
+ setEstimateInput(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + handleEstimateSubmit() + } + }} + inputMode="numeric" + placeholder="Custom estimate" + className="h-8 text-sm" + /> +
+ + +
+
+
+
@@ -641,6 +757,72 @@ export function LinearIssueEditSection({ + {/* Estimate */} + + + + + +
+
+ {LINEAR_ESTIMATE_PRESETS.map((estimate) => ( + + ))} +
+ setEstimateInput(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + handleEstimateSubmit() + } + }} + inputMode="numeric" + placeholder="Custom estimate" + className="h-8 text-sm" + /> +
+ + +
+
+
+
+ {/* Assignee */} @@ -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) => { hasEditedRef.current = true + setFullIssue((prev) => (prev ? { ...prev, ...patch } : prev)) setEditState((prev) => (prev ? { ...prev, ...patch } : prev)) }, []) diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index d90067a5b..d90180fff 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -1972,6 +1972,7 @@ export default function TaskPage(): React.JSX.Element { const [selectedLinearIssueId, setSelectedLinearIssueId] = useState(null) const [selectedLinearIssueFallback, setSelectedLinearIssueFallback] = useState(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 {