diff --git a/src/main/ipc/linear.ts b/src/main/ipc/linear.ts index f3df09c84..c0b4b02c2 100644 --- a/src/main/ipc/linear.ts +++ b/src/main/ipc/linear.ts @@ -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) diff --git a/src/main/linear/issues.ts b/src/main/linear/issues.ts index 1c10d51fa..1392b90b9 100644 --- a/src/main/linear/issues.ts +++ b/src/main/linear/issues.ts @@ -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 } diff --git a/src/main/linear/projects.ts b/src/main/linear/projects.ts index 142895e84..f5b9f7d80 100644 --- a/src/main/linear/projects.ts +++ b/src/main/linear/projects.ts @@ -17,9 +17,6 @@ export async function listProjects( workspaceId?: LinearWorkspaceSelection | null ): Promise { 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) { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 05376ad93..a32dc5c65 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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 { return createLinearIssue(teamId, title, description, workspaceId, { parentId: parentIssueId, - projectId + projectId, + ...options }) } diff --git a/src/main/runtime/rpc/methods/linear.test.ts b/src/main/runtime/rpc/methods/linear.test.ts index e542ce426..5e1ee00f8 100644 --- a/src/main/runtime/rpc/methods/linear.test.ts +++ b/src/main/runtime/rpc/methods/linear.test.ts @@ -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', diff --git a/src/main/runtime/rpc/methods/linear.ts b/src/main/runtime/rpc/methods/linear.ts index a16e8637e..bf661e353 100644 --- a/src/main/runtime/rpc/methods/linear.ts +++ b/src/main/runtime/rpc/methods/linear.ts @@ -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({ diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index ef0026a59..599f8160d 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -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 } diff --git a/src/preload/index.ts b/src/preload/index.ts index ececccf1f..3f2fc1d14 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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 } diff --git a/src/renderer/src/components/LinearIssueTextEditor.tsx b/src/renderer/src/components/LinearIssueTextEditor.tsx new file mode 100644 index 000000000..320cae135 --- /dev/null +++ b/src/renderer/src/components/LinearIssueTextEditor.tsx @@ -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 | Pick) => void + density?: 'page' | 'drawer' + fields?: 'all' | 'title' | 'description' +} + +function useAutosizeTextArea(value: string): React.RefObject { + const ref = useRef(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) => { + if (!isScreenSubmitShortcut(event)) { + return + } + event.preventDefault() + event.currentTarget.blur() + }, + [] + ) + + const handleTitleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + 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 ( +
+ {fields !== 'description' ? ( +
+