From a67afb5a04006468b5c68519b623d9bc153cfc03 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 7 Jun 2026 19:50:05 -0700 Subject: [PATCH] fix: address review findings (#4853) --- src/main/ipc/linear.ts | 90 +++ src/main/linear/projects.test.ts | 63 ++ src/main/linear/projects.ts | 64 ++ src/main/runtime/orca-runtime.ts | 11 +- .../rpc/methods/linear-project-create.ts | 41 ++ src/main/runtime/rpc/methods/linear.test.ts | 31 + src/main/runtime/rpc/methods/linear.ts | 2 + src/main/runtime/runtime-rpc.ts | 1 + src/preload/api-types.ts | 13 + src/preload/index.ts | 16 + src/renderer/src/components/TaskPage.tsx | 652 ++++++++++++++++-- .../linear-project-view-surfaces.tsx | 5 +- .../src/runtime/runtime-linear-client.test.ts | 42 ++ .../src/runtime/runtime-linear-client.ts | 27 + 14 files changed, 1003 insertions(+), 55 deletions(-) create mode 100644 src/main/runtime/rpc/methods/linear-project-create.ts diff --git a/src/main/ipc/linear.ts b/src/main/ipc/linear.ts index fbce759cea4..1cf04dbb558 100644 --- a/src/main/ipc/linear.ts +++ b/src/main/ipc/linear.ts @@ -13,6 +13,7 @@ import { getIssueComments } from '../linear/issues' import { + createProject, getCustomView, getProject, listCustomViewIssues, @@ -56,6 +57,29 @@ function normalizeCustomViewModel(value: unknown): LinearCustomViewModel { return value } +function normalizeIdList(value: unknown, fieldName: string): string[] | undefined { + if (value === undefined) { + return undefined + } + if ( + !Array.isArray(value) || + !value.every((id): id is string => typeof id === 'string' && Boolean(id.trim())) + ) { + throw new Error(`Invalid ${fieldName}`) + } + return value.map((id) => id.trim()) +} + +function normalizeOptionalDate(value: unknown, fieldName: string): string | undefined { + if (value === undefined || value === null || value === '') { + return undefined + } + if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value.trim())) { + throw new Error(`Invalid ${fieldName}`) + } + return value.trim() +} + export function registerLinearHandlers(): void { ipcMain.handle('linear:connect', async (_event, args: { apiKey: string }) => { if (typeof args?.apiKey !== 'string' || !args.apiKey.trim()) { @@ -284,6 +308,72 @@ export function registerLinearHandlers(): void { } ) + ipcMain.handle( + 'linear:createProject', + async ( + _event, + args: { + name: string + description?: string + content?: string + teamIds?: string[] + leadId?: string | null + memberIds?: string[] + labelIds?: string[] + priority?: number + startDate?: string + targetDate?: string + workspaceId?: string + } + ) => { + if (typeof args?.name !== 'string' || !args.name.trim()) { + return { ok: false, error: 'Project name is required' } + } + let teamIds: string[] + try { + teamIds = normalizeIdList(args.teamIds, 'team IDs') ?? [] + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : 'Invalid team IDs' } + } + if (teamIds.length === 0) { + return { ok: false, error: 'At least one team is required' } + } + if ( + args.priority !== undefined && + (!Number.isInteger(args.priority) || args.priority < 0 || args.priority > 4) + ) { + return { ok: false, error: 'Invalid priority' } + } + let memberIds: string[] | undefined + let labelIds: string[] | undefined + let startDate: string | undefined + let targetDate: string | undefined + try { + memberIds = normalizeIdList(args.memberIds, 'member IDs') + labelIds = normalizeIdList(args.labelIds, 'label IDs') + startDate = normalizeOptionalDate(args.startDate, 'start date') + targetDate = normalizeOptionalDate(args.targetDate, 'target date') + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : 'Invalid project' } + } + return createProject( + { + name: args.name.trim(), + description: args.description?.trim() || undefined, + content: args.content?.trim() || undefined, + teamIds, + leadId: normalizeWorkspaceId(args.leadId), + memberIds, + labelIds, + priority: typeof args.priority === 'number' ? args.priority : undefined, + startDate, + targetDate + }, + normalizeWorkspaceId(args.workspaceId) + ) + } + ) + ipcMain.handle( 'linear:getProject', async (_event, args: { id: string; workspaceId?: string; force?: boolean }) => { diff --git a/src/main/linear/projects.test.ts b/src/main/linear/projects.test.ts index 12f4a1ab520..c64c23971d0 100644 --- a/src/main/linear/projects.test.ts +++ b/src/main/linear/projects.test.ts @@ -210,6 +210,69 @@ describe('Linear project queries', () => { }) }) + it('creates a project with team metadata and maps the created project', async () => { + rawRequest.mockResolvedValueOnce({ + data: { + projectCreate: { + success: true, + project: { + ...rawProject('project-1'), + description: 'Summary', + content: 'Brief', + priority: 2, + targetDate: '2026-08-01', + teams: { nodes: [{ id: 'team-1', name: 'Team', key: 'TM' }] } + } + } + } + }) + const { createProject } = await import('./projects') + + const result = await createProject( + { + name: 'Roadmap', + description: 'Summary', + content: 'Brief', + teamIds: ['team-1'], + leadId: 'user-1', + memberIds: ['user-1', 'user-2'], + labelIds: ['label-1'], + priority: 2, + startDate: '2026-07-01', + targetDate: '2026-08-01' + }, + 'workspace-1' + ) + + expect(result).toMatchObject({ + ok: true, + project: { + id: 'project-1', + name: 'project-1', + workspaceId: 'workspace-1', + description: 'Summary', + content: 'Brief', + priority: 2, + targetDate: '2026-08-01', + teams: [{ id: 'team-1', name: 'Team', key: 'TM' }] + } + }) + expect(rawRequest.mock.calls[0]?.[1]).toEqual({ + input: { + name: 'Roadmap', + description: 'Summary', + content: 'Brief', + teamIds: ['team-1'], + leadId: 'user-1', + memberIds: ['user-1', 'user-2'], + labelIds: ['label-1'], + priority: 2, + startDate: '2026-07-01', + targetDate: '2026-08-01' + } + }) + }) + it('lets manual custom view list refresh bypass older in-flight reads', async () => { const staleRequest = deferred>() const refreshRequest = deferred>() diff --git a/src/main/linear/projects.ts b/src/main/linear/projects.ts index 5b0a257840c..bb9ff3bf9d0 100644 --- a/src/main/linear/projects.ts +++ b/src/main/linear/projects.ts @@ -160,6 +160,26 @@ type CustomViewConnectionResponse = { | null } +type ProjectMutationResponse = { + projectCreate?: { + success?: boolean | null + project?: LinearProjectNode | null + } | null +} + +export type LinearProjectCreateInput = { + name: string + description?: string + content?: string + teamIds: string[] + leadId?: string + memberIds?: string[] + labelIds?: string[] + priority?: number + startDate?: string + targetDate?: string +} + const ORCA_PROJECT_FIELDS = ` id name @@ -316,6 +336,17 @@ const PROJECT_QUERY = ` } ` +const CREATE_PROJECT_MUTATION = ` + mutation OrcaLinearProjectCreate($input: ProjectCreateInput!) { + projectCreate(input: $input) { + success + project { + ${ORCA_PROJECT_DETAIL_FIELDS} + } + } + } +` + const PROJECT_ISSUES_QUERY = ` query OrcaLinearProjectIssues( $id: String!, @@ -865,6 +896,39 @@ export async function getProject( ) } +export async function createProject( + input: LinearProjectCreateInput, + workspaceId?: string | null +): Promise<{ ok: true; project: LinearProjectDetail } | { ok: false; error: string }> { + const entry = getClients(workspaceId)[0] + if (!entry) { + return { ok: false, error: 'Not connected to Linear' } + } + + await acquire() + try { + const result = await entry.client.client.rawRequest< + ProjectMutationResponse, + LinearRawVariables + >(CREATE_PROJECT_MUTATION, { input }) + const payload = result.data?.projectCreate + const project = payload?.project + if (!payload?.success || !project) { + return { ok: false, error: 'Linear project create failed' } + } + return { ok: true, project: mapProjectDetailForWorkspace(entry, project) } + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.workspace.id) + throw error + } + const message = error instanceof Error ? error.message : String(error) + return { ok: false, error: message } + } finally { + release() + } +} + export async function listProjectIssues( projectId: string, limit = 20, diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index fe85fe4f0ef..491352460ab 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -293,13 +293,15 @@ import { type LinearListFilter } from '../linear/issues' import { + createProject as createLinearProject, getCustomView as getLinearCustomView, getProject as getLinearProject, listCustomViewIssues as listLinearCustomViewIssues, listCustomViewProjects as listLinearCustomViewProjects, listCustomViews as listLinearCustomViews, listProjectIssues as listLinearProjectIssues, - listProjects as listLinearProjects + listProjects as listLinearProjects, + type LinearProjectCreateInput } from '../linear/projects' import { getTeamLabels as getLinearTeamLabels, @@ -13892,6 +13894,13 @@ export class OrcaRuntimeService { return listLinearProjects(query, Math.min(Math.max(1, limit), 50), workspaceId, force) } + linearCreateProject( + input: LinearProjectCreateInput, + workspaceId?: string + ): ReturnType { + return createLinearProject(input, workspaceId) + } + linearGetProject( id: string, workspaceId: string, diff --git a/src/main/runtime/rpc/methods/linear-project-create.ts b/src/main/runtime/rpc/methods/linear-project-create.ts new file mode 100644 index 00000000000..026c585aba2 --- /dev/null +++ b/src/main/runtime/rpc/methods/linear-project-create.ts @@ -0,0 +1,41 @@ +import { z } from 'zod' +import { defineMethod, type RpcMethod } from '../core' +import { OptionalString, requiredString } from '../schemas' + +const LinearPriority = z.number().int().min(0).max(4).optional() +const LinearLabelIds = z.array(requiredString('Invalid label ID')).optional() + +const CreateProject = z.object({ + name: requiredString('Project name is required'), + description: OptionalString, + content: OptionalString, + workspaceId: OptionalString, + teamIds: z.array(requiredString('Invalid team ID')).min(1, 'At least one team is required'), + leadId: z.union([z.string(), z.null()]).optional(), + memberIds: z.array(requiredString('Invalid member ID')).optional(), + labelIds: LinearLabelIds, + priority: LinearPriority, + startDate: OptionalString, + targetDate: OptionalString +}) + +export const LINEAR_PROJECT_CREATE_METHOD: RpcMethod = defineMethod({ + name: 'linear.createProject', + params: CreateProject, + handler: async (params, { runtime }) => + runtime.linearCreateProject( + { + name: params.name.trim(), + description: params.description?.trim() || undefined, + content: params.content?.trim() || undefined, + teamIds: params.teamIds.map((id) => id.trim()), + leadId: params.leadId ? params.leadId.trim() : undefined, + memberIds: params.memberIds?.map((id) => id.trim()), + labelIds: params.labelIds?.map((id) => id.trim()), + priority: params.priority, + startDate: params.startDate?.trim() || undefined, + targetDate: params.targetDate?.trim() || undefined + }, + params.workspaceId + ) +}) diff --git a/src/main/runtime/rpc/methods/linear.test.ts b/src/main/runtime/rpc/methods/linear.test.ts index e95fb56681f..f2c68db7801 100644 --- a/src/main/runtime/rpc/methods/linear.test.ts +++ b/src/main/runtime/rpc/methods/linear.test.ts @@ -163,6 +163,7 @@ describe('linear RPC methods', () => { linearListProjectIssues: vi.fn().mockResolvedValue({ items: [{ id: 'issue-2' }] }), linearListTeams: vi.fn().mockResolvedValue([{ id: 'team-1' }]), linearListProjects: vi.fn().mockResolvedValue({ items: [{ id: 'project-1' }] }), + linearCreateProject: vi.fn().mockResolvedValue({ ok: true, project: { id: 'project-3' } }), linearTeamStates: vi.fn().mockResolvedValue([{ id: 'state-1' }]), linearTeamLabels: vi.fn().mockResolvedValue([{ id: 'label-1' }]), linearTeamMembers: vi.fn().mockResolvedValue([{ id: 'member-1' }]) @@ -173,6 +174,21 @@ describe('linear RPC methods', () => { await dispatcher.dispatch( makeRequest('linear.listProjects', { query: 'roadmap', limit: 5, force: true }) ) + await dispatcher.dispatch( + makeRequest('linear.createProject', { + name: 'Roadmap', + description: 'Summary', + content: 'Brief', + teamIds: ['team-1'], + workspaceId: 'workspace-1', + leadId: 'user-1', + memberIds: ['user-1', 'user-2'], + labelIds: ['label-1'], + priority: 2, + startDate: '2026-07-01', + targetDate: '2026-08-01' + }) + ) await dispatcher.dispatch( makeRequest('linear.getProject', { id: 'project-1', @@ -232,6 +248,21 @@ describe('linear RPC methods', () => { expect(runtime.linearListTeams).toHaveBeenCalledWith('all') expect(runtime.linearListProjects).toHaveBeenCalledWith('roadmap', 5, undefined, true) + expect(runtime.linearCreateProject).toHaveBeenCalledWith( + { + name: 'Roadmap', + description: 'Summary', + content: 'Brief', + teamIds: ['team-1'], + leadId: 'user-1', + memberIds: ['user-1', 'user-2'], + labelIds: ['label-1'], + priority: 2, + startDate: '2026-07-01', + targetDate: '2026-08-01' + }, + 'workspace-1' + ) expect(runtime.linearGetProject).toHaveBeenCalledWith('project-1', 'workspace-1', true) expect(runtime.linearListProjectIssues).toHaveBeenCalledWith( 'project-1', diff --git a/src/main/runtime/rpc/methods/linear.ts b/src/main/runtime/rpc/methods/linear.ts index 3153a2c420b..d3581f78310 100644 --- a/src/main/runtime/rpc/methods/linear.ts +++ b/src/main/runtime/rpc/methods/linear.ts @@ -1,6 +1,7 @@ import { z } from 'zod' import { defineMethod, type RpcMethod } from '../core' import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas' +import { LINEAR_PROJECT_CREATE_METHOD } from './linear-project-create' const VALID_FILTERS = ['assigned', 'created', 'all', 'completed'] as const const VALID_CUSTOM_VIEW_MODELS = ['issue', 'project'] as const @@ -222,6 +223,7 @@ export const LINEAR_METHODS: RpcMethod[] = [ handler: async (params, { runtime }) => runtime.linearListProjects(params?.query, params?.limit, params?.workspaceId, params?.force) }), + LINEAR_PROJECT_CREATE_METHOD, defineMethod({ name: 'linear.getProject', params: ProjectId, diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index b29f74b4dbb..e692a10f7f4 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -227,6 +227,7 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'linear.addIssueComment', 'linear.connect', 'linear.createIssue', + 'linear.createProject', 'linear.issueComments', 'linear.listCustomViewIssues', 'linear.listCustomViewProjects', diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 93248b49cea..fd69d1e50cf 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1433,6 +1433,19 @@ export type PreloadApi = { workspaceId?: LinearWorkspaceSelection force?: boolean }) => Promise> + createProject: (args: { + name: string + description?: string + content?: string + teamIds: string[] + workspaceId?: string + leadId?: string | null + memberIds?: string[] + labelIds?: string[] + priority?: number + startDate?: string + targetDate?: string + }) => Promise<{ ok: true; project: LinearProjectDetail } | { ok: false; error: string }> getProject: (args: { id: string workspaceId: string diff --git a/src/preload/index.ts b/src/preload/index.ts index a2f45cc13cc..db3fff187ce 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -26,6 +26,7 @@ import type { GitUpstreamStatus, GhosttyImportPreview, ListWorkItemsResult, + LinearProjectDetail, MemorySnapshot, NotificationDismissResult, NotificationDispatchResult, @@ -1282,6 +1283,21 @@ const api = { force?: boolean }): Promise => ipcRenderer.invoke('linear:listProjects', args), + createProject: (args: { + name: string + description?: string + content?: string + teamIds: string[] + workspaceId?: string + leadId?: string | null + memberIds?: string[] + labelIds?: string[] + priority?: number + startDate?: string + targetDate?: string + }): Promise<{ ok: true; project: LinearProjectDetail } | { ok: false; error: string }> => + ipcRenderer.invoke('linear:createProject', args), + getProject: (args: { id: string; workspaceId: string; force?: boolean }): Promise => ipcRenderer.invoke('linear:getProject', args), diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index c03a42da12b..b385db3abc7 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -203,6 +203,7 @@ import { useTeamLabels } from '@/hooks/useIssueMetadata' import { + linearCreateProject, linearCreateIssue, linearGetIssue, linearTeamStates, @@ -498,10 +499,7 @@ const LINEAR_MODE_OPTIONS: { id: LinearMode; label: string }[] = [ { id: 'views', label: 'Views' } ] -const LINEAR_CUSTOM_VIEW_MODEL_OPTIONS: { id: LinearCustomViewModel; label: string }[] = [ - { id: 'issue', label: 'Issues' }, - { id: 'project', label: 'Projects' } -] +const LINEAR_CUSTOM_VIEW_MODELS = ['issue', 'project'] satisfies readonly LinearCustomViewModel[] const LINEAR_VIEW_OPTIONS: { id: LinearViewMode @@ -512,6 +510,17 @@ const LINEAR_VIEW_OPTIONS: { { id: 'board', label: 'Board', Icon: LayoutGrid } ] +function mergeLinearCollectionResults( + results: LinearCollectionResult[] +): LinearCollectionResult { + const errors = results.flatMap((result) => result.errors ?? []) + return { + items: results.flatMap((result) => result.items), + ...(errors.length > 0 ? { errors } : {}), + ...(results.some((result) => result.hasMore) ? { hasMore: true } : {}) + } +} + const LINEAR_GROUP_OPTIONS: { id: LinearGroupBy; label: string }[] = [ { id: 'none', label: 'No grouping' }, { id: 'status', label: 'Status' }, @@ -3200,7 +3209,6 @@ export default function TaskPage(): React.JSX.Element { >(null) const [linearProjectIssuesLoading, setLinearProjectIssuesLoading] = useState(false) const [linearProjectIssuesError, setLinearProjectIssuesError] = useState(null) - const [linearCustomViewModel, setLinearCustomViewModel] = useState('issue') const [linearCustomViewsResult, setLinearCustomViewsResult] = useState< LinearCollectionResult >({ items: [] }) @@ -3442,7 +3450,6 @@ export default function TaskPage(): React.JSX.Element { } if (context.kind === 'view' && context.model) { - setLinearCustomViewModel(context.model) setLinearMode('views') setLinearCustomViewsLoading(true) setLinearCustomViewsError(null) @@ -4246,6 +4253,41 @@ export default function TaskPage(): React.JSX.Element { [jiraIssues, jiraCacheSnapshot.issueCache, jiraCacheSnapshot.searchCache] ) + // New Linear project dialog state + const [newLinearProjectOpen, setNewLinearProjectOpen] = useState(false) + const [newLinearProjectName, setNewLinearProjectName] = useState('') + const [newLinearProjectDescription, setNewLinearProjectDescription] = useState('') + const [newLinearProjectContent, setNewLinearProjectContent] = useState('') + const [newLinearProjectTeamId, setNewLinearProjectTeamId] = useState(null) + const [newLinearProjectLeadId, setNewLinearProjectLeadId] = useState(null) + const [newLinearProjectMemberIds, setNewLinearProjectMemberIds] = useState([]) + const [newLinearProjectLabelIds, setNewLinearProjectLabelIds] = useState([]) + const [newLinearProjectPriority, setNewLinearProjectPriority] = useState(0) + const [newLinearProjectStartDate, setNewLinearProjectStartDate] = useState('') + const [newLinearProjectTargetDate, setNewLinearProjectTargetDate] = useState('') + const [newLinearProjectSubmitting, setNewLinearProjectSubmitting] = useState(false) + + const newLinearProjectTargetTeam = useMemo( + () => availableTeams.find((t) => t.id === newLinearProjectTeamId) ?? availableTeams[0] ?? null, + [availableTeams, newLinearProjectTeamId] + ) + const newLinearProjectMembers = useTeamMembers( + newLinearProjectOpen ? (newLinearProjectTargetTeam?.id ?? null) : null, + settings, + newLinearProjectTargetTeam?.workspaceId + ) + const newLinearProjectLabels = useTeamLabels( + newLinearProjectOpen ? (newLinearProjectTargetTeam?.id ?? null) : null, + settings, + newLinearProjectTargetTeam?.workspaceId + ) + + useEffect(() => { + setNewLinearProjectLeadId(null) + setNewLinearProjectMemberIds([]) + setNewLinearProjectLabelIds([]) + }, [newLinearProjectTargetTeam?.id, newLinearProjectTargetTeam?.workspaceId]) + // New Linear issue dialog state const [newLinearIssueOpen, setNewLinearIssueOpen] = useState(false) const [newLinearIssueTitle, setNewLinearIssueTitle] = useState('') @@ -4347,6 +4389,7 @@ export default function TaskPage(): React.JSX.Element { !gitlabDialogItem && !selectedLinearIssue && !newIssueOpen && + !newLinearProjectOpen && !newLinearIssueOpen && !linearConnectOpen && activeModal === 'none', @@ -5112,6 +5155,7 @@ export default function TaskPage(): React.JSX.Element { githubMode !== 'items' || dialogWorkItem || newIssueOpen || + newLinearProjectOpen || newLinearIssueOpen || newJiraIssueOpen || activeModal !== 'none' @@ -5154,6 +5198,7 @@ export default function TaskPage(): React.JSX.Element { dialogWorkItem, githubMode, newIssueOpen, + newLinearProjectOpen, newLinearIssueOpen, newJiraIssueOpen, taskSource @@ -5350,6 +5395,81 @@ export default function TaskPage(): React.JSX.Element { setDialogWorkItem ]) + const handleCreateNewLinearProject = useCallback(async (): Promise => { + if (!newLinearProjectTargetTeam) { + return + } + const name = newLinearProjectName.trim() + if (!name || newLinearProjectSubmitting) { + return + } + setNewLinearProjectSubmitting(true) + try { + const result = await linearCreateProject(settings, { + name, + description: newLinearProjectDescription.trim() || undefined, + content: newLinearProjectContent.trim() || undefined, + teamIds: [newLinearProjectTargetTeam.id], + workspaceId: newLinearProjectTargetTeam.workspaceId, + leadId: newLinearProjectLeadId || undefined, + memberIds: newLinearProjectMemberIds.length > 0 ? newLinearProjectMemberIds : undefined, + labelIds: newLinearProjectLabelIds.length > 0 ? newLinearProjectLabelIds : undefined, + priority: newLinearProjectPriority, + startDate: newLinearProjectStartDate || undefined, + targetDate: newLinearProjectTargetDate || undefined + }) + if (!result.ok) { + toast.error(result.error || 'Failed to create project.') + return + } + toast.success(`Created ${result.project.name}`, { + action: result.project.url + ? { + label: 'View', + onClick: () => window.open(result.project.url, '_blank') + } + : undefined + }) + setNewLinearProjectOpen(false) + setNewLinearProjectName('') + setNewLinearProjectDescription('') + setNewLinearProjectContent('') + setNewLinearProjectLeadId(null) + setNewLinearProjectMemberIds([]) + setNewLinearProjectLabelIds([]) + setNewLinearProjectPriority(0) + setNewLinearProjectStartDate('') + setNewLinearProjectTargetDate('') + setAppliedLinearProjectSearch('') + setLinearProjectSearchInput('') + setLinearProjectsResult((current) => ({ + ...current, + items: [result.project, ...current.items.filter((item) => item.id !== result.project.id)] + })) + setSelectedLinearProjectDetail(result.project) + openLinearProjectContext(result.project) + setLinearRefreshNonce((n) => n + 1) + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to create project.') + } finally { + setNewLinearProjectSubmitting(false) + } + }, [ + newLinearProjectContent, + newLinearProjectDescription, + newLinearProjectLabelIds, + newLinearProjectLeadId, + newLinearProjectMemberIds, + newLinearProjectName, + newLinearProjectPriority, + newLinearProjectStartDate, + newLinearProjectSubmitting, + newLinearProjectTargetDate, + newLinearProjectTargetTeam, + openLinearProjectContext, + settings + ]) + const handleCreateNewLinearIssue = useCallback(async (): Promise => { if (!newLinearIssueTargetTeam) { return @@ -5884,17 +6004,28 @@ export default function TaskPage(): React.JSX.Element { return } let cancelled = false - const cached = getCachedLinearCustomViews(linearCustomViewModel, LINEAR_ITEM_LIMIT) - if (cached) { - setLinearCustomViewsResult(cached) + const cachedResults = LINEAR_CUSTOM_VIEW_MODELS.map((model) => + getCachedLinearCustomViews(model, LINEAR_ITEM_LIMIT) + ) + const allCached = cachedResults.every( + (result): result is LinearCollectionResult => result !== null + ) + if (allCached) { + setLinearCustomViewsResult(mergeLinearCollectionResults(cachedResults)) } const force = linearRefreshNonce > 0 - setLinearCustomViewsLoading(force || cached === null) + setLinearCustomViewsLoading(force || !allCached) setLinearCustomViewsError(null) - void listLinearCustomViews(linearCustomViewModel, LINEAR_ITEM_LIMIT, undefined, { force }) + // Why: the Views tab already has a Model column, so listing both view + // models avoids a second, redundant Issues/Projects switch. + void Promise.all( + LINEAR_CUSTOM_VIEW_MODELS.map((model) => + listLinearCustomViews(model, LINEAR_ITEM_LIMIT, undefined, { force }) + ) + ) .then((result) => { if (!cancelled) { - setLinearCustomViewsResult(result) + setLinearCustomViewsResult(mergeLinearCollectionResults(result)) setLinearCustomViewsLoading(false) } }) @@ -5917,9 +6048,9 @@ export default function TaskPage(): React.JSX.Element { linearStatus.connected, selectedLinearWorkspaceId, selectedLinearCustomView, - linearCustomViewModel, linearRefreshNonce, - getCachedLinearCustomViews + getCachedLinearCustomViews, + listLinearCustomViews ]) useEffect(() => { @@ -6755,6 +6886,20 @@ export default function TaskPage(): React.JSX.Element { variant="outline" size="icon" onClick={() => { + if (linearMode === 'projects' && !selectedLinearProject) { + setNewLinearProjectName('') + setNewLinearProjectDescription('') + setNewLinearProjectContent('') + setNewLinearProjectTeamId(availableTeams[0]?.id ?? null) + setNewLinearProjectLeadId(null) + setNewLinearProjectMemberIds([]) + setNewLinearProjectLabelIds([]) + setNewLinearProjectPriority(0) + setNewLinearProjectStartDate('') + setNewLinearProjectTargetDate('') + setNewLinearProjectOpen(true) + return + } setNewLinearIssueTitle('') setNewLinearIssueBody('') const projectTeamId = @@ -6770,14 +6915,20 @@ export default function TaskPage(): React.JSX.Element { setNewLinearIssueOpen(true) }} disabled={availableTeams.length === 0} - aria-label="New Linear issue" + aria-label={ + linearMode === 'projects' && !selectedLinearProject + ? 'New Linear project' + : 'New Linear issue' + } className="size-8 border-border/50 bg-transparent hover:bg-muted/50 backdrop-blur-md supports-[backdrop-filter]:bg-transparent" > - New Linear issue + {linearMode === 'projects' && !selectedLinearProject + ? 'New Linear project' + : 'New Linear issue'} @@ -6890,38 +7041,6 @@ export default function TaskPage(): React.JSX.Element { ) : null} - ) : linearMode === 'views' && !selectedLinearCustomView ? ( -
- {LINEAR_CUSTOM_VIEW_MODEL_OPTIONS.map((option) => { - const active = option.id === linearCustomViewModel - return ( - - ) - })} -
) : null} ) : taskSource === 'jira' && jiraStatus.connected ? ( @@ -8256,7 +8375,6 @@ export default function TaskPage(): React.JSX.Element { ) : null} ) : selectedLinearCustomView?.model === 'project' && !selectedLinearProject ? ( @@ -9166,6 +9284,440 @@ export default function TaskPage(): React.JSX.Element { + { + if (!newLinearProjectSubmitting) { + setNewLinearProjectOpen(open) + } + }} + > + { + if (isScreenSubmitShortcut(event)) { + event.preventDefault() + void handleCreateNewLinearProject() + } + }} + > + New Linear project + + Create a Linear project for the selected team. + +
+
+ + New Project + + / + {availableTeams.length > 1 ? ( + + + + + +
+ Team +
+
+ {availableTeams.map((team) => ( + + ))} +
+
+
+ ) : ( + + {newLinearProjectTargetTeam + ? `${newLinearProjectTargetTeam.key} - ${newLinearProjectTargetTeam.name}` + : ''} + + )} +
+ +
+ +
+ setNewLinearProjectName(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter' && !event.nativeEvent.isComposing) { + event.preventDefault() + void handleCreateNewLinearProject() + } + }} + placeholder="Project name" + disabled={newLinearProjectSubmitting} + className="w-full border-none bg-transparent p-0 text-xl font-semibold text-foreground outline-none placeholder:text-muted-foreground/45 focus:outline-none focus:ring-0 focus-visible:ring-0" + /> + + setNewLinearProjectDescription(event.target.value)} + placeholder="Add a short summary..." + disabled={newLinearProjectSubmitting} + className="w-full border-none bg-transparent p-0 text-sm text-foreground outline-none placeholder:text-muted-foreground/45 focus:outline-none focus:ring-0 focus-visible:ring-0" + /> + +
+ + + + + +
+ Priority +
+ {[0, 1, 2, 3, 4].map((priority) => ( + + ))} +
+
+ + + + + + +
+ Lead +
+ {newLinearProjectMembers.loading ? ( +
+ +
+ ) : ( +
+ + {newLinearProjectMembers.data.map((member) => ( + + ))} +
+ )} +
+
+ + + + + + +
+ Members +
+ {newLinearProjectMembers.loading ? ( +
+ +
+ ) : ( +
+ {newLinearProjectMembers.data.map((member) => { + const selected = newLinearProjectMemberIds.includes(member.id) + return ( + + ) + })} +
+ )} +
+
+ + + + + + +
+ Labels +
+ {newLinearProjectLabels.loading ? ( +
+ +
+ ) : ( +
+ {newLinearProjectLabels.data.length === 0 ? ( +
No labels
+ ) : ( + newLinearProjectLabels.data.map((label) => { + const selected = newLinearProjectLabelIds.includes(label.id) + return ( + + ) + }) + )} +
+ )} +
+
+ + + + +
+ +
+