Improve Linear issue editing flow (#2901)
* fix: address review findings * fix: update Linear RPC test
This commit is contained in:
parent
f037e1c3d6
commit
5783c4192b
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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} />
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -1069,6 +1069,7 @@ export type GitHubPullRequestStateUpdate = {
|
|||
export type LinearIssueUpdate = {
|
||||
stateId?: string
|
||||
title?: string
|
||||
description?: string
|
||||
assigneeId?: string | null
|
||||
estimate?: number | null
|
||||
priority?: number
|
||||
|
|
|
|||
Loading…
Reference in New Issue