Improve Linear issue editing flow (#2901)

* fix: address review findings

* fix: update Linear RPC test
This commit is contained in:
Jinjing 2026-05-26 23:15:33 -07:00 committed by GitHub
parent f037e1c3d6
commit 5783c4192b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 965 additions and 128 deletions

View File

@ -98,6 +98,10 @@ export function registerLinearHandlers(): void {
workspaceId?: string
parentIssueId?: string
projectId?: string | null
stateId?: string
priority?: number
assigneeId?: string | null
labelIds?: string[]
}
) => {
if (typeof args?.teamId !== 'string' || !args.teamId.trim()) {
@ -106,6 +110,19 @@ export function registerLinearHandlers(): void {
if (typeof args?.title !== 'string' || !args.title.trim()) {
return { ok: false, error: 'Title is required' }
}
if (
args.priority !== undefined &&
(!Number.isInteger(args.priority) || args.priority < 0 || args.priority > 4)
) {
return { ok: false, error: 'Invalid priority' }
}
if (
args.labelIds !== undefined &&
(!Array.isArray(args.labelIds) ||
!args.labelIds.every((id) => typeof id === 'string' && id.trim()))
) {
return { ok: false, error: 'Invalid label IDs' }
}
return createIssue(
args.teamId.trim(),
args.title.trim(),
@ -113,7 +130,11 @@ export function registerLinearHandlers(): void {
normalizeWorkspaceId(args.workspaceId),
{
parentId: typeof args.parentIssueId === 'string' ? args.parentIssueId.trim() : undefined,
projectId: typeof args.projectId === 'string' ? args.projectId.trim() : null
projectId: typeof args.projectId === 'string' ? args.projectId.trim() : null,
stateId: typeof args.stateId === 'string' ? args.stateId.trim() : undefined,
priority: typeof args.priority === 'number' ? args.priority : undefined,
assigneeId: typeof args.assigneeId === 'string' ? args.assigneeId.trim() : null,
labelIds: Array.isArray(args.labelIds) ? args.labelIds.map((id) => id.trim()) : undefined
}
)
}
@ -142,6 +163,12 @@ export function registerLinearHandlers(): void {
if (u.stateId !== undefined && (typeof u.stateId !== 'string' || !u.stateId.trim())) {
return { ok: false, error: 'Invalid state ID' }
}
if (u.title !== undefined && (typeof u.title !== 'string' || !u.title.trim())) {
return { ok: false, error: 'Title is required' }
}
if (u.description !== undefined && typeof u.description !== 'string') {
return { ok: false, error: 'Description must be a string' }
}
if (
u.priority !== undefined &&
(!Number.isInteger(u.priority) || u.priority < 0 || u.priority > 4)

View File

@ -366,7 +366,14 @@ export async function createIssue(
title: string,
description?: string,
workspaceId?: string | null,
options?: { parentId?: string; projectId?: string | null }
options?: {
parentId?: string
projectId?: string | null
stateId?: string
priority?: number
assigneeId?: string | null
labelIds?: string[]
}
): Promise<
| { ok: true; id: string; identifier: string; title: string; url: string }
| { ok: false; error: string }
@ -383,7 +390,11 @@ export async function createIssue(
title,
...(description ? { description } : {}),
...(options?.parentId ? { parentId: options.parentId } : {}),
...(options?.projectId ? { projectId: options.projectId } : {})
...(options?.projectId ? { projectId: options.projectId } : {}),
...(options?.stateId ? { stateId: options.stateId } : {}),
...(options?.priority !== undefined ? { priority: options.priority } : {}),
...(options?.assigneeId ? { assigneeId: options.assigneeId } : {}),
...(options?.labelIds ? { labelIds: options.labelIds } : {})
})
if (!result.success) {
return { ok: false, error: 'Linear create failed' }
@ -436,6 +447,9 @@ export async function updateIssue(
if (updates.title !== undefined) {
payload.title = updates.title
}
if (updates.description !== undefined) {
payload.description = updates.description
}
if (updates.assigneeId !== undefined) {
payload.assigneeId = updates.assigneeId
}

View File

@ -17,9 +17,6 @@ export async function listProjects(
workspaceId?: LinearWorkspaceSelection | null
): Promise<LinearProjectSummary[]> {
const trimmed = query?.trim()
if (!trimmed) {
return []
}
const entries = getClients(workspaceId)
if (entries.length === 0) {
@ -30,6 +27,10 @@ export async function listProjects(
entries.map(async (entry) => {
await acquire()
try {
if (!trimmed) {
const connection = await entry.client.projects({ first: limit })
return connection.nodes.map(mapLinearProject)
}
const connection = await entry.client.searchProjects(trimmed, { first: limit })
return connection.nodes.map(mapLinearProject)
} catch (error) {

View File

@ -11499,11 +11499,18 @@ export class OrcaRuntimeService {
description?: string,
workspaceId?: string,
parentIssueId?: string,
projectId?: string | null
projectId?: string | null,
options?: {
stateId?: string
priority?: number
assigneeId?: string | null
labelIds?: string[]
}
): ReturnType<typeof createLinearIssue> {
return createLinearIssue(teamId, title, description, workspaceId, {
parentId: parentIssueId,
projectId
projectId,
...options
})
}

View File

@ -110,7 +110,13 @@ describe('linear RPC methods', () => {
'Details',
'workspace-1',
undefined,
undefined
undefined,
{
assigneeId: undefined,
labelIds: undefined,
priority: undefined,
stateId: undefined
}
)
expect(runtime.linearCreateIssue).toHaveBeenCalledWith(
'team-1',
@ -118,7 +124,13 @@ describe('linear RPC methods', () => {
undefined,
'workspace-1',
'issue-3',
'project-1'
'project-1',
{
assigneeId: undefined,
labelIds: undefined,
priority: undefined,
stateId: undefined
}
)
expect(runtime.linearUpdateIssue).toHaveBeenCalledWith(
'issue-3',

View File

@ -3,6 +3,8 @@ import { defineMethod, type RpcMethod } from '../core'
import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas'
const VALID_FILTERS = ['assigned', 'created', 'all', 'completed'] as const
const LinearPriority = z.number().int().min(0).max(4).optional()
const LinearLabelIds = z.array(requiredString('Invalid label ID')).optional()
const Connect = z.object({
apiKey: requiredString('Invalid API key')
@ -38,7 +40,11 @@ const CreateIssue = z.object({
description: OptionalString,
workspaceId: OptionalString,
parentIssueId: OptionalString,
projectId: z.union([z.string(), z.null()]).optional()
projectId: z.union([z.string(), z.null()]).optional(),
stateId: OptionalString,
priority: LinearPriority,
assigneeId: z.union([z.string(), z.null()]).optional(),
labelIds: LinearLabelIds
})
const IssueId = z.object({
@ -71,6 +77,7 @@ const IssueUpdate = z.object({
updates: z.object({
stateId: OptionalString,
title: OptionalString,
description: z.string().optional(),
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(),
@ -127,7 +134,13 @@ export const LINEAR_METHODS: RpcMethod[] = [
params.description?.trim() || undefined,
params.workspaceId,
params.parentIssueId,
params.projectId
params.projectId,
{
stateId: params.stateId,
priority: params.priority,
assigneeId: params.assigneeId,
labelIds: params.labelIds
}
)
}),
defineMethod({

View File

@ -1190,6 +1190,10 @@ export type PreloadApi = {
workspaceId?: string
parentIssueId?: string
projectId?: string | null
stateId?: string
priority?: number
assigneeId?: string | null
labelIds?: string[]
}) => Promise<
| { ok: true; id: string; identifier: string; title: string; url: string }
| { ok: false; error: string }

View File

@ -1161,6 +1161,10 @@ const api = {
workspaceId?: string
parentIssueId?: string
projectId?: string | null
stateId?: string
priority?: number
assigneeId?: string | null
labelIds?: string[]
}): Promise<
| { ok: true; id: string; identifier: string; title: string; url: string }
| { ok: false; error: string }

View File

@ -0,0 +1,233 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { LoaderCircle } from 'lucide-react'
import { toast } from 'sonner'
import { cn } from '@/lib/utils'
import { useAppStore } from '@/store'
import { getScreenSubmitShortcutLabel, isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut'
import { linearUpdateIssue } from '@/runtime/runtime-linear-client'
import type { LinearIssue } from '../../../shared/types'
type LinearIssueTextEditorProps = {
issue: LinearIssue
onIssueChange: (patch: Pick<LinearIssue, 'title'> | Pick<LinearIssue, 'description'>) => void
density?: 'page' | 'drawer'
fields?: 'all' | 'title' | 'description'
}
function useAutosizeTextArea(value: string): React.RefObject<HTMLTextAreaElement | null> {
const ref = useRef<HTMLTextAreaElement>(null)
useEffect(() => {
const textarea = ref.current
if (!textarea) {
return
}
textarea.style.height = 'auto'
textarea.style.height = `${textarea.scrollHeight}px`
}, [value])
return ref
}
export function LinearIssueTextEditor({
issue,
onIssueChange,
density = 'page',
fields = 'all'
}: LinearIssueTextEditorProps): React.JSX.Element {
const settings = useAppStore((s) => s.settings)
const patchLinearIssue = useAppStore((s) => s.patchLinearIssue)
const [titleDraft, setTitleDraft] = useState(issue.title)
const [descriptionDraft, setDescriptionDraft] = useState(issue.description ?? '')
const [savingField, setSavingField] = useState<'title' | 'description' | null>(null)
const submitShortcutLabel = getScreenSubmitShortcutLabel()
const titleRef = useAutosizeTextArea(titleDraft)
const descriptionRef = useAutosizeTextArea(descriptionDraft)
const lastIssueIdRef = useRef(issue.id)
const lastSyncedTitleRef = useRef(issue.title)
const lastSyncedDescriptionRef = useRef(issue.description ?? '')
useEffect(() => {
const nextDescription = issue.description ?? ''
if (issue.id !== lastIssueIdRef.current) {
lastIssueIdRef.current = issue.id
lastSyncedTitleRef.current = issue.title
lastSyncedDescriptionRef.current = nextDescription
setTitleDraft(issue.title)
setDescriptionDraft(nextDescription)
return
}
const previousTitle = lastSyncedTitleRef.current
const previousDescription = lastSyncedDescriptionRef.current
// Why: optimistic saves can update one field while the user has unsaved
// edits in the other; only sync fields that still match the last source.
if (issue.title !== previousTitle && titleDraft === previousTitle) {
setTitleDraft(issue.title)
}
if (nextDescription !== previousDescription && descriptionDraft === previousDescription) {
setDescriptionDraft(nextDescription)
}
lastSyncedTitleRef.current = issue.title
lastSyncedDescriptionRef.current = nextDescription
}, [descriptionDraft, issue.description, issue.id, issue.title, titleDraft])
const saveField = useCallback(
async (field: 'title' | 'description') => {
const nextTitle = titleDraft.trim()
const nextDescription = descriptionDraft.trimEnd()
if (field === 'title' && !nextTitle) {
setTitleDraft(issue.title)
toast.error('Title is required')
return
}
const nextValue = field === 'title' ? nextTitle : nextDescription
const currentValue = field === 'title' ? issue.title : (issue.description ?? '')
if (nextValue === currentValue) {
return
}
const patch =
field === 'title'
? ({ title: nextTitle } as const)
: ({ description: nextDescription } as const)
setSavingField(field)
onIssueChange(patch)
patchLinearIssue(issue.id, patch)
try {
const result = await linearUpdateIssue(settings, issue.id, patch, issue.workspaceId)
if (!result.ok) {
throw new Error(result.error)
}
} catch (error) {
const revert =
field === 'title'
? ({ title: issue.title } as const)
: ({ description: issue.description ?? '' } as const)
onIssueChange(revert)
patchLinearIssue(issue.id, revert)
if (field === 'title') {
setTitleDraft(issue.title)
} else {
setDescriptionDraft(issue.description ?? '')
}
toast.error(error instanceof Error ? error.message : `Failed to update ${field}`)
} finally {
setSavingField(null)
}
},
[
descriptionDraft,
issue.description,
issue.id,
issue.title,
issue.workspaceId,
onIssueChange,
patchLinearIssue,
settings,
titleDraft
]
)
const handleDescriptionKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (!isScreenSubmitShortcut(event)) {
return
}
event.preventDefault()
event.currentTarget.blur()
},
[]
)
const handleTitleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key === 'Enter') {
event.preventDefault()
event.currentTarget.blur()
return
}
handleDescriptionKeyDown(event)
},
[handleDescriptionKeyDown]
)
const titleClass =
density === 'page'
? 'text-[28px] font-semibold leading-tight'
: 'text-[15px] font-semibold leading-tight'
const descriptionClass =
density === 'page' ? 'mt-7 px-3 text-[15px] leading-7' : 'px-3 text-[14px] leading-relaxed'
return (
<div className="min-w-0">
{fields !== 'description' ? (
<div className="relative">
<textarea
ref={titleRef}
value={titleDraft}
onChange={(event) => setTitleDraft(event.target.value)}
onBlur={() => void saveField('title')}
onKeyDown={handleTitleKeyDown}
disabled={savingField === 'title'}
rows={1}
aria-label="Issue title"
className={cn(
'peer scrollbar-sleek block w-full resize-none overflow-hidden rounded-md border border-transparent bg-transparent px-1 py-0 text-foreground outline-none transition hover:border-border/50 hover:bg-accent/40 focus-visible:border-border focus-visible:bg-background focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-80',
titleClass
)}
/>
<div className="pointer-events-none absolute bottom-1.5 right-2 z-10 flex items-center gap-1 text-[10px] text-muted-foreground/75 opacity-0 transition-opacity peer-focus:opacity-100">
<kbd className="inline-flex h-4 min-w-4 select-none items-center justify-center rounded border border-border bg-muted/70 px-1 font-mono text-[9px] font-medium shadow-xs">
</kbd>
<span>to save</span>
</div>
{savingField === 'title' ? (
<LoaderCircle className="absolute right-2 top-2 size-4 animate-spin text-muted-foreground" />
) : null}
</div>
) : null}
{fields !== 'title' ? (
<div className="relative">
<textarea
ref={descriptionRef}
value={descriptionDraft}
onChange={(event) => setDescriptionDraft(event.target.value)}
onBlur={() => void saveField('description')}
onKeyDown={handleDescriptionKeyDown}
disabled={savingField === 'description'}
rows={descriptionDraft.trim() ? 3 : 1}
placeholder="No description provided."
aria-label="Issue description"
className={cn(
'peer scrollbar-sleek block w-full resize-none overflow-hidden rounded-md border border-transparent bg-transparent py-1 text-foreground outline-none transition placeholder:italic placeholder:text-muted-foreground hover:border-border/50 hover:bg-accent/40 focus-visible:border-border focus-visible:bg-background focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-80',
descriptionClass
)}
/>
<div className="pointer-events-none absolute bottom-1.5 right-2 z-10 flex items-center gap-1.5 text-[10px] text-muted-foreground/75 opacity-0 transition-opacity peer-focus:opacity-100">
<span className="flex items-center gap-1">
<span>{submitShortcutLabel}</span>
<span>save</span>
</span>
<span className="text-muted-foreground/35">·</span>
<span className="flex items-center gap-1">
<kbd className="inline-flex h-4 min-w-4 select-none items-center justify-center rounded border border-border bg-muted/70 px-1 font-mono text-[9px] font-medium shadow-xs">
</kbd>
<span>newline</span>
</span>
</div>
{savingField === 'description' ? (
<LoaderCircle className="absolute right-2 top-2 size-4 animate-spin text-muted-foreground" />
) : null}
</div>
) : null}
</div>
)
}

View File

@ -30,6 +30,7 @@ import {
type LinearLocalComment
} from '@/components/LinearItemDrawer'
import { Button } from '@/components/ui/button'
import { LinearIssueTextEditor } from '@/components/LinearIssueTextEditor'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Sheet, SheetContent, SheetDescription, SheetTitle } from '@/components/ui/sheet'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
@ -401,6 +402,14 @@ export default function LinearIssueWorkspace({
setEditState((prev) => (prev ? { ...prev, ...patch } : prev))
}, [])
const handleIssueTextChange = useCallback(
(patch: Partial<Pick<LinearIssue, 'title' | 'description'>>) => {
hasEditedRef.current = true
setFullIssue((prev) => (prev ? { ...prev, ...patch } : prev))
},
[]
)
const loadComments = useCallback(
async (targetIssue: LinearIssue, requestId: number): Promise<void> => {
setCommentsLoading(true)
@ -480,6 +489,8 @@ export default function LinearIssueWorkspace({
return {
...fetched,
state: prev.state,
title: prev.title,
description: prev.description,
priority: prev.priority,
assignee: prev.assignee,
estimate: prev.estimate,
@ -648,20 +659,7 @@ export default function LinearIssueWorkspace({
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-sleek">
<div className="mx-auto grid w-full grid-cols-1 gap-10 px-7 py-10 lg:grid-cols-[minmax(0,1fr)_320px] lg:px-10 xl:px-12">
<main className="min-w-0">
<h1 className="max-w-[820px] text-[28px] font-semibold leading-tight text-foreground">
{displayed.title}
</h1>
<section className="mt-7 max-w-[820px] text-[15px] leading-7 text-foreground">
{displayed.description?.trim() ? (
<CommentMarkdown
content={displayed.description}
className="text-[15px] leading-7"
/>
) : (
<p className="text-sm italic text-muted-foreground">No description provided.</p>
)}
</section>
<LinearIssueTextEditor issue={displayed} onIssueChange={handleIssueTextChange} />
<LinearIssueSubIssueButton issue={displayed} onOpenIssue={onOpenIssue} />

View File

@ -17,6 +17,7 @@ import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { LinearIssueTextEditor } from '@/components/LinearIssueTextEditor'
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'
@ -1096,6 +1097,14 @@ export default function LinearItemDrawer({
setEditState((prev) => (prev ? { ...prev, ...patch } : prev))
}, [])
const handleIssueTextChange = useCallback(
(patch: Partial<Pick<LinearIssue, 'title' | 'description'>>) => {
hasEditedRef.current = true
setFullIssue((prev) => (prev ? { ...prev, ...patch } : prev))
},
[]
)
// Why: the list view may not include the full description. Re-fetch
// the issue by ID and its comments to populate the drawer.
useEffect(() => {
@ -1227,9 +1236,14 @@ export default function LinearItemDrawer({
<span className="font-mono text-[12px] text-muted-foreground">
{displayed.identifier}
</span>
<h2 className="mt-1 text-[15px] font-semibold leading-tight text-foreground">
{displayed.title}
</h2>
<div className="mt-1">
<LinearIssueTextEditor
issue={displayed}
onIssueChange={handleIssueTextChange}
density="drawer"
fields="title"
/>
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-muted-foreground">
{displayed.workspaceName && <span>{displayed.workspaceName}</span>}
{displayed.team?.name && <span>{displayed.team.name}</span>}
@ -1285,14 +1299,12 @@ export default function LinearItemDrawer({
{/* Body + comments */}
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-sleek">
<div className="px-4 py-4">
{displayed.description?.trim() ? (
<CommentMarkdown
content={displayed.description}
className="text-[14px] leading-relaxed"
/>
) : (
<span className="italic text-muted-foreground">No description provided.</span>
)}
<LinearIssueTextEditor
issue={displayed}
onIssueChange={handleIssueTextChange}
density="drawer"
fields="description"
/>
</div>
<div className="border-t border-border/40 px-4 py-4">

View File

@ -33,7 +33,11 @@ import {
Search,
SlidersHorizontal,
Users,
X
X,
FolderKanban,
Tag,
UserRound,
AlertTriangle
} from 'lucide-react'
import { toast } from 'sonner'
@ -129,6 +133,7 @@ import type {
GitLabTodo,
GitLabWorkItem,
LinearIssue,
LinearProjectSummary,
LinearTeam,
LinearWorkflowState,
Repo,
@ -137,12 +142,13 @@ import type {
} from '../../../shared/types'
import { shouldSuppressEnterSubmit } from '@/lib/new-workspace-enter-guard'
import { getScreenSubmitShortcutLabel, isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut'
import { useTeamStates } from '@/hooks/useIssueMetadata'
import { useTeamStates, useTeamMembers, useTeamLabels } from '@/hooks/useIssueMetadata'
import {
linearCreateIssue,
linearGetIssue,
linearTeamStates,
linearUpdateIssue
linearUpdateIssue,
linearListProjects
} from '@/runtime/runtime-linear-client'
import {
normalizeVisibleTaskProviders,
@ -3091,11 +3097,86 @@ export default function TaskPage(): React.JSX.Element {
const [newLinearIssueTeamId, setNewLinearIssueTeamId] = useState<string | null>(null)
const [newLinearIssueSubmitting, setNewLinearIssueSubmitting] = useState(false)
const [newLinearIssueStateId, setNewLinearIssueStateId] = useState<string | null>(null)
const [newLinearIssueAssigneeId, setNewLinearIssueAssigneeId] = useState<string | null>(null)
const [newLinearIssuePriority, setNewLinearIssuePriority] = useState<number>(0)
const [newLinearIssueProjectId, setNewLinearIssueProjectId] = useState<string | null>(null)
const [newLinearIssueLabelIds, setNewLinearIssueLabelIds] = useState<string[]>([])
const newLinearIssueTargetTeam = useMemo(
() => availableTeams.find((t) => t.id === newLinearIssueTeamId) ?? availableTeams[0] ?? null,
[availableTeams, newLinearIssueTeamId]
)
const [newLinearIssueProjects, setNewLinearIssueProjects] = useState<LinearProjectSummary[]>([])
const [newLinearIssueProjectsLoading, setNewLinearIssueProjectsLoading] = useState(false)
useEffect(() => {
let cancelled = false
if (!newLinearIssueTargetTeam) {
setNewLinearIssueProjects([])
setNewLinearIssueProjectsLoading(false)
return
}
setNewLinearIssueProjectsLoading(true)
const targetWorkspaceId =
newLinearIssueTargetTeam.workspaceId ||
(selectedLinearWorkspaceId !== 'all' ? selectedLinearWorkspaceId : null)
linearListProjects(settings, undefined, 100, targetWorkspaceId)
.then((p) => {
if (!cancelled) {
setNewLinearIssueProjects(p)
}
})
.catch(() => {})
.finally(() => {
if (!cancelled) {
setNewLinearIssueProjectsLoading(false)
}
})
return () => {
// Why: project lists are workspace-scoped; stale responses must not
// populate the composer after a team/workspace switch.
cancelled = true
}
}, [newLinearIssueTargetTeam, settings, selectedLinearWorkspaceId])
useEffect(() => {
// Why: the selected team can change indirectly when the available Linear
// teams/workspace list refreshes, even if the explicit picker value did not.
setNewLinearIssueStateId(null)
setNewLinearIssueAssigneeId(null)
setNewLinearIssuePriority(0)
setNewLinearIssueProjectId(null)
setNewLinearIssueLabelIds([])
}, [newLinearIssueTargetTeam?.id, newLinearIssueTargetTeam?.workspaceId])
const newLinearStates = useTeamStates(
newLinearIssueTargetTeam?.id || null,
settings,
newLinearIssueTargetTeam?.workspaceId
)
const newLinearMembers = useTeamMembers(
newLinearIssueTargetTeam?.id || null,
settings,
newLinearIssueTargetTeam?.workspaceId
)
const newLinearLabels = useTeamLabels(
newLinearIssueTargetTeam?.id || null,
settings,
newLinearIssueTargetTeam?.workspaceId
)
useEffect(() => {
if (newLinearStates.data.length > 0 && !newLinearIssueStateId) {
const defaultState =
newLinearStates.data.find((s) => s.type === 'unstarted') || newLinearStates.data[0]
if (defaultState) {
setNewLinearIssueStateId(defaultState.id)
}
}
}, [newLinearStates.data, newLinearIssueStateId])
const [linearConnectOpen, setLinearConnectOpen] = useState(false)
const [linearApiKeyDraft, setLinearApiKeyDraft] = useState('')
const [linearConnectState, setLinearConnectState] = useState<'idle' | 'connecting' | 'error'>(
@ -3851,7 +3932,12 @@ export default function TaskPage(): React.JSX.Element {
teamId: newLinearIssueTargetTeam.id,
title,
description: newLinearIssueBody || undefined,
workspaceId: newLinearIssueTargetTeam.workspaceId
workspaceId: newLinearIssueTargetTeam.workspaceId,
stateId: newLinearIssueStateId || undefined,
priority: newLinearIssuePriority,
assigneeId: newLinearIssueAssigneeId || undefined,
projectId: newLinearIssueProjectId || null,
labelIds: newLinearIssueLabelIds.length > 0 ? newLinearIssueLabelIds : undefined
})
if (!result.ok) {
toast.error(result.error || 'Failed to create issue.')
@ -3868,6 +3954,11 @@ export default function TaskPage(): React.JSX.Element {
setNewLinearIssueOpen(false)
setNewLinearIssueTitle('')
setNewLinearIssueBody('')
setNewLinearIssueStateId(null)
setNewLinearIssueAssigneeId(null)
setNewLinearIssuePriority(0)
setNewLinearIssueProjectId(null)
setNewLinearIssueLabelIds([])
setLinearRefreshNonce((n) => n + 1)
// Why: auto-select the new issue in the inline workspace so the user
@ -3887,6 +3978,11 @@ export default function TaskPage(): React.JSX.Element {
newLinearIssueSubmitting,
newLinearIssueTargetTeam,
newLinearIssueTitle,
newLinearIssueStateId,
newLinearIssuePriority,
newLinearIssueAssigneeId,
newLinearIssueProjectId,
newLinearIssueLabelIds,
openLinearDetailPage,
settings
])
@ -6177,7 +6273,8 @@ export default function TaskPage(): React.JSX.Element {
}}
>
<DialogContent
className="sm:max-w-lg"
showCloseButton={false}
className="sm:max-w-2xl bg-background border-border shadow-2xl p-0 overflow-hidden flex flex-col gap-0 rounded-xl"
onKeyDown={(event) => {
if (isScreenSubmitShortcut(event)) {
event.preventDefault()
@ -6185,98 +6282,508 @@ export default function TaskPage(): React.JSX.Element {
}
}}
>
<DialogHeader>
<DialogTitle>New Linear issue</DialogTitle>
<DialogDescription>
{availableTeams.length > 1
? 'Creates a new issue in the selected team.'
: `Creates a new issue in ${
newLinearIssueTargetTeam?.workspaceName
? `${newLinearIssueTargetTeam.workspaceName} / `
: ''
}${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>
{/* Header/Team section */}
<div className="flex items-center justify-between border-b border-border/60 px-5 py-3 bg-muted/10">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
New Issue
</span>
<span className="text-muted-foreground/40 text-xs">/</span>
{availableTeams.length > 1 ? (
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="xs"
className="h-7 gap-1 px-2 font-medium text-xs text-foreground hover:bg-muted"
>
{newLinearIssueTargetTeam?.key ?? 'Select Team'}
<ChevronDown className="size-3 text-muted-foreground" />
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-64 p-1">
<div className="text-[10px] font-semibold text-muted-foreground px-2 py-1.5 uppercase tracking-wider">
Switch Team
</div>
{availableTeams.map((t) => (
<SelectItem key={t.id} value={t.id}>
{selectedLinearWorkspaceId === 'all' && t.workspaceName
? `${t.workspaceName} · `
: ''}
{t.key} {t.name}
</SelectItem>
<button
key={t.id}
type="button"
onClick={() => setNewLinearIssueTeamId(t.id)}
className={`w-full flex items-center justify-between text-left px-2 py-1.5 text-xs rounded-sm hover:bg-muted transition-colors ${
newLinearIssueTeamId === t.id ? 'bg-muted font-medium' : ''
}`}
>
<span>
{t.key} {t.name}
</span>
{newLinearIssueTeamId === t.id && <Check className="size-3" />}
</button>
))}
</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}
/>
</PopoverContent>
</Popover>
) : (
<span className="text-xs font-medium text-foreground">
{newLinearIssueTargetTeam?.key ?? ''} {newLinearIssueTargetTeam?.name ?? ''}
</span>
)}
</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 scrollbar-sleek"
/>
</div>
<p className="text-[10px] text-muted-foreground">{submitShortcutLabel} to submit.</p>
</div>
<DialogFooter>
<Button
variant="outline"
<button
onClick={() => setNewLinearIssueOpen(false)}
className="text-muted-foreground hover:text-foreground p-1 rounded-md transition-colors"
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>
<X className="size-4" />
</button>
</div>
{/* Form Content */}
<div className="flex flex-col px-6 py-4 gap-3">
{/* Title */}
<input
autoFocus
value={newLinearIssueTitle}
onChange={(e) => setNewLinearIssueTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.nativeEvent.isComposing) {
e.preventDefault()
void handleCreateNewLinearIssue()
}
}}
placeholder="Issue title"
disabled={newLinearIssueSubmitting}
className="text-lg font-semibold bg-transparent border-none outline-none focus:outline-none focus:ring-0 focus-visible:ring-0 p-0 placeholder:text-muted-foreground/40 text-foreground w-full"
/>
{/* Description */}
<textarea
value={newLinearIssueBody}
onChange={(e) => setNewLinearIssueBody(e.target.value)}
placeholder="Add description..."
rows={5}
disabled={newLinearIssueSubmitting}
className="w-full min-w-0 text-sm bg-transparent border-none outline-none focus:outline-none focus:ring-0 focus-visible:ring-0 p-0 placeholder:text-muted-foreground/45 text-foreground resize-none max-h-60 overflow-y-auto scrollbar-sleek py-1"
/>
{/* Attribute Badges Row */}
<div className="flex flex-wrap items-center gap-2 border-t border-border/40 pt-4 mt-2">
{/* Status Selector */}
<Popover>
<PopoverTrigger asChild>
<button
type="button"
disabled={newLinearIssueSubmitting}
className="flex items-center gap-1.5 px-2 py-1 rounded-md text-xs border border-border/80 bg-muted/15 hover:bg-muted/50 active:bg-muted transition-colors text-foreground/80 cursor-pointer disabled:opacity-50"
>
{(() => {
const selectedState = newLinearStates.data.find(
(s) => s.id === newLinearIssueStateId
)
return (
<>
<span
className="size-2 rounded-full flex-shrink-0"
style={{ backgroundColor: selectedState?.color || '#a3a3a3' }}
/>
<span>{selectedState?.name || 'Status'}</span>
</>
)
})()}
<ChevronDown className="size-3 text-muted-foreground/70" />
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-56 p-1">
<div className="text-[10px] font-semibold text-muted-foreground px-2 py-1 uppercase tracking-wider">
Status
</div>
{newLinearStates.loading ? (
<div className="flex items-center justify-center p-4">
<LoaderCircle className="size-4 animate-spin text-muted-foreground" />
</div>
) : (
<div className="max-h-60 overflow-y-auto scrollbar-sleek">
{newLinearStates.data.map((s) => (
<button
key={s.id}
type="button"
onClick={() => setNewLinearIssueStateId(s.id)}
className={`w-full flex items-center justify-between text-left px-2 py-1.5 text-xs rounded-sm hover:bg-muted transition-colors ${
newLinearIssueStateId === s.id
? 'bg-muted font-medium text-foreground'
: 'text-foreground/80'
}`}
>
<div className="flex items-center gap-2">
<span
className="size-2 rounded-full flex-shrink-0"
style={{ backgroundColor: s.color || '#a3a3a3' }}
/>
<span>{s.name}</span>
</div>
{newLinearIssueStateId === s.id && (
<Check className="size-3 text-foreground" />
)}
</button>
))}
</div>
)}
</PopoverContent>
</Popover>
{/* Assignee Selector */}
<Popover>
<PopoverTrigger asChild>
<button
type="button"
disabled={newLinearIssueSubmitting}
className="flex items-center gap-1.5 px-2 py-1 rounded-md text-xs border border-border/80 bg-muted/15 hover:bg-muted/50 active:bg-muted transition-colors text-foreground/80 cursor-pointer disabled:opacity-50"
>
{(() => {
const selectedAssignee = newLinearMembers.data.find(
(m) => m.id === newLinearIssueAssigneeId
)
if (selectedAssignee) {
return (
<>
{selectedAssignee.avatarUrl ? (
<img
src={selectedAssignee.avatarUrl}
alt={selectedAssignee.displayName}
className="size-3.5 rounded-full flex-shrink-0"
/>
) : (
<UserRound className="size-3.5 text-muted-foreground/70" />
)}
<span className="truncate max-w-[100px]">
{selectedAssignee.displayName}
</span>
</>
)
}
return (
<>
<UserRound className="size-3.5 text-muted-foreground/70" />
<span>Assignee</span>
</>
)
})()}
<ChevronDown className="size-3 text-muted-foreground/70" />
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-64 p-1">
<div className="text-[10px] font-semibold text-muted-foreground px-2 py-1 uppercase tracking-wider">
Assignee
</div>
{newLinearMembers.loading ? (
<div className="flex items-center justify-center p-4">
<LoaderCircle className="size-4 animate-spin text-muted-foreground" />
</div>
) : (
<div className="max-h-60 overflow-y-auto scrollbar-sleek">
<button
type="button"
onClick={() => setNewLinearIssueAssigneeId(null)}
className={`w-full flex items-center justify-between text-left px-2 py-1.5 text-xs rounded-sm hover:bg-muted transition-colors ${
newLinearIssueAssigneeId === null
? 'bg-muted font-medium text-foreground'
: 'text-foreground/80'
}`}
>
<div className="flex items-center gap-2">
<UserRound className="size-3.5 text-muted-foreground/50" />
<span>Unassigned</span>
</div>
{newLinearIssueAssigneeId === null && (
<Check className="size-3 text-foreground" />
)}
</button>
{newLinearMembers.data.map((m) => (
<button
key={m.id}
type="button"
onClick={() => setNewLinearIssueAssigneeId(m.id)}
className={`w-full flex items-center justify-between text-left px-2 py-1.5 text-xs rounded-sm hover:bg-muted transition-colors ${
newLinearIssueAssigneeId === m.id
? 'bg-muted font-medium text-foreground'
: 'text-foreground/80'
}`}
>
<div className="flex items-center gap-2 truncate">
{m.avatarUrl ? (
<img
src={m.avatarUrl}
alt={m.displayName}
className="size-3.5 rounded-full flex-shrink-0"
/>
) : (
<UserRound className="size-3.5 text-muted-foreground/70" />
)}
<span className="truncate">{m.displayName}</span>
</div>
{newLinearIssueAssigneeId === m.id && (
<Check className="size-3 text-foreground" />
)}
</button>
))}
</div>
)}
</PopoverContent>
</Popover>
{/* Priority Selector */}
<Popover>
<PopoverTrigger asChild>
<button
type="button"
disabled={newLinearIssueSubmitting}
className="flex items-center gap-1.5 px-2 py-1 rounded-md text-xs border border-border/80 bg-muted/15 hover:bg-muted/50 active:bg-muted transition-colors text-foreground/80 cursor-pointer disabled:opacity-50"
>
<AlertTriangle
className={`size-3.5 ${
newLinearIssuePriority === 1
? 'text-rose-500'
: newLinearIssuePriority === 2
? 'text-amber-500'
: newLinearIssuePriority === 3
? 'text-yellow-500 font-medium'
: newLinearIssuePriority === 4
? 'text-blue-400'
: 'text-muted-foreground/70'
}`}
/>
<span>
{newLinearIssuePriority === 1
? 'Urgent'
: newLinearIssuePriority === 2
? 'High'
: newLinearIssuePriority === 3
? 'Medium'
: newLinearIssuePriority === 4
? 'Low'
: 'Priority'}
</span>
<ChevronDown className="size-3 text-muted-foreground/70" />
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-48 p-1">
<div className="text-[10px] font-semibold text-muted-foreground px-2 py-1 uppercase tracking-wider">
Priority
</div>
{[
{ val: 0, label: 'No priority' },
{ val: 1, label: 'Urgent' },
{ val: 2, label: 'High' },
{ val: 3, label: 'Medium' },
{ val: 4, label: 'Low' }
].map((p) => (
<button
key={p.val}
type="button"
onClick={() => setNewLinearIssuePriority(p.val)}
className={`w-full flex items-center justify-between text-left px-2 py-1.5 text-xs rounded-sm hover:bg-muted transition-colors ${
newLinearIssuePriority === p.val
? 'bg-muted font-medium text-foreground'
: 'text-foreground/80'
}`}
>
<div className="flex items-center gap-2">
<AlertTriangle
className={`size-3.5 ${
p.val === 1
? 'text-rose-500'
: p.val === 2
? 'text-amber-500'
: p.val === 3
? 'text-yellow-500'
: p.val === 4
? 'text-blue-400'
: 'text-muted-foreground/50'
}`}
/>
<span>{p.label}</span>
</div>
{newLinearIssuePriority === p.val && (
<Check className="size-3 text-foreground" />
)}
</button>
))}
</PopoverContent>
</Popover>
{/* Project Selector */}
<Popover>
<PopoverTrigger asChild>
<button
type="button"
disabled={newLinearIssueSubmitting}
className="flex items-center gap-1.5 px-2 py-1 rounded-md text-xs border border-border/80 bg-muted/15 hover:bg-muted/50 active:bg-muted transition-colors text-foreground/80 cursor-pointer disabled:opacity-50"
>
<FolderKanban className="size-3.5 text-muted-foreground/70" />
<span className="truncate max-w-[120px]">
{(() => {
const selectedProj = newLinearIssueProjects.find(
(p) => p.id === newLinearIssueProjectId
)
return selectedProj?.name || 'Project'
})()}
</span>
<ChevronDown className="size-3 text-muted-foreground/70" />
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-64 p-1">
<div className="text-[10px] font-semibold text-muted-foreground px-2 py-1 uppercase tracking-wider">
Project
</div>
{newLinearIssueProjectsLoading ? (
<div className="flex items-center justify-center p-4">
<LoaderCircle className="size-4 animate-spin text-muted-foreground" />
</div>
) : (
<div className="max-h-60 overflow-y-auto scrollbar-sleek">
<button
type="button"
onClick={() => setNewLinearIssueProjectId(null)}
className={`w-full flex items-center justify-between text-left px-2 py-1.5 text-xs rounded-sm hover:bg-muted transition-colors ${
newLinearIssueProjectId === null
? 'bg-muted font-medium text-foreground'
: 'text-foreground/80'
}`}
>
<div className="flex items-center gap-2">
<FolderKanban className="size-3.5 text-muted-foreground/50" />
<span>No Project</span>
</div>
{newLinearIssueProjectId === null && (
<Check className="size-3 text-foreground" />
)}
</button>
{newLinearIssueProjects.map((p) => (
<button
key={p.id}
type="button"
onClick={() => setNewLinearIssueProjectId(p.id)}
className={`w-full flex items-center justify-between text-left px-2 py-1.5 text-xs rounded-sm hover:bg-muted transition-colors ${
newLinearIssueProjectId === p.id
? 'bg-muted font-medium text-foreground'
: 'text-foreground/80'
}`}
>
<div className="flex items-center gap-2 truncate">
<FolderKanban className="size-3.5 text-muted-foreground/70 flex-shrink-0" />
<span className="truncate">{p.name}</span>
</div>
{newLinearIssueProjectId === p.id && (
<Check className="size-3 text-foreground" />
)}
</button>
))}
</div>
)}
</PopoverContent>
</Popover>
{/* Labels Selector */}
<Popover>
<PopoverTrigger asChild>
<button
type="button"
disabled={newLinearIssueSubmitting}
className="flex items-center gap-1.5 px-2 py-1 rounded-md text-xs border border-border/80 bg-muted/15 hover:bg-muted/50 active:bg-muted transition-colors text-foreground/80 cursor-pointer disabled:opacity-50"
>
<Tag className="size-3.5 text-muted-foreground/70" />
<span>
{newLinearIssueLabelIds.length === 0
? 'Labels'
: `${newLinearIssueLabelIds.length} label${newLinearIssueLabelIds.length > 1 ? 's' : ''}`}
</span>
<ChevronDown className="size-3 text-muted-foreground/70" />
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-64 p-1">
<div className="text-[10px] font-semibold text-muted-foreground px-2 py-1 uppercase tracking-wider">
Labels
</div>
{newLinearLabels.loading ? (
<div className="flex items-center justify-center p-4">
<LoaderCircle className="size-4 animate-spin text-muted-foreground" />
</div>
) : (
<div className="max-h-60 overflow-y-auto scrollbar-sleek">
{newLinearLabels.data.map((l) => {
const isSelected = newLinearIssueLabelIds.includes(l.id)
return (
<button
key={l.id}
type="button"
onClick={() => {
if (isSelected) {
setNewLinearIssueLabelIds(
newLinearIssueLabelIds.filter((id) => id !== l.id)
)
} else {
setNewLinearIssueLabelIds([...newLinearIssueLabelIds, l.id])
}
}}
className={`w-full flex items-center justify-between text-left px-2 py-1.5 text-xs rounded-sm hover:bg-muted transition-colors ${
isSelected
? 'bg-muted font-medium text-foreground'
: 'text-foreground/80'
}`}
>
<div className="flex items-center gap-2">
<span
className="size-2 rounded-full flex-shrink-0"
style={{ backgroundColor: l.color || '#a3a3a3' }}
/>
<span>{l.name}</span>
</div>
{isSelected && <Check className="size-3 text-foreground" />}
</button>
)
})}
</div>
)}
</PopoverContent>
</Popover>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-between border-t border-border/60 px-6 py-4 bg-muted/5">
<span className="text-[10px] text-muted-foreground/60 font-medium">
{submitShortcutLabel} to submit.
</span>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setNewLinearIssueOpen(false)}
disabled={newLinearIssueSubmitting}
className="text-xs h-8 text-muted-foreground hover:text-foreground"
>
Cancel
</Button>
<Button
size="sm"
onClick={() => void handleCreateNewLinearIssue()}
disabled={
!newLinearIssueTargetTeam ||
!newLinearIssueTitle.trim() ||
newLinearIssueSubmitting
}
className="text-xs h-8 bg-foreground text-background hover:bg-foreground/90 disabled:opacity-50"
>
{newLinearIssueSubmitting ? (
<>
<LoaderCircle className="size-3.5 animate-spin mr-1" />
Creating
</>
) : (
'Create issue'
)}
</Button>
</div>
</div>
</DialogContent>
</Dialog>

View File

@ -154,6 +154,10 @@ export async function linearCreateIssue(
workspaceId?: string
parentIssueId?: string
projectId?: string | null
stateId?: string
priority?: number
assigneeId?: string | null
labelIds?: string[]
}
): Promise<LinearCreateIssueResult> {
const target = getActiveRuntimeTarget(settings)

View File

@ -1069,6 +1069,7 @@ export type GitHubPullRequestStateUpdate = {
export type LinearIssueUpdate = {
stateId?: string
title?: string
description?: string
assigneeId?: string | null
estimate?: number | null
priority?: number