Add Linear project and custom view browsing (#3966)

* Add Linear project and custom view browsing

* fix: address review findings
This commit is contained in:
Jinjing 2026-05-30 18:10:06 -07:00 committed by GitHub
parent dbfb947faf
commit ab3e1b8bc5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 4402 additions and 139 deletions

View File

@ -1,3 +1,5 @@
/* eslint-disable max-lines -- Why: Linear IPC validates one namespace in one
registration boundary so local and SSH runtime schemas can stay mirrored. */
import { ipcMain } from 'electron'
import { connect, disconnect, getStatus, selectWorkspace, testConnection } from '../linear/client'
import { _resetPreflightCache } from './preflight'
@ -10,10 +12,22 @@ import {
addIssueComment,
getIssueComments
} from '../linear/issues'
import { listProjects } from '../linear/projects'
import {
getCustomView,
getProject,
listCustomViewIssues,
listCustomViewProjects,
listCustomViews,
listProjectIssues,
listProjects
} from '../linear/projects'
import { listTeams, getTeamStates, getTeamLabels, getTeamMembers } from '../linear/teams'
import type { LinearListFilter } from '../linear/issues'
import type { LinearIssueUpdate, LinearWorkspaceSelection } from '../../shared/types'
import type {
LinearCustomViewModel,
LinearIssueUpdate,
LinearWorkspaceSelection
} from '../../shared/types'
const VALID_FILTERS = new Set<LinearListFilter>(['assigned', 'created', 'all', 'completed'])
@ -26,6 +40,21 @@ function normalizeWorkspaceSelection(value: unknown): LinearWorkspaceSelection |
return workspaceId as LinearWorkspaceSelection | undefined
}
function normalizeConcreteWorkspaceId(value: unknown): string {
const workspaceId = normalizeWorkspaceId(value)
if (!workspaceId || workspaceId === 'all') {
throw new Error('Concrete Linear workspace ID is required')
}
return workspaceId
}
function normalizeCustomViewModel(value: unknown): LinearCustomViewModel {
if (value !== 'issue' && value !== 'project') {
throw new Error('Custom view model is required')
}
return value
}
export function registerLinearHandlers(): void {
ipcMain.handle('linear:connect', async (_event, args: { apiKey: string }) => {
if (typeof args?.apiKey !== 'string' || !args.apiKey.trim()) {
@ -244,6 +273,97 @@ export function registerLinearHandlers(): void {
}
)
ipcMain.handle(
'linear:getProject',
async (_event, args: { id: string; workspaceId?: string }) => {
if (typeof args?.id !== 'string' || !args.id.trim()) {
throw new Error('Project ID is required')
}
return getProject(args.id.trim(), normalizeConcreteWorkspaceId(args.workspaceId))
}
)
ipcMain.handle(
'linear:listProjectIssues',
async (_event, args: { projectId: string; limit?: number; workspaceId?: string }) => {
if (typeof args?.projectId !== 'string' || !args.projectId.trim()) {
throw new Error('Project ID is required')
}
const limit = Math.min(Math.max(1, args?.limit ?? 20), 50)
return listProjectIssues(
args.projectId.trim(),
limit,
normalizeConcreteWorkspaceId(args.workspaceId)
)
}
)
ipcMain.handle(
'linear:listCustomViews',
async (
_event,
args?: {
model?: LinearCustomViewModel
limit?: number
workspaceId?: LinearWorkspaceSelection
}
) => {
const limit = Math.min(Math.max(1, args?.limit ?? 20), 50)
return listCustomViews(
normalizeCustomViewModel(args?.model),
limit,
normalizeWorkspaceSelection(args?.workspaceId)
)
}
)
ipcMain.handle(
'linear:getCustomView',
async (
_event,
args: { viewId: string; model?: LinearCustomViewModel; workspaceId?: string }
) => {
if (typeof args?.viewId !== 'string' || !args.viewId.trim()) {
throw new Error('Custom view ID is required')
}
return getCustomView(
args.viewId.trim(),
normalizeCustomViewModel(args.model),
normalizeConcreteWorkspaceId(args.workspaceId)
)
}
)
ipcMain.handle(
'linear:listCustomViewIssues',
async (_event, args: { viewId: string; limit?: number; workspaceId?: string }) => {
if (typeof args?.viewId !== 'string' || !args.viewId.trim()) {
throw new Error('Custom view ID is required')
}
const limit = Math.min(Math.max(1, args?.limit ?? 20), 50)
return listCustomViewIssues(
args.viewId.trim(),
limit,
normalizeConcreteWorkspaceId(args.workspaceId)
)
}
)
ipcMain.handle(
'linear:listCustomViewProjects',
async (_event, args: { viewId: string; limit?: number; workspaceId?: string }) => {
if (typeof args?.viewId !== 'string' || !args.viewId.trim()) {
throw new Error('Custom view ID is required')
}
const limit = Math.min(Math.max(1, args?.limit ?? 20), 50)
return listCustomViewProjects(
args.viewId.trim(),
limit,
normalizeConcreteWorkspaceId(args.workspaceId)
)
}
)
ipcMain.handle(
'linear:teamStates',
async (_event, args: { teamId: string; workspaceId?: string }) => {

View File

@ -1,53 +1,951 @@
import type { Project, ProjectSearchResult } from '@linear/sdk'
import type { LinearProjectSummary, LinearWorkspaceSelection } from '../../shared/types'
import { acquire, clearToken, getClients, isAuthError, release } from './client'
/* eslint-disable max-lines -- Why: Linear project and custom-view reads share
raw GraphQL selection sets, workspace fan-out, and partial-failure mapping. */
import type {
LinearCollectionResult,
LinearConcreteWorkspaceId,
LinearCustomViewModel,
LinearCustomViewSummary,
LinearIssue,
LinearProjectDetail,
LinearProjectMemberSummary,
LinearProjectSummary,
LinearWorkspaceError,
LinearWorkspaceSelection
} from '../../shared/types'
import {
acquire,
clearToken,
getClients,
isAuthError,
release,
type LinearClientForWorkspace
} from './client'
function mapLinearProject(project: Project | ProjectSearchResult): LinearProjectSummary {
type LinearRawVariables = Record<string, unknown>
type PageInfoNode = {
hasNextPage?: boolean | null
}
type LinearConnection<T> = {
nodes?: T[] | null
pageInfo?: PageInfoNode | null
}
type LinearUserNode = {
id: string
displayName?: string | null
avatarUrl?: string | null
}
type LinearProjectNode = {
id: string
name: string
description?: string | null
content?: string | null
url?: string | null
color?: string | null
icon?: string | null
health?: string | null
priority?: number | null
priorityLabel?: string | null
progress?: number | null
scope?: number | null
issueCountHistory?: number[] | null
completedIssueCountHistory?: number[] | null
startDate?: string | null
targetDate?: string | null
createdAt?: string | null
updatedAt?: string | null
completedAt?: string | null
canceledAt?: string | null
startedAt?: string | null
status?: {
id: string
name?: string | null
type?: string | null
color?: string | null
} | null
lead?: LinearUserNode | null
members?: LinearConnection<LinearUserNode> | null
teams?: LinearConnection<{ id: string; name?: string | null; key?: string | null }> | null
labels?: LinearConnection<{ id: string; name?: string | null; color?: string | null }> | null
projectMilestones?: LinearConnection<{
id: string
name?: string | null
status?: string | null
targetDate?: string | null
progress?: number | null
}> | null
externalLinks?: LinearConnection<{
id: string
label?: string | null
url?: string | null
}> | null
lastUpdate?: {
id: string
body?: string | null
health?: string | null
url?: string | null
createdAt?: string | null
updatedAt?: string | null
user?: LinearUserNode | null
} | null
}
type LinearIssueNode = {
id: string
identifier: string
title: string
description?: string | null
url: string
estimate?: number | null
priority: number
updatedAt: string
labelIds?: string[] | null
state?: {
name?: string | null
type?: string | null
color?: string | null
} | null
team?: {
id?: string | null
name?: string | null
key?: string | null
} | null
assignee?: LinearUserNode | null
labels?: LinearConnection<{ id: string; name: string }> | null
}
type LinearCustomViewNode = {
id: string
name: string
description?: string | null
modelName?: string | null
color?: string | null
icon?: string | null
shared?: boolean | null
slugId?: string | null
createdAt?: string | null
updatedAt?: string | null
team?: { id: string; name?: string | null; key?: string | null } | null
owner?: LinearUserNode | null
creator?: LinearUserNode | null
}
type ProjectConnectionResponse = {
projects?: LinearConnection<LinearProjectNode> | null
searchProjects?: LinearConnection<LinearProjectNode> | null
project?: LinearProjectNode | null
}
type ProjectIssueConnectionResponse = {
project?: {
issues?: LinearConnection<LinearIssueNode> | null
} | null
}
type CustomViewConnectionResponse = {
customViews?: LinearConnection<LinearCustomViewNode> | null
customView?:
| (LinearCustomViewNode & {
issues?: LinearConnection<LinearIssueNode> | null
projects?: LinearConnection<LinearProjectNode> | null
})
| null
}
const ORCA_PROJECT_FIELDS = `
id
name
description
content
url
color
icon
health
priority
priorityLabel
progress
scope
issueCountHistory
completedIssueCountHistory
startDate
targetDate
createdAt
updatedAt
completedAt
canceledAt
startedAt
status {
id
name
type
color
}
lead {
id
displayName
avatarUrl
}
members(first: 10) {
nodes {
id
displayName
avatarUrl
}
}
teams(first: 10) {
nodes {
id
name
key
}
}
labels(first: 20) {
nodes {
id
name
color
}
}
`
const ORCA_PROJECT_DETAIL_FIELDS = `
${ORCA_PROJECT_FIELDS}
projectMilestones(first: 20) {
nodes {
id
name
status
targetDate
progress
}
}
externalLinks(first: 20) {
nodes {
id
label
url
}
}
lastUpdate {
id
body
health
url
createdAt
updatedAt
user {
id
displayName
avatarUrl
}
}
`
const ORCA_ISSUE_FIELDS = `
id
identifier
title
description
url
priority
estimate
updatedAt
labelIds
state {
name
type
color
}
team {
id
name
key
}
assignee {
id
displayName
avatarUrl
}
labels(first: 50) {
nodes {
id
name
}
}
`
const PROJECTS_QUERY = `
query OrcaLinearProjects($first: Int, $filter: ProjectFilter, $orderBy: PaginationOrderBy) {
projects(first: $first, filter: $filter, orderBy: $orderBy) {
nodes {
${ORCA_PROJECT_FIELDS}
}
pageInfo {
hasNextPage
}
}
}
`
const SEARCH_PROJECTS_QUERY = `
query OrcaLinearProjectSearch($term: String!, $first: Int) {
searchProjects(term: $term, first: $first) {
nodes {
${ORCA_PROJECT_FIELDS}
}
pageInfo {
hasNextPage
}
}
}
`
const PROJECT_QUERY = `
query OrcaLinearProject($id: String!) {
project(id: $id) {
${ORCA_PROJECT_DETAIL_FIELDS}
}
}
`
const PROJECT_ISSUES_QUERY = `
query OrcaLinearProjectIssues($id: String!, $first: Int, $orderBy: PaginationOrderBy) {
project(id: $id) {
issues(first: $first, orderBy: $orderBy) {
nodes {
${ORCA_ISSUE_FIELDS}
}
pageInfo {
hasNextPage
}
}
}
}
`
const CUSTOM_VIEWS_QUERY = `
query OrcaLinearCustomViews(
$first: Int,
$filter: CustomViewFilter,
$orderBy: PaginationOrderBy
) {
customViews(first: $first, filter: $filter, orderBy: $orderBy) {
nodes {
id
name
description
modelName
color
icon
shared
slugId
createdAt
updatedAt
team {
id
name
key
}
owner {
id
displayName
avatarUrl
}
creator {
id
displayName
avatarUrl
}
}
pageInfo {
hasNextPage
}
}
}
`
const CUSTOM_VIEW_QUERY = `
query OrcaLinearCustomView($id: String!) {
customView(id: $id) {
id
name
description
modelName
color
icon
shared
slugId
createdAt
updatedAt
team {
id
name
key
}
owner {
id
displayName
avatarUrl
}
creator {
id
displayName
avatarUrl
}
}
}
`
const CUSTOM_VIEW_ISSUES_QUERY = `
query OrcaLinearCustomViewIssues($id: String!, $first: Int, $orderBy: PaginationOrderBy) {
customView(id: $id) {
id
modelName
issues(first: $first, orderBy: $orderBy) {
nodes {
${ORCA_ISSUE_FIELDS}
}
pageInfo {
hasNextPage
}
}
}
}
`
const CUSTOM_VIEW_PROJECTS_QUERY = `
query OrcaLinearCustomViewProjects($id: String!, $first: Int, $orderBy: PaginationOrderBy) {
customView(id: $id) {
id
modelName
projects(first: $first, orderBy: $orderBy) {
nodes {
${ORCA_PROJECT_FIELDS}
}
pageInfo {
hasNextPage
}
}
}
}
`
const inFlight = new Map<string, Promise<unknown>>()
function clampLimit(limit = 20): number {
return Math.min(Math.max(1, Math.floor(limit)), 50)
}
function coalesce<T>(key: string, load: () => Promise<T>): Promise<T> {
const existing = inFlight.get(key) as Promise<T> | undefined
if (existing) {
return existing
}
const promise = load().finally(() => inFlight.delete(key))
inFlight.set(key, promise)
return promise
}
function normalizeConcreteWorkspaceId(workspaceId: unknown): LinearConcreteWorkspaceId {
if (typeof workspaceId !== 'string' || !workspaceId.trim() || workspaceId === 'all') {
throw new Error('Concrete Linear workspace ID is required')
}
return workspaceId.trim()
}
function workspaceError(entry: LinearClientForWorkspace, error: unknown): LinearWorkspaceError {
if (isAuthError(error)) {
return {
workspaceId: entry.workspace.id,
workspaceName: entry.workspace.organizationName,
type: 'auth',
message: 'Linear authentication expired for this workspace.'
}
}
const record = error as { name?: string; message?: string; status?: number; response?: unknown }
const message = record.message || 'Linear request failed.'
const status =
typeof record.status === 'number'
? record.status
: typeof (record.response as { status?: unknown } | undefined)?.status === 'number'
? ((record.response as { status: number }).status as number)
: undefined
const name = record.name ?? ''
if (status === 429 || /rate/i.test(name)) {
return {
workspaceId: entry.workspace.id,
workspaceName: entry.workspace.organizationName,
type: 'rate_limited',
message
}
}
if ((typeof status === 'number' && status >= 500) || /network/i.test(name)) {
return {
workspaceId: entry.workspace.id,
workspaceName: entry.workspace.organizationName,
type: 'network',
message
}
}
return {
workspaceId: entry.workspace.id,
workspaceName: entry.workspace.organizationName,
type: 'unknown',
message
}
}
function shouldFailWholeRequest(selection: LinearWorkspaceSelection | null | undefined): boolean {
return selection !== 'all'
}
function lastNumericValue(values?: number[] | null): number | undefined {
const last = values?.at(-1)
return typeof last === 'number' ? last : undefined
}
function mapUser(user?: LinearUserNode | null): LinearProjectMemberSummary | undefined {
if (!user?.id) {
return undefined
}
return {
id: user.id,
displayName: user.displayName ?? '',
avatarUrl: user.avatarUrl ?? undefined
}
}
function mapProjectForWorkspace(
entry: LinearClientForWorkspace,
project: LinearProjectNode
): LinearProjectSummary {
return {
id: project.id,
workspaceId: entry.workspace.id,
workspaceName: entry.workspace.organizationName,
name: project.name,
url: project.url ?? undefined,
color: project.color ?? undefined
color: project.color ?? undefined,
icon: project.icon ?? undefined,
description: project.description ?? undefined,
content: project.content ?? undefined,
status: project.status
? {
id: project.status.id,
name: project.status.name ?? '',
type: project.status.type ?? undefined,
color: project.status.color ?? undefined
}
: undefined,
health: project.health ?? null,
priority: project.priority ?? null,
priorityLabel: project.priorityLabel ?? null,
lead: mapUser(project.lead),
members: project.members?.nodes
?.map(mapUser)
.filter((user): user is LinearProjectMemberSummary => !!user),
teams: project.teams?.nodes?.map((team) => ({
id: team.id,
name: team.name ?? '',
key: team.key ?? undefined
})),
labels: project.labels?.nodes?.map((label) => ({
id: label.id,
name: label.name ?? '',
color: label.color ?? undefined
})),
startDate: project.startDate ?? null,
targetDate: project.targetDate ?? null,
createdAt: project.createdAt ?? undefined,
updatedAt: project.updatedAt ?? undefined,
completedAt: project.completedAt ?? null,
canceledAt: project.canceledAt ?? null,
startedAt: project.startedAt ?? null,
progress: project.progress ?? null,
scope: project.scope ?? null,
issueCount: lastNumericValue(project.issueCountHistory),
completedIssueCount: lastNumericValue(project.completedIssueCountHistory)
}
}
function mapProjectDetailForWorkspace(
entry: LinearClientForWorkspace,
project: LinearProjectNode
): LinearProjectDetail {
return {
...mapProjectForWorkspace(entry, project),
milestones: project.projectMilestones?.nodes?.map((milestone) => ({
id: milestone.id,
name: milestone.name ?? '',
status: milestone.status ?? undefined,
targetDate: milestone.targetDate ?? null,
progress: milestone.progress ?? null
})),
resources: project.externalLinks?.nodes
?.filter((link) => link.url)
.map((link) => ({
id: link.id,
title: link.label || link.url || 'Link',
url: link.url!,
type: 'link'
})),
latestUpdate: project.lastUpdate
? {
id: project.lastUpdate.id,
body: project.lastUpdate.body ?? undefined,
health: project.lastUpdate.health ?? null,
url: project.lastUpdate.url ?? undefined,
createdAt: project.lastUpdate.createdAt ?? undefined,
updatedAt: project.lastUpdate.updatedAt ?? undefined,
user: mapUser(project.lastUpdate.user)
}
: undefined
}
}
function mapIssueForWorkspace(
entry: LinearClientForWorkspace,
issue: LinearIssueNode
): LinearIssue {
const labelNodes = issue.labels?.nodes ?? []
return {
id: issue.id,
identifier: issue.identifier,
title: issue.title,
description: issue.description ?? undefined,
url: issue.url,
state: {
name: issue.state?.name ?? '',
type: issue.state?.type ?? '',
color: issue.state?.color ?? ''
},
team: {
id: issue.team?.id ?? '',
name: issue.team?.name ?? '',
key: issue.team?.key ?? ''
},
labels: labelNodes.map((label) => label.name),
labelIds: issue.labelIds ?? labelNodes.map((label) => label.id),
assignee: mapUser(issue.assignee),
estimate: issue.estimate ?? null,
priority: issue.priority,
updatedAt: issue.updatedAt,
workspaceId: entry.workspace.id,
workspaceName: entry.workspace.organizationName
}
}
function mapCustomViewModel(modelName?: string | null): LinearCustomViewModel | null {
const normalized = modelName?.toLowerCase()
if (normalized === 'issue') {
return 'issue'
}
if (normalized === 'project') {
return 'project'
}
return null
}
function mapCustomViewForWorkspace(
entry: LinearClientForWorkspace,
view: LinearCustomViewNode
): LinearCustomViewSummary | null {
const model = mapCustomViewModel(view.modelName)
if (!model) {
return null
}
return {
id: view.id,
workspaceId: entry.workspace.id,
workspaceName: entry.workspace.organizationName,
name: view.name,
description: view.description ?? undefined,
model,
url: view.slugId
? `https://linear.app/${entry.workspace.organizationUrlKey}/view/${view.slugId}`
: undefined,
color: view.color ?? undefined,
icon: view.icon ?? undefined,
shared: view.shared ?? undefined,
team: view.team
? {
id: view.team.id,
name: view.team.name ?? undefined,
key: view.team.key ?? undefined
}
: undefined,
owner: mapUser(view.owner),
creator: mapUser(view.creator),
createdAt: view.createdAt ?? undefined,
updatedAt: view.updatedAt ?? undefined
}
}
async function readCollection<T>(
key: string,
workspaceId: LinearWorkspaceSelection | null | undefined,
load: (entry: LinearClientForWorkspace) => Promise<LinearCollectionResult<T>>
): Promise<LinearCollectionResult<T>> {
return coalesce(key, async () => {
const entries = getClients(workspaceId)
if (entries.length === 0) {
return { items: [] }
}
const results = await Promise.all(
entries.map(async (entry) => {
await acquire()
try {
return await load(entry)
} catch (error) {
if (isAuthError(error)) {
clearToken(entry.workspace.id)
} else {
console.warn('[linear] project/view read failed:', error)
}
if (shouldFailWholeRequest(workspaceId)) {
throw error
}
return { items: [], errors: [workspaceError(entry, error)] }
} finally {
release()
}
})
)
return {
items: results.flatMap((result) => result.items),
errors: results.flatMap((result) => result.errors ?? []).length
? results.flatMap((result) => result.errors ?? [])
: undefined,
hasMore: results.some((result) => result.hasMore)
}
})
}
async function readConcreteCollection<T>(
key: string,
workspaceId: LinearConcreteWorkspaceId,
load: (entry: LinearClientForWorkspace) => Promise<LinearCollectionResult<T>>
): Promise<LinearCollectionResult<T>> {
const concreteWorkspaceId = normalizeConcreteWorkspaceId(workspaceId)
return readCollection(key, concreteWorkspaceId, load)
}
export async function listProjects(
query: string | undefined,
limit = 20,
workspaceId?: LinearWorkspaceSelection | null
): Promise<LinearProjectSummary[]> {
): Promise<LinearCollectionResult<LinearProjectSummary>> {
const first = clampLimit(limit)
const trimmed = query?.trim()
const entries = getClients(workspaceId)
if (entries.length === 0) {
return []
}
const results = await Promise.all(
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) {
if (isAuthError(error)) {
clearToken(entry.workspace.id)
if (workspaceId !== 'all') {
throw error
}
} else {
console.warn('[linear] listProjects failed:', error)
}
return []
} finally {
release()
}
})
)
return results.flat().slice(0, limit)
const key = `listProjects:${workspaceId ?? 'default'}:${trimmed ?? ''}:${first}`
return readCollection(key, workspaceId, async (entry) => {
const variables = trimmed ? { term: trimmed, first } : { first, orderBy: 'updatedAt' }
const result = await entry.client.client.rawRequest<
ProjectConnectionResponse,
LinearRawVariables
>(trimmed ? SEARCH_PROJECTS_QUERY : PROJECTS_QUERY, variables)
const connection = trimmed ? result.data?.searchProjects : result.data?.projects
return {
items: (connection?.nodes ?? []).map((project) => mapProjectForWorkspace(entry, project)),
hasMore: !!connection?.pageInfo?.hasNextPage
}
})
}
export async function getProject(
id: string,
workspaceId: LinearConcreteWorkspaceId
): Promise<LinearProjectDetail | null> {
const projectId = id.trim()
const concreteWorkspaceId = normalizeConcreteWorkspaceId(workspaceId)
if (!projectId) {
throw new Error('Project ID is required')
}
const key = `getProject:${concreteWorkspaceId}:${projectId}`
return coalesce(key, async () => {
const entries = getClients(concreteWorkspaceId)
const entry = entries[0]
if (!entry) {
return null
}
await acquire()
try {
const result = await entry.client.client.rawRequest<
ProjectConnectionResponse,
LinearRawVariables
>(PROJECT_QUERY, { id: projectId })
return result.data?.project ? mapProjectDetailForWorkspace(entry, result.data.project) : null
} catch (error) {
if (isAuthError(error)) {
clearToken(entry.workspace.id)
}
throw error
} finally {
release()
}
})
}
export async function listProjectIssues(
projectId: string,
limit = 20,
workspaceId: LinearConcreteWorkspaceId
): Promise<LinearCollectionResult<LinearIssue>> {
const id = projectId.trim()
if (!id) {
throw new Error('Project ID is required')
}
const first = clampLimit(limit)
const concreteWorkspaceId = normalizeConcreteWorkspaceId(workspaceId)
return readConcreteCollection(
`listProjectIssues:${concreteWorkspaceId}:${id}:${first}`,
concreteWorkspaceId,
async (entry) => {
const result = await entry.client.client.rawRequest<
ProjectIssueConnectionResponse,
LinearRawVariables
>(PROJECT_ISSUES_QUERY, { id, first, orderBy: 'updatedAt' })
const project = result.data?.project
if (!project) {
throw new Error('Project was not found')
}
const connection = project.issues
return {
items: (connection?.nodes ?? []).map((issue) => mapIssueForWorkspace(entry, issue)),
hasMore: !!connection?.pageInfo?.hasNextPage
}
}
)
}
export async function listCustomViews(
model: LinearCustomViewModel,
limit = 20,
workspaceId?: LinearWorkspaceSelection | null
): Promise<LinearCollectionResult<LinearCustomViewSummary>> {
const first = clampLimit(limit)
const key = `listCustomViews:${workspaceId ?? 'default'}:${model}:${first}`
const filter = { modelName: { eq: model === 'project' ? 'Project' : 'Issue' } }
return readCollection(key, workspaceId, async (entry) => {
const result = await entry.client.client.rawRequest<
CustomViewConnectionResponse,
LinearRawVariables
>(CUSTOM_VIEWS_QUERY, { first, filter, orderBy: 'updatedAt' })
const connection = result.data?.customViews
return {
items: (connection?.nodes ?? [])
.map((view) => mapCustomViewForWorkspace(entry, view))
.filter((view): view is LinearCustomViewSummary => !!view && view.model === model),
hasMore: !!connection?.pageInfo?.hasNextPage
}
})
}
export async function getCustomView(
viewId: string,
model: LinearCustomViewModel,
workspaceId: LinearConcreteWorkspaceId
): Promise<LinearCustomViewSummary | null> {
const id = viewId.trim()
const concreteWorkspaceId = normalizeConcreteWorkspaceId(workspaceId)
if (!id) {
throw new Error('Custom view ID is required')
}
const key = `getCustomView:${concreteWorkspaceId}:${model}:${id}`
return coalesce(key, async () => {
const entries = getClients(concreteWorkspaceId)
const entry = entries[0]
if (!entry) {
return null
}
await acquire()
try {
const result = await entry.client.client.rawRequest<
CustomViewConnectionResponse,
LinearRawVariables
>(CUSTOM_VIEW_QUERY, { id })
const view = result.data?.customView
const mapped = view ? mapCustomViewForWorkspace(entry, view) : null
return mapped?.model === model ? mapped : null
} catch (error) {
if (isAuthError(error)) {
clearToken(entry.workspace.id)
}
throw error
} finally {
release()
}
})
}
export async function listCustomViewIssues(
viewId: string,
limit = 20,
workspaceId: LinearConcreteWorkspaceId
): Promise<LinearCollectionResult<LinearIssue>> {
const id = viewId.trim()
if (!id) {
throw new Error('Custom view ID is required')
}
const first = clampLimit(limit)
const concreteWorkspaceId = normalizeConcreteWorkspaceId(workspaceId)
return readConcreteCollection(
`listCustomViewIssues:${concreteWorkspaceId}:${id}:${first}`,
concreteWorkspaceId,
async (entry) => {
const result = await entry.client.client.rawRequest<
CustomViewConnectionResponse,
LinearRawVariables
>(CUSTOM_VIEW_ISSUES_QUERY, { id, first, orderBy: 'updatedAt' })
const view = result.data?.customView
if (mapCustomViewModel(view?.modelName) !== 'issue') {
throw new Error('Custom view does not contain issues')
}
const connection = view?.issues
return {
items: (connection?.nodes ?? []).map((issue) => mapIssueForWorkspace(entry, issue)),
hasMore: !!connection?.pageInfo?.hasNextPage
}
}
)
}
export async function listCustomViewProjects(
viewId: string,
limit = 20,
workspaceId: LinearConcreteWorkspaceId
): Promise<LinearCollectionResult<LinearProjectSummary>> {
const id = viewId.trim()
if (!id) {
throw new Error('Custom view ID is required')
}
const first = clampLimit(limit)
const concreteWorkspaceId = normalizeConcreteWorkspaceId(workspaceId)
return readConcreteCollection(
`listCustomViewProjects:${concreteWorkspaceId}:${id}:${first}`,
concreteWorkspaceId,
async (entry) => {
const result = await entry.client.client.rawRequest<
CustomViewConnectionResponse,
LinearRawVariables
>(CUSTOM_VIEW_PROJECTS_QUERY, { id, first, orderBy: 'updatedAt' })
const view = result.data?.customView
if (mapCustomViewModel(view?.modelName) !== 'project') {
throw new Error('Custom view does not contain projects')
}
const connection = view?.projects
return {
items: (connection?.nodes ?? []).map((project) => mapProjectForWorkspace(entry, project)),
hasMore: !!connection?.pageInfo?.hasNextPage
}
}
)
}

View File

@ -49,6 +49,7 @@ import type {
WorktreeBaseStatusEvent,
WorktreeRemoteBranchConflictEvent,
WorktreeStartupLaunch,
LinearCustomViewModel,
LinearIssueUpdate,
LinearWorkspaceSelection,
NestedRepoScanResult,
@ -253,7 +254,15 @@ import {
updateIssue as updateLinearIssue,
type LinearListFilter
} from '../linear/issues'
import { listProjects as listLinearProjects } from '../linear/projects'
import {
getCustomView as getLinearCustomView,
getProject as getLinearProject,
listCustomViewIssues as listLinearCustomViewIssues,
listCustomViewProjects as listLinearCustomViewProjects,
listCustomViews as listLinearCustomViews,
listProjectIssues as listLinearProjectIssues,
listProjects as listLinearProjects
} from '../linear/projects'
import {
getTeamLabels as getLinearTeamLabels,
getTeamMembers as getLinearTeamMembers,
@ -12149,6 +12158,50 @@ export class OrcaRuntimeService {
return listLinearProjects(query, Math.min(Math.max(1, limit), 50), workspaceId)
}
linearGetProject(id: string, workspaceId: string): ReturnType<typeof getLinearProject> {
return getLinearProject(id, workspaceId)
}
linearListProjectIssues(
projectId: string,
limit = 20,
workspaceId: string
): ReturnType<typeof listLinearProjectIssues> {
return listLinearProjectIssues(projectId, Math.min(Math.max(1, limit), 50), workspaceId)
}
linearListCustomViews(
model: LinearCustomViewModel,
limit = 20,
workspaceId?: LinearWorkspaceSelection
): ReturnType<typeof listLinearCustomViews> {
return listLinearCustomViews(model, Math.min(Math.max(1, limit), 50), workspaceId)
}
linearGetCustomView(
viewId: string,
model: LinearCustomViewModel,
workspaceId: string
): ReturnType<typeof getLinearCustomView> {
return getLinearCustomView(viewId, model, workspaceId)
}
linearListCustomViewIssues(
viewId: string,
limit = 20,
workspaceId: string
): ReturnType<typeof listLinearCustomViewIssues> {
return listLinearCustomViewIssues(viewId, Math.min(Math.max(1, limit), 50), workspaceId)
}
linearListCustomViewProjects(
viewId: string,
limit = 20,
workspaceId: string
): ReturnType<typeof listLinearCustomViewProjects> {
return listLinearCustomViewProjects(viewId, Math.min(Math.max(1, limit), 50), workspaceId)
}
linearTeamStates(teamId: string, workspaceId?: string): ReturnType<typeof getLinearTeamStates> {
return getLinearTeamStates(teamId, workspaceId)
}

View File

@ -155,8 +155,14 @@ describe('linear RPC methods', () => {
it('routes Linear metadata requests to the runtime server', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
linearGetCustomView: vi.fn().mockResolvedValue({ id: 'view-1' }),
linearGetProject: vi.fn().mockResolvedValue({ id: 'project-1' }),
linearListCustomViewIssues: vi.fn().mockResolvedValue({ items: [{ id: 'issue-1' }] }),
linearListCustomViewProjects: vi.fn().mockResolvedValue({ items: [{ id: 'project-2' }] }),
linearListCustomViews: vi.fn().mockResolvedValue({ items: [{ id: 'view-1' }] }),
linearListProjectIssues: vi.fn().mockResolvedValue({ items: [{ id: 'issue-2' }] }),
linearListTeams: vi.fn().mockResolvedValue([{ id: 'team-1' }]),
linearListProjects: vi.fn().mockResolvedValue([{ id: 'project-1' }]),
linearListProjects: vi.fn().mockResolvedValue({ items: [{ id: 'project-1' }] }),
linearTeamStates: vi.fn().mockResolvedValue([{ id: 'state-1' }]),
linearTeamLabels: vi.fn().mockResolvedValue([{ id: 'label-1' }]),
linearTeamMembers: vi.fn().mockResolvedValue([{ id: 'member-1' }])
@ -165,6 +171,44 @@ describe('linear RPC methods', () => {
await dispatcher.dispatch(makeRequest('linear.listTeams', { workspaceId: 'all' }))
await dispatcher.dispatch(makeRequest('linear.listProjects', { query: 'roadmap', limit: 5 }))
await dispatcher.dispatch(
makeRequest('linear.getProject', { id: 'project-1', workspaceId: 'workspace-1' })
)
await dispatcher.dispatch(
makeRequest('linear.listProjectIssues', {
projectId: 'project-1',
limit: 10,
workspaceId: 'workspace-1'
})
)
await dispatcher.dispatch(
makeRequest('linear.listCustomViews', {
model: 'project',
limit: 10,
workspaceId: 'workspace-1'
})
)
await dispatcher.dispatch(
makeRequest('linear.getCustomView', {
viewId: 'view-1',
model: 'project',
workspaceId: 'workspace-1'
})
)
await dispatcher.dispatch(
makeRequest('linear.listCustomViewIssues', {
viewId: 'view-1',
limit: 10,
workspaceId: 'workspace-1'
})
)
await dispatcher.dispatch(
makeRequest('linear.listCustomViewProjects', {
viewId: 'view-2',
limit: 10,
workspaceId: 'workspace-1'
})
)
await dispatcher.dispatch(
makeRequest('linear.teamStates', { teamId: 'team-1', workspaceId: 'workspace-1' })
)
@ -177,6 +221,12 @@ describe('linear RPC methods', () => {
expect(runtime.linearListTeams).toHaveBeenCalledWith('all')
expect(runtime.linearListProjects).toHaveBeenCalledWith('roadmap', 5, undefined)
expect(runtime.linearGetProject).toHaveBeenCalledWith('project-1', 'workspace-1')
expect(runtime.linearListProjectIssues).toHaveBeenCalledWith('project-1', 10, 'workspace-1')
expect(runtime.linearListCustomViews).toHaveBeenCalledWith('project', 10, 'workspace-1')
expect(runtime.linearGetCustomView).toHaveBeenCalledWith('view-1', 'project', 'workspace-1')
expect(runtime.linearListCustomViewIssues).toHaveBeenCalledWith('view-1', 10, 'workspace-1')
expect(runtime.linearListCustomViewProjects).toHaveBeenCalledWith('view-2', 10, 'workspace-1')
expect(runtime.linearTeamStates).toHaveBeenCalledWith('team-1', 'workspace-1')
expect(runtime.linearTeamLabels).toHaveBeenCalledWith('team-1', 'workspace-1')
expect(runtime.linearTeamMembers).toHaveBeenCalledWith('team-1', 'workspace-1')

View File

@ -3,6 +3,7 @@ import { defineMethod, type RpcMethod } from '../core'
import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas'
const VALID_FILTERS = ['assigned', 'created', 'all', 'completed'] as const
const VALID_CUSTOM_VIEW_MODELS = ['issue', 'project'] as const
const LinearPriority = z.number().int().min(0).max(4).optional()
const LinearLabelIds = z.array(requiredString('Invalid label ID')).optional()
@ -16,6 +17,11 @@ const WorkspaceSelection = z
})
.optional()
const ConcreteWorkspaceId = requiredString('Concrete Linear workspace ID is required').refine(
(value) => value !== 'all',
'Concrete Linear workspace ID is required'
)
const SelectWorkspace = z.object({
workspaceId: requiredString('Workspace ID is required')
})
@ -66,6 +72,35 @@ const ListProjects = z
})
.optional()
const ProjectId = z.object({
id: requiredString('Project ID is required'),
workspaceId: ConcreteWorkspaceId
})
const ProjectIssues = z.object({
projectId: requiredString('Project ID is required'),
limit: OptionalFiniteNumber,
workspaceId: ConcreteWorkspaceId
})
const ListCustomViews = z.object({
model: z.enum(VALID_CUSTOM_VIEW_MODELS),
limit: OptionalFiniteNumber,
workspaceId: OptionalString
})
const CustomViewId = z.object({
viewId: requiredString('Custom view ID is required'),
model: z.enum(VALID_CUSTOM_VIEW_MODELS),
workspaceId: ConcreteWorkspaceId
})
const CustomViewContents = z.object({
viewId: requiredString('Custom view ID is required'),
limit: OptionalFiniteNumber,
workspaceId: ConcreteWorkspaceId
})
const TeamId = z.object({
teamId: requiredString('Team ID is required'),
workspaceId: OptionalString
@ -181,6 +216,54 @@ export const LINEAR_METHODS: RpcMethod[] = [
handler: async (params, { runtime }) =>
runtime.linearListProjects(params?.query, params?.limit, params?.workspaceId)
}),
defineMethod({
name: 'linear.getProject',
params: ProjectId,
handler: async (params, { runtime }) =>
runtime.linearGetProject(params.id.trim(), params.workspaceId.trim())
}),
defineMethod({
name: 'linear.listProjectIssues',
params: ProjectIssues,
handler: async (params, { runtime }) =>
runtime.linearListProjectIssues(
params.projectId.trim(),
params.limit,
params.workspaceId.trim()
)
}),
defineMethod({
name: 'linear.listCustomViews',
params: ListCustomViews,
handler: async (params, { runtime }) =>
runtime.linearListCustomViews(params.model, params.limit, params.workspaceId)
}),
defineMethod({
name: 'linear.getCustomView',
params: CustomViewId,
handler: async (params, { runtime }) =>
runtime.linearGetCustomView(params.viewId.trim(), params.model, params.workspaceId.trim())
}),
defineMethod({
name: 'linear.listCustomViewIssues',
params: CustomViewContents,
handler: async (params, { runtime }) =>
runtime.linearListCustomViewIssues(
params.viewId.trim(),
params.limit,
params.workspaceId.trim()
)
}),
defineMethod({
name: 'linear.listCustomViewProjects',
params: CustomViewContents,
handler: async (params, { runtime }) =>
runtime.linearListCustomViewProjects(
params.viewId.trim(),
params.limit,
params.workspaceId.trim()
)
}),
defineMethod({
name: 'linear.teamStates',
params: TeamId,

View File

@ -218,12 +218,18 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
'host.pwsh.isAvailable',
'host.wsl.isAvailable',
'host.wsl.listDistros',
'linear.getCustomView',
'linear.getIssue',
'linear.getProject',
'linear.addIssueComment',
'linear.connect',
'linear.createIssue',
'linear.issueComments',
'linear.listCustomViewIssues',
'linear.listCustomViewProjects',
'linear.listCustomViews',
'linear.listIssues',
'linear.listProjectIssues',
'linear.listProjects',
'linear.teamLabels',
'linear.teamMembers',

View File

@ -61,7 +61,10 @@ import type {
ListWorkItemsResult,
IssueInfo,
LinearViewer,
LinearCollectionResult,
LinearConnectionStatus,
LinearCustomViewModel,
LinearCustomViewSummary,
LinearWorkspaceSelection,
LinearIssue,
LinearIssueUpdate,
@ -69,6 +72,7 @@ import type {
LinearWorkflowState,
LinearLabel,
LinearMember,
LinearProjectDetail,
LinearProjectSummary,
LinearTeam,
MarkdownDocument,
@ -1292,7 +1296,33 @@ export type PreloadApi = {
query?: string
limit?: number
workspaceId?: LinearWorkspaceSelection
}) => Promise<LinearProjectSummary[]>
}) => Promise<LinearCollectionResult<LinearProjectSummary>>
getProject: (args: { id: string; workspaceId: string }) => Promise<LinearProjectDetail | null>
listProjectIssues: (args: {
projectId: string
limit?: number
workspaceId: string
}) => Promise<LinearCollectionResult<LinearIssue>>
listCustomViews: (args: {
model: LinearCustomViewModel
limit?: number
workspaceId?: LinearWorkspaceSelection
}) => Promise<LinearCollectionResult<LinearCustomViewSummary>>
getCustomView: (args: {
viewId: string
model: LinearCustomViewModel
workspaceId: string
}) => Promise<LinearCustomViewSummary | null>
listCustomViewIssues: (args: {
viewId: string
limit?: number
workspaceId: string
}) => Promise<LinearCollectionResult<LinearIssue>>
listCustomViewProjects: (args: {
viewId: string
limit?: number
workspaceId: string
}) => Promise<LinearCollectionResult<LinearProjectSummary>>
teamStates: (args: { teamId: string; workspaceId?: string }) => Promise<LinearWorkflowState[]>
teamLabels: (args: { teamId: string; workspaceId?: string }) => Promise<LinearLabel[]>
teamMembers: (args: { teamId: string; workspaceId?: string }) => Promise<LinearMember[]>

View File

@ -1227,7 +1227,40 @@ const api = {
query?: string
limit?: number
workspaceId?: string
}): Promise<unknown[]> => ipcRenderer.invoke('linear:listProjects', args),
}): Promise<unknown> => ipcRenderer.invoke('linear:listProjects', args),
getProject: (args: { id: string; workspaceId: string }): Promise<unknown> =>
ipcRenderer.invoke('linear:getProject', args),
listProjectIssues: (args: {
projectId: string
limit?: number
workspaceId: string
}): Promise<unknown> => ipcRenderer.invoke('linear:listProjectIssues', args),
listCustomViews: (args: {
model: string
limit?: number
workspaceId?: string
}): Promise<unknown> => ipcRenderer.invoke('linear:listCustomViews', args),
getCustomView: (args: {
viewId: string
model: string
workspaceId: string
}): Promise<unknown> => ipcRenderer.invoke('linear:getCustomView', args),
listCustomViewIssues: (args: {
viewId: string
limit?: number
workspaceId: string
}): Promise<unknown> => ipcRenderer.invoke('linear:listCustomViewIssues', args),
listCustomViewProjects: (args: {
viewId: string
limit?: number
workspaceId: string
}): Promise<unknown> => ipcRenderer.invoke('linear:listCustomViewProjects', args),
teamStates: (args: { teamId: string; workspaceId?: string }): Promise<unknown[]> =>
ipcRenderer.invoke('linear:teamStates', args),

View File

@ -272,7 +272,7 @@ function LinearIssueSidebarProjectCard({
void linearListProjects(settings, query, 20, issue.workspaceId)
.then((result) => {
if (!cancelled) {
setProjects(result)
setProjects(result.items)
}
})
.catch((error) => {

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,712 @@
/* eslint-disable max-lines -- Why: project/view tables and project overview
share compact Linear metadata presentation rules for the Tasks Linear surface. */
import React from 'react'
import {
ArrowLeft,
ArrowRight,
CalendarDays,
ExternalLink,
FileText,
FolderKanban,
Layers3,
RefreshCw,
UserRound
} from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Progress } from '@/components/ui/progress'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import type {
LinearCustomViewModel,
LinearCustomViewSummary,
LinearProjectDetail,
LinearProjectSummary,
LinearWorkspaceError
} from '../../../shared/types'
type LinearProjectLike = LinearProjectSummary & {
content?: string
summary?: string
description?: string
status?: unknown
health?: unknown
lead?: unknown
members?: unknown[]
teams?: unknown[]
labels?: unknown[]
milestones?: unknown[]
resources?: unknown[]
latestUpdate?: unknown
lastUpdate?: unknown
}
type LinearProjectTableProps = {
projects: LinearProjectSummary[]
loading: boolean
hasError?: boolean
selectedProjectId?: string | null
workspaceSelection?: string | null
onSelectProject: (project: LinearProjectSummary) => void
onOpenProject: (project: LinearProjectSummary) => void
onUseProjectIssues?: (project: LinearProjectSummary) => void
}
type LinearCustomViewTableProps = {
views: LinearCustomViewSummary[]
model: LinearCustomViewModel
loading: boolean
hasError?: boolean
selectedViewId?: string | null
workspaceSelection?: string | null
onSelectView: (view: LinearCustomViewSummary) => void
onOpenView: (view: LinearCustomViewSummary) => void
}
type LinearProjectOverviewProps = {
project: LinearProjectDetail | LinearProjectSummary | null
loading: boolean
error?: string | null
onBack: () => void
onOpenProject: (project: LinearProjectSummary) => void
onRefresh: () => void
onOpenIssues?: () => void
}
type LinearCollectionNoticeProps = {
errors?: LinearWorkspaceError[]
hasMore?: boolean
count: number
label: string
}
function textFromUnknown(value: unknown): string | null {
if (typeof value === 'string' && value.trim()) {
return value.trim()
}
if (!value || typeof value !== 'object') {
return null
}
const record = value as Record<string, unknown>
for (const key of ['name', 'label', 'displayName', 'title', 'status', 'body']) {
const text = record[key]
if (typeof text === 'string' && text.trim()) {
return text.trim()
}
}
return null
}
function dateLabel(value: string | null | undefined): string {
if (!value) {
return 'None'
}
const date = new Date(value)
return Number.isNaN(date.getTime()) ? value : date.toLocaleDateString()
}
function priorityLabel(priority: unknown, fallback: unknown): string {
const fromFallback = textFromUnknown(fallback)
if (fromFallback) {
return fromFallback
}
if (typeof priority === 'number') {
return priority === 0 ? 'None' : `P${priority}`
}
return textFromUnknown(priority) ?? 'None'
}
function projectProgress(project: LinearProjectLike): number | null {
const progress = typeof project.progress === 'number' ? project.progress : null
if (progress === null || !Number.isFinite(progress)) {
return null
}
return progress <= 1 ? Math.round(progress * 100) : Math.round(progress)
}
function listLabels(values: unknown[] | undefined, limit: number): string[] {
if (!Array.isArray(values)) {
return []
}
return values
.map((value) => textFromUnknown(value))
.filter((value): value is string => Boolean(value))
.slice(0, limit)
}
function workspaceLabel(
workspaceSelection: string | null | undefined,
workspaceName?: string
): string | null {
return workspaceSelection === 'all' && workspaceName ? workspaceName : null
}
function ProjectColorMark({ project }: { project: LinearProjectSummary }): React.JSX.Element {
return (
<span
className="size-2.5 shrink-0 rounded-sm border border-border/50 bg-muted"
style={project.color ? { backgroundColor: project.color } : undefined}
aria-hidden
/>
)
}
function ProjectStatusBadge({ project }: { project: LinearProjectLike }): React.JSX.Element {
const label = textFromUnknown(project.status) ?? 'Backlog'
return (
<Badge variant="outline" className="max-w-full truncate text-[11px] font-medium">
{label}
</Badge>
)
}
export function LinearCollectionNotice({
errors,
hasMore,
count,
label
}: LinearCollectionNoticeProps): React.JSX.Element | null {
if (!hasMore && (!errors || errors.length === 0)) {
return null
}
return (
<div className="flex flex-none flex-col gap-2 border-t border-border/50 bg-muted/25 px-3 py-2 text-xs text-muted-foreground">
{errors && errors.length > 0 ? (
<div className="flex flex-wrap gap-2">
{errors.map((error) => (
<Badge key={`${error.workspaceId}-${error.type}`} variant="outline">
{error.workspaceName ?? error.workspaceId}: {error.message}
</Badge>
))}
</div>
) : null}
{hasMore ? (
<div>
Showing first {count} {label}. Search or open Linear for the full set.
</div>
) : null}
</div>
)
}
export function LinearProjectTable({
projects,
loading,
hasError,
selectedProjectId,
workspaceSelection,
onSelectProject,
onOpenProject,
onUseProjectIssues
}: LinearProjectTableProps): React.JSX.Element {
if (loading && projects.length === 0) {
return (
<div className="divide-y divide-border/50">
{Array.from({ length: 10 }).map((_, index) => (
<div
key={index}
className="grid gap-3 px-3 py-3 md:grid-cols-[minmax(180px,1.5fr)_110px_100px_90px_120px_110px_80px_70px]"
>
<div className="h-4 w-4/5 animate-pulse rounded bg-muted/70" />
<div className="h-4 w-20 animate-pulse rounded bg-muted/60" />
<div className="h-4 w-16 animate-pulse rounded bg-muted/60" />
<div className="h-4 w-16 animate-pulse rounded bg-muted/60" />
<div className="h-4 w-24 animate-pulse rounded bg-muted/60" />
<div className="h-4 w-20 animate-pulse rounded bg-muted/60" />
<div className="h-4 w-10 animate-pulse rounded bg-muted/60" />
<div />
</div>
))}
</div>
)
}
if (projects.length === 0) {
return (
<div className="px-4 py-10 text-center">
<p className="text-sm font-medium text-foreground">
{hasError ? 'Unable to load Linear projects' : 'No Linear projects found'}
</p>
<p className="mt-2 text-sm text-muted-foreground">
{hasError ? 'Review the workspace error below, then refresh.' : 'Try search or refresh.'}
</p>
</div>
)
}
return (
<div className="min-w-[820px] divide-y divide-border/50">
{projects.map((project) => {
const projectLike = project as LinearProjectLike
const selected = project.id === selectedProjectId
const labels = listLabels(projectLike.labels, 2)
const workspace = workspaceLabel(workspaceSelection, project.workspaceName)
const progress = projectProgress(projectLike)
return (
<div
key={`${project.workspaceId ?? 'workspace'}-${project.id}`}
role="button"
tabIndex={0}
aria-current={selected ? 'true' : undefined}
data-current={selected ? 'true' : undefined}
onClick={() => onSelectProject(project)}
onKeyDown={(event) => {
if (event.target !== event.currentTarget) {
return
}
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
onSelectProject(project)
}
}}
className={cn(
'group/row grid min-h-12 cursor-pointer grid-cols-[minmax(180px,1.5fr)_110px_100px_90px_120px_110px_80px_70px] items-center gap-3 px-3 py-2 text-left transition hover:bg-accent focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
selected && 'bg-accent'
)}
>
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<ProjectColorMark project={project} />
<span className="min-w-0 truncate text-[13px] font-medium text-foreground">
{project.name}
</span>
</div>
<div className="mt-1 flex min-w-0 items-center gap-1.5 text-[11px] text-muted-foreground">
{workspace ? <span className="truncate">{workspace}</span> : null}
{labels.map((label) => (
<Badge key={label} variant="outline" className="px-1.5 py-0 text-[10px]">
{label}
</Badge>
))}
</div>
</div>
<div className="min-w-0">
<ProjectStatusBadge project={projectLike} />
</div>
<span className="truncate text-[12px] text-muted-foreground">
{textFromUnknown(projectLike.health) ?? 'None'}
</span>
<span className="truncate text-[12px] text-muted-foreground">
{priorityLabel(projectLike.priority, projectLike.priorityLabel)}
</span>
<span className="truncate text-[12px] text-muted-foreground">
{textFromUnknown(projectLike.lead) ?? 'Unassigned'}
</span>
<span className="truncate text-[12px] text-muted-foreground">
{dateLabel(project.targetDate)}
</span>
<span className="text-[12px] text-muted-foreground">
{typeof project.issueCount === 'number'
? project.issueCount
: typeof project.scope === 'number'
? project.scope
: progress !== null
? `${progress}%`
: '-'}
</span>
<div className="flex items-center justify-end gap-1 md:opacity-0 md:transition-opacity md:group-hover/row:opacity-100 md:group-focus-within/row:opacity-100">
{onUseProjectIssues ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
onClick={(event) => {
event.stopPropagation()
onUseProjectIssues(project)
}}
aria-label={`Open ${project.name} issues`}
>
<ArrowRight className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
Issues
</TooltipContent>
</Tooltip>
) : null}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
onClick={(event) => {
event.stopPropagation()
onOpenProject(project)
}}
aria-label={`Open ${project.name} in Linear`}
>
<ExternalLink className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
Open in Linear
</TooltipContent>
</Tooltip>
</div>
</div>
)
})}
</div>
)
}
export function LinearCustomViewTable({
views,
model,
loading,
hasError,
selectedViewId,
workspaceSelection,
onSelectView,
onOpenView
}: LinearCustomViewTableProps): React.JSX.Element {
if (loading && views.length === 0) {
return (
<div className="divide-y divide-border/50">
{Array.from({ length: 8 }).map((_, index) => (
<div
key={index}
className="grid gap-3 px-3 py-3 md:grid-cols-[minmax(220px,1.5fr)_120px_120px_120px_130px_60px]"
>
<div className="h-4 w-4/5 animate-pulse rounded bg-muted/70" />
<div className="h-4 w-20 animate-pulse rounded bg-muted/60" />
<div className="h-4 w-20 animate-pulse rounded bg-muted/60" />
<div className="h-4 w-20 animate-pulse rounded bg-muted/60" />
<div className="h-4 w-24 animate-pulse rounded bg-muted/60" />
<div />
</div>
))}
</div>
)
}
if (views.length === 0) {
return (
<div className="px-4 py-10 text-center">
<p className="text-sm font-medium text-foreground">
{hasError ? `Unable to load ${model} views` : `No ${model} views found`}
</p>
<p className="mt-2 text-sm text-muted-foreground">
{hasError
? 'Review the workspace error below, then refresh.'
: 'Create or save views in Linear, then refresh.'}
</p>
</div>
)
}
return (
<div className="min-w-[680px] divide-y divide-border/50">
{views.map((view) => {
const selected = view.id === selectedViewId
const workspace = workspaceLabel(workspaceSelection, view.workspaceName)
return (
<div
key={`${view.workspaceId ?? 'workspace'}-${view.id}`}
role="button"
tabIndex={0}
aria-current={selected ? 'true' : undefined}
data-current={selected ? 'true' : undefined}
onClick={() => onSelectView(view)}
onKeyDown={(event) => {
if (event.target !== event.currentTarget) {
return
}
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
onSelectView(view)
}
}}
className={cn(
'group/row grid min-h-12 cursor-pointer grid-cols-[minmax(220px,1.5fr)_120px_120px_120px_130px_60px] items-center gap-3 px-3 py-2 text-left transition hover:bg-accent focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
selected && 'bg-accent'
)}
>
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<Layers3 className="size-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 truncate text-[13px] font-medium text-foreground">
{view.name}
</span>
</div>
{view.description || workspace ? (
<div className="mt-1 truncate text-[11px] text-muted-foreground">
{workspace ? `${workspace}${view.description ? ' · ' : ''}` : null}
{view.description}
</div>
) : null}
</div>
<Badge variant="outline" className="w-fit capitalize">
{view.model}
</Badge>
<span className="truncate text-[12px] text-muted-foreground">
{view.shared ? 'Shared' : 'Private'}
</span>
<span className="truncate text-[12px] text-muted-foreground">
{textFromUnknown(view.owner ?? view.creator) ?? 'Unknown'}
</span>
<span className="truncate text-[12px] text-muted-foreground">
{view.updatedAt ? dateLabel(view.updatedAt) : 'Unknown'}
</span>
<div className="flex justify-end md:opacity-0 md:transition-opacity md:group-hover/row:opacity-100 md:group-focus-within/row:opacity-100">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
onClick={(event) => {
event.stopPropagation()
onOpenView(view)
}}
aria-label={`Open ${view.name} in Linear`}
>
<ExternalLink className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
Open in Linear
</TooltipContent>
</Tooltip>
</div>
</div>
)
})}
</div>
)
}
export function LinearProjectOverview({
project,
loading,
error,
onBack,
onOpenProject,
onRefresh,
onOpenIssues
}: LinearProjectOverviewProps): React.JSX.Element {
const projectLike = project as LinearProjectLike | null
const progress = projectLike ? projectProgress(projectLike) : null
const teams = listLabels(projectLike?.teams, 4)
const labels = listLabels(projectLike?.labels, 4)
const members = listLabels(projectLike?.members, 4)
const milestones = listLabels(projectLike?.milestones, 4)
const resources = listLabels(projectLike?.resources, 4)
const latestUpdate = textFromUnknown(projectLike?.latestUpdate ?? projectLike?.lastUpdate)
const body = projectLike?.content || projectLike?.description || projectLike?.summary || ''
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex h-10 flex-none items-center justify-between gap-3 border-b border-border/50 bg-muted/35 px-3">
<div className="flex min-w-0 items-center gap-2">
<Button variant="ghost" size="icon-xs" onClick={onBack} aria-label="Back to projects">
<ArrowLeft className="size-3.5" />
</Button>
<div className="min-w-0">
<div className="truncate text-[13px] font-medium text-foreground">
{project?.name ?? 'Project'}
</div>
<div className="truncate text-[11px] text-muted-foreground">
{project?.workspaceName
? `Linear / Projects / ${project.workspaceName}`
: 'Linear / Projects'}
</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
{onOpenIssues ? (
<Button
variant="outline"
size="xs"
onClick={onOpenIssues}
className="gap-1 border-border/50 bg-background/70"
>
<Layers3 className="size-3.5" />
Issues
</Button>
) : null}
<Button
variant="outline"
size="xs"
onClick={onRefresh}
disabled={loading}
className="gap-1 border-border/50 bg-background/70"
>
<RefreshCw className={cn('size-3.5', loading && 'animate-spin')} />
Refresh
</Button>
{project ? (
<Button
variant="outline"
size="xs"
onClick={() => onOpenProject(project)}
className="gap-1 border-border/50 bg-background/70"
>
<ExternalLink className="size-3.5" />
Linear
</Button>
) : null}
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-4 scrollbar-sleek">
{error ? (
<div className="mb-3 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
) : null}
{loading && !project ? (
<div className="space-y-3">
<div className="h-5 w-1/3 animate-pulse rounded bg-muted/70" />
<div className="h-24 animate-pulse rounded-md bg-muted/50" />
<div className="h-40 animate-pulse rounded-md bg-muted/50" />
</div>
) : projectLike ? (
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_280px]">
<div className="min-w-0 space-y-4">
<section className="rounded-md border border-border/50 bg-muted/20 p-4">
<div className="flex min-w-0 items-center gap-2">
<ProjectColorMark project={projectLike} />
<h2 className="min-w-0 truncate text-base font-semibold text-foreground">
{projectLike.name}
</h2>
</div>
{body ? (
<p className="mt-3 whitespace-pre-wrap text-sm leading-6 text-muted-foreground">
{body}
</p>
) : (
<p className="mt-3 text-sm text-muted-foreground">No project description.</p>
)}
</section>
{progress !== null ? (
<section className="rounded-md border border-border/50 bg-muted/20 p-4">
<div className="mb-2 flex items-center justify-between text-sm">
<span className="font-medium text-foreground">Progress</span>
<span className="text-muted-foreground">{progress}%</span>
</div>
<Progress value={Math.max(0, Math.min(100, progress))} />
{typeof projectLike.scope === 'number' ? (
<div className="mt-2 text-xs text-muted-foreground">
{projectLike.scope} scoped issues
</div>
) : null}
</section>
) : null}
{milestones.length > 0 || resources.length > 0 || latestUpdate ? (
<section className="rounded-md border border-border/50 bg-muted/20 p-4">
<h3 className="text-sm font-medium text-foreground">Planning</h3>
<div className="mt-3 grid gap-3 md:grid-cols-3">
<MetadataList
icon={<FolderKanban className="size-3.5" />}
label="Milestones"
items={milestones}
/>
<MetadataList
icon={<FileText className="size-3.5" />}
label="Resources"
items={resources}
/>
<MetadataList
icon={<RefreshCw className="size-3.5" />}
label="Latest update"
items={latestUpdate ? [latestUpdate] : []}
/>
</div>
</section>
) : null}
</div>
<aside className="min-w-0 space-y-3">
<PropertyRow
label="Status"
value={textFromUnknown(projectLike.status) ?? 'Backlog'}
/>
<PropertyRow label="Health" value={textFromUnknown(projectLike.health) ?? 'None'} />
<PropertyRow
label="Priority"
value={priorityLabel(projectLike.priority, projectLike.priorityLabel)}
/>
<PropertyRow
label="Lead"
value={textFromUnknown(projectLike.lead) ?? 'Unassigned'}
icon={<UserRound className="size-3.5" />}
/>
<PropertyRow
label="Start"
value={dateLabel(projectLike.startDate)}
icon={<CalendarDays className="size-3.5" />}
/>
<PropertyRow
label="Target"
value={dateLabel(projectLike.targetDate)}
icon={<CalendarDays className="size-3.5" />}
/>
<MetadataList label="Teams" items={teams} />
<MetadataList label="Members" items={members} />
<MetadataList label="Labels" items={labels} />
</aside>
</div>
) : (
<div className="px-4 py-10 text-center text-sm text-muted-foreground">
Select a project to view its overview.
</div>
)}
</div>
</div>
)
}
function PropertyRow({
label,
value,
icon
}: {
label: string
value: string
icon?: React.ReactNode
}): React.JSX.Element {
return (
<div className="rounded-md border border-border/50 bg-muted/20 px-3 py-2">
<div className="flex items-center gap-1.5 text-[11px] uppercase tracking-[0.08em] text-muted-foreground">
{icon}
{label}
</div>
<div className="mt-1 truncate text-sm text-foreground">{value}</div>
</div>
)
}
function MetadataList({
icon,
label,
items
}: {
icon?: React.ReactNode
label: string
items: string[]
}): React.JSX.Element {
return (
<div className="rounded-md border border-border/50 bg-muted/20 px-3 py-2">
<div className="flex items-center gap-1.5 text-[11px] uppercase tracking-[0.08em] text-muted-foreground">
{icon}
{label}
</div>
{items.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-1.5">
{items.map((item) => (
<Badge key={item} variant="outline" className="max-w-full truncate">
{item}
</Badge>
))}
</div>
) : (
<div className="mt-1 text-sm text-muted-foreground">None</div>
)}
</div>
)
}

View File

@ -1,7 +1,15 @@
/* eslint-disable max-lines -- Why: runtime Linear routing cases stay together
so local preload fallback and SSH runtime transport parity are reviewed as one boundary. */
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
linearCreateIssue,
linearCreateSubIssue,
linearGetCustomView,
linearGetProject,
linearListCustomViewIssues,
linearListCustomViewProjects,
linearListCustomViews,
linearListProjectIssues,
linearListProjects,
linearListTeams,
linearSearchIssues,
@ -23,6 +31,12 @@ const linearCreateIssueLocal = vi.fn()
const linearUpdateIssueLocal = vi.fn()
const linearListTeamsLocal = vi.fn()
const linearListProjectsLocal = vi.fn()
const linearGetCustomViewLocal = vi.fn()
const linearGetProjectLocal = vi.fn()
const linearListProjectIssuesLocal = vi.fn()
const linearListCustomViewsLocal = vi.fn()
const linearListCustomViewIssuesLocal = vi.fn()
const linearListCustomViewProjectsLocal = vi.fn()
const linearSelectWorkspaceLocal = vi.fn()
beforeEach(() => {
@ -35,6 +49,12 @@ beforeEach(() => {
linearUpdateIssueLocal.mockReset()
linearListTeamsLocal.mockReset()
linearListProjectsLocal.mockReset()
linearGetCustomViewLocal.mockReset()
linearGetProjectLocal.mockReset()
linearListProjectIssuesLocal.mockReset()
linearListCustomViewsLocal.mockReset()
linearListCustomViewIssuesLocal.mockReset()
linearListCustomViewProjectsLocal.mockReset()
linearSelectWorkspaceLocal.mockReset()
runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => {
return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args)
@ -49,6 +69,12 @@ beforeEach(() => {
updateIssue: linearUpdateIssueLocal,
listTeams: linearListTeamsLocal,
listProjects: linearListProjectsLocal,
getCustomView: linearGetCustomViewLocal,
getProject: linearGetProjectLocal,
listProjectIssues: linearListProjectIssuesLocal,
listCustomViews: linearListCustomViewsLocal,
listCustomViewIssues: linearListCustomViewIssuesLocal,
listCustomViewProjects: linearListCustomViewProjectsLocal,
selectWorkspace: linearSelectWorkspaceLocal
}
}
@ -98,7 +124,7 @@ describe('runtime linear client', () => {
await expect(
linearListProjects({ activeRuntimeEnvironmentId: null }, 'roadmap', 10, 'workspace-1')
).resolves.toEqual([])
).resolves.toEqual({ items: [] })
})
it('routes Linear reads through the selected runtime environment', async () => {
@ -176,7 +202,7 @@ describe('runtime linear client', () => {
.mockResolvedValueOnce({
id: 'rpc-projects',
ok: true,
result: [{ id: 'project-1' }],
result: { items: [{ id: 'project-1' }] },
_meta: { runtimeId: 'runtime-1' }
})
.mockResolvedValueOnce({
@ -257,4 +283,119 @@ describe('runtime linear client', () => {
timeoutMs: 15_000
})
})
it('routes Linear project and custom-view reads through the selected runtime environment', async () => {
runtimeEnvironmentCall
.mockResolvedValueOnce({
id: 'rpc-project',
ok: true,
result: { id: 'project-1' },
_meta: { runtimeId: 'runtime-1' }
})
.mockResolvedValueOnce({
id: 'rpc-project-issues',
ok: true,
result: { items: [{ id: 'issue-1' }] },
_meta: { runtimeId: 'runtime-1' }
})
.mockResolvedValueOnce({
id: 'rpc-views',
ok: true,
result: { items: [{ id: 'view-1' }] },
_meta: { runtimeId: 'runtime-1' }
})
.mockResolvedValueOnce({
id: 'rpc-view',
ok: true,
result: { id: 'view-1' },
_meta: { runtimeId: 'runtime-1' }
})
.mockResolvedValueOnce({
id: 'rpc-view-issues',
ok: true,
result: { items: [{ id: 'issue-2' }] },
_meta: { runtimeId: 'runtime-1' }
})
.mockResolvedValueOnce({
id: 'rpc-view-projects',
ok: true,
result: { items: [{ id: 'project-2' }] },
_meta: { runtimeId: 'runtime-1' }
})
await linearGetProject({ activeRuntimeEnvironmentId: 'env-1' }, 'project-1', 'workspace-1')
await linearListProjectIssues(
{ activeRuntimeEnvironmentId: 'env-1' },
'project-1',
10,
'workspace-1'
)
await linearListCustomViews(
{ activeRuntimeEnvironmentId: 'env-1' },
'project',
10,
'workspace-1'
)
await linearGetCustomView(
{ activeRuntimeEnvironmentId: 'env-1' },
'view-1',
'project',
'workspace-1'
)
await linearListCustomViewIssues(
{ activeRuntimeEnvironmentId: 'env-1' },
'view-1',
10,
'workspace-1'
)
await linearListCustomViewProjects(
{ activeRuntimeEnvironmentId: 'env-1' },
'view-2',
10,
'workspace-1'
)
expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(1, {
selector: 'env-1',
method: 'linear.getProject',
params: { id: 'project-1', workspaceId: 'workspace-1' },
timeoutMs: 30_000
})
expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(2, {
selector: 'env-1',
method: 'linear.listProjectIssues',
params: { projectId: 'project-1', limit: 10, workspaceId: 'workspace-1' },
timeoutMs: 30_000
})
expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(3, {
selector: 'env-1',
method: 'linear.listCustomViews',
params: { model: 'project', limit: 10, workspaceId: 'workspace-1' },
timeoutMs: 30_000
})
expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(4, {
selector: 'env-1',
method: 'linear.getCustomView',
params: { viewId: 'view-1', model: 'project', workspaceId: 'workspace-1' },
timeoutMs: 30_000
})
expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(5, {
selector: 'env-1',
method: 'linear.listCustomViewIssues',
params: { viewId: 'view-1', limit: 10, workspaceId: 'workspace-1' },
timeoutMs: 30_000
})
expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(6, {
selector: 'env-1',
method: 'linear.listCustomViewProjects',
params: { viewId: 'view-2', limit: 10, workspaceId: 'workspace-1' },
timeoutMs: 30_000
})
expect(linearGetProjectLocal).not.toHaveBeenCalled()
expect(linearGetCustomViewLocal).not.toHaveBeenCalled()
expect(linearListProjectIssuesLocal).not.toHaveBeenCalled()
expect(linearListCustomViewsLocal).not.toHaveBeenCalled()
expect(linearListCustomViewIssuesLocal).not.toHaveBeenCalled()
expect(linearListCustomViewProjectsLocal).not.toHaveBeenCalled()
})
})

View File

@ -4,11 +4,15 @@
import type {
GlobalSettings,
LinearComment,
LinearCollectionResult,
LinearConnectionStatus,
LinearCustomViewModel,
LinearCustomViewSummary,
LinearIssue,
LinearIssueUpdate,
LinearLabel,
LinearMember,
LinearProjectDetail,
LinearProjectSummary,
LinearTeam,
LinearViewer,
@ -268,10 +272,10 @@ export async function linearListProjects(
query?: string,
limit?: number,
workspaceId?: LinearWorkspaceSelection | null
): Promise<LinearProjectSummary[]> {
): Promise<LinearCollectionResult<LinearProjectSummary>> {
const target = getActiveRuntimeTarget(settings)
return target.kind === 'environment'
? callRuntimeRpc<LinearProjectSummary[]>(
? callRuntimeRpc<LinearCollectionResult<LinearProjectSummary>>(
target,
'linear.listProjects',
{ query, limit, workspaceId: workspaceId ?? undefined },
@ -279,7 +283,108 @@ export async function linearListProjects(
)
: typeof window.api.linear.listProjects === 'function'
? window.api.linear.listProjects({ query, limit, workspaceId: workspaceId ?? undefined })
: []
: { items: [] }
}
export async function linearGetProject(
settings: RuntimeLinearSettings,
id: string,
workspaceId: string
): Promise<LinearProjectDetail | null> {
const target = getActiveRuntimeTarget(settings)
return target.kind === 'environment'
? callRuntimeRpc<LinearProjectDetail | null>(
target,
'linear.getProject',
{ id, workspaceId },
{ timeoutMs: 30_000 }
)
: window.api.linear.getProject({ id, workspaceId })
}
export async function linearListProjectIssues(
settings: RuntimeLinearSettings,
projectId: string,
limit: number | undefined,
workspaceId: string
): Promise<LinearCollectionResult<LinearIssue>> {
const target = getActiveRuntimeTarget(settings)
return target.kind === 'environment'
? callRuntimeRpc<LinearCollectionResult<LinearIssue>>(
target,
'linear.listProjectIssues',
{ projectId, limit, workspaceId },
{ timeoutMs: 30_000 }
)
: window.api.linear.listProjectIssues({ projectId, limit, workspaceId })
}
export async function linearListCustomViews(
settings: RuntimeLinearSettings,
model: LinearCustomViewModel,
limit?: number,
workspaceId?: LinearWorkspaceSelection | null
): Promise<LinearCollectionResult<LinearCustomViewSummary>> {
const target = getActiveRuntimeTarget(settings)
return target.kind === 'environment'
? callRuntimeRpc<LinearCollectionResult<LinearCustomViewSummary>>(
target,
'linear.listCustomViews',
{ model, limit, workspaceId: workspaceId ?? undefined },
{ timeoutMs: 30_000 }
)
: window.api.linear.listCustomViews({ model, limit, workspaceId: workspaceId ?? undefined })
}
export async function linearGetCustomView(
settings: RuntimeLinearSettings,
viewId: string,
model: LinearCustomViewModel,
workspaceId: string
): Promise<LinearCustomViewSummary | null> {
const target = getActiveRuntimeTarget(settings)
return target.kind === 'environment'
? callRuntimeRpc<LinearCustomViewSummary | null>(
target,
'linear.getCustomView',
{ viewId, model, workspaceId },
{ timeoutMs: 30_000 }
)
: window.api.linear.getCustomView({ viewId, model, workspaceId })
}
export async function linearListCustomViewIssues(
settings: RuntimeLinearSettings,
viewId: string,
limit: number | undefined,
workspaceId: string
): Promise<LinearCollectionResult<LinearIssue>> {
const target = getActiveRuntimeTarget(settings)
return target.kind === 'environment'
? callRuntimeRpc<LinearCollectionResult<LinearIssue>>(
target,
'linear.listCustomViewIssues',
{ viewId, limit, workspaceId },
{ timeoutMs: 30_000 }
)
: window.api.linear.listCustomViewIssues({ viewId, limit, workspaceId })
}
export async function linearListCustomViewProjects(
settings: RuntimeLinearSettings,
viewId: string,
limit: number | undefined,
workspaceId: string
): Promise<LinearCollectionResult<LinearProjectSummary>> {
const target = getActiveRuntimeTarget(settings)
return target.kind === 'environment'
? callRuntimeRpc<LinearCollectionResult<LinearProjectSummary>>(
target,
'linear.listCustomViewProjects',
{ viewId, limit, workspaceId },
{ timeoutMs: 30_000 }
)
: window.api.linear.listCustomViewProjects({ viewId, limit, workspaceId })
}
export async function linearTeamStates(

View File

@ -1,7 +1,16 @@
/* eslint-disable max-lines -- Why: Linear cache and fallback tests share one
mocked slice harness, keeping failure-mode coverage easy to compare. */
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { create } from 'zustand'
import type { AppState } from '../types'
import type { LinearConnectionStatus, LinearIssue, LinearViewer } from '../../../../shared/types'
import type {
LinearConnectionStatus,
LinearIssue,
LinearProjectDetail,
LinearProjectSummary,
LinearTeam,
LinearViewer
} from '../../../../shared/types'
import { createLinearSlice } from './linear'
const linearStatus = vi.fn()
@ -11,14 +20,28 @@ const linearListIssues = vi.fn()
const linearSearchIssues = vi.fn()
const linearListTeams = vi.fn()
const linearGetIssue = vi.fn()
const linearListProjects = vi.fn()
const linearGetCustomView = vi.fn()
const linearGetProject = vi.fn()
const linearListProjectIssues = vi.fn()
const linearListCustomViews = vi.fn()
const linearListCustomViewIssues = vi.fn()
const linearListCustomViewProjects = vi.fn()
const linearTestConnection = vi.fn()
vi.mock('@/runtime/runtime-linear-client', () => ({
linearConnect: (...args: unknown[]) => linearConnect(...args),
linearDisconnect: (...args: unknown[]) => linearDisconnect(...args),
linearDisconnectWorkspace: vi.fn(),
linearGetCustomView: (...args: unknown[]) => linearGetCustomView(...args),
linearGetProject: (...args: unknown[]) => linearGetProject(...args),
linearGetIssue: (...args: unknown[]) => linearGetIssue(...args),
linearListCustomViewIssues: (...args: unknown[]) => linearListCustomViewIssues(...args),
linearListCustomViewProjects: (...args: unknown[]) => linearListCustomViewProjects(...args),
linearListCustomViews: (...args: unknown[]) => linearListCustomViews(...args),
linearListIssues: (...args: unknown[]) => linearListIssues(...args),
linearListProjectIssues: (...args: unknown[]) => linearListProjectIssues(...args),
linearListProjects: (...args: unknown[]) => linearListProjects(...args),
linearListTeams: (...args: unknown[]) => linearListTeams(...args),
linearSearchIssues: (...args: unknown[]) => linearSearchIssues(...args),
linearSelectWorkspace: vi.fn(),
@ -55,6 +78,14 @@ function issue(id: string): LinearIssue {
}
}
function team(id: string): LinearTeam {
return { id, name: id, key: id, workspaceId: 'workspace-1', workspaceName: 'Workspace' }
}
function project(id: string): LinearProjectSummary {
return { id, name: id, workspaceId: 'workspace-1', workspaceName: 'Workspace' }
}
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((res) => {
@ -161,6 +192,195 @@ describe('createLinearSlice caching', () => {
).resolves.toMatchObject([{ id: 'LIN-CACHED' }])
})
it('surfaces scoped project issue failures alongside cached rows', async () => {
const store = createTestStore()
store.setState({
linearProjectIssueCache: {
'workspace-1::project-issues::project-1::20': {
data: { items: [issue('LIN-CACHED')] },
fetchedAt: 1
}
}
})
linearListProjectIssues.mockRejectedValueOnce(new Error('network down'))
await expect(
store.getState().listLinearProjectIssues('project-1', 'workspace-1', 20, { force: true })
).resolves.toMatchObject({
items: [{ id: 'LIN-CACHED' }],
errors: [{ workspaceId: 'workspace-1', type: 'unknown', message: 'network down' }]
})
})
it('surfaces scoped custom-view project failures alongside cached rows', async () => {
const store = createTestStore()
const rateLimitError = Object.assign(new Error('slow down'), { status: 429 })
store.setState({
linearCustomViewProjectCache: {
'workspace-1::custom-view-projects::view-1::20': {
data: { items: [project('project-cached')] },
fetchedAt: 1
}
}
})
linearListCustomViewProjects.mockRejectedValueOnce(rateLimitError)
await expect(
store.getState().listLinearCustomViewProjects('view-1', 'workspace-1', 20, {
force: true
})
).resolves.toMatchObject({
items: [{ id: 'project-cached' }],
errors: [{ workspaceId: 'workspace-1', type: 'rate_limited', message: 'slow down' }]
})
})
it('surfaces top-level project list failures alongside cached rows', async () => {
const store = createTestStore()
store.setState({
linearStatus: { connected: true, viewer: null, selectedWorkspaceId: 'workspace-1' },
linearProjectCache: {
'workspace-1::projects::::20': {
data: { items: [project('project-cached')] },
fetchedAt: 1
}
}
})
linearListProjects.mockRejectedValueOnce(new Error('network down'))
await expect(
store.getState().listLinearProjects(undefined, 20, undefined, { force: true })
).resolves.toMatchObject({
items: [{ id: 'project-cached' }],
errors: [{ workspaceId: 'workspace-1', type: 'unknown', message: 'network down' }]
})
})
it('surfaces top-level custom-view failures alongside cached rows', async () => {
const store = createTestStore()
store.setState({
linearStatus: { connected: true, viewer: null, selectedWorkspaceId: 'workspace-1' },
linearCustomViewCache: {
'workspace-1::custom-views::project::20': {
data: {
items: [{ id: 'view-cached', name: 'Cached view', model: 'project' }]
},
fetchedAt: 1
}
}
})
linearListCustomViews.mockRejectedValueOnce(new Error('network down'))
await expect(
store.getState().listLinearCustomViews('project', 20, undefined, { force: true })
).resolves.toMatchObject({
items: [{ id: 'view-cached' }],
errors: [{ workspaceId: 'workspace-1', type: 'unknown', message: 'network down' }]
})
})
it('fetches custom views by exact id for saved-context restore', async () => {
const store = createTestStore()
linearGetCustomView.mockResolvedValueOnce({
id: 'view-1',
name: 'Burn views',
model: 'project',
workspaceId: 'workspace-1'
})
await expect(
store.getState().fetchLinearCustomView('view-1', 'workspace-1', 'project', { force: true })
).resolves.toMatchObject({ id: 'view-1' })
expect(linearGetCustomView).toHaveBeenCalledWith(null, 'view-1', 'project', 'workspace-1')
})
it('fails forced exact custom-view validation instead of reopening stale cache', async () => {
const store = createTestStore()
store.setState({
linearCustomViewDetailCache: {
'workspace-1::custom-view-detail::project::view-1': {
data: {
id: 'view-1',
name: 'Stale view',
model: 'project',
workspaceId: 'workspace-1'
},
fetchedAt: 1
}
}
})
linearGetCustomView.mockRejectedValueOnce(new Error('network down'))
await expect(
store.getState().fetchLinearCustomView('view-1', 'workspace-1', 'project', { force: true })
).rejects.toThrow('network down')
})
it('prevents stale detail reads from overwriting forced refresh caches', async () => {
const store = createTestStore()
const staleProject = deferred<LinearProjectDetail | null>()
const freshProject = deferred<LinearProjectDetail | null>()
const staleView = deferred<{
id: string
name: string
model: 'project'
workspaceId: string
}>()
const freshView = deferred<{
id: string
name: string
model: 'project'
workspaceId: string
}>()
linearGetProject
.mockReturnValueOnce(staleProject.promise)
.mockReturnValueOnce(freshProject.promise)
linearGetCustomView
.mockReturnValueOnce(staleView.promise)
.mockReturnValueOnce(freshView.promise)
const staleProjectPromise = store.getState().fetchLinearProject('project-1', 'workspace-1')
const freshProjectPromise = store
.getState()
.fetchLinearProject('project-1', 'workspace-1', { force: true })
const staleViewPromise = store
.getState()
.fetchLinearCustomView('view-1', 'workspace-1', 'project')
const freshViewPromise = store
.getState()
.fetchLinearCustomView('view-1', 'workspace-1', 'project', { force: true })
freshProject.resolve({ ...project('project-1'), name: 'Fresh project' })
freshView.resolve({
id: 'view-1',
name: 'Fresh view',
model: 'project',
workspaceId: 'workspace-1'
})
await freshProjectPromise
await freshViewPromise
staleProject.resolve({ ...project('project-1'), name: 'Stale project' })
staleView.resolve({
id: 'view-1',
name: 'Stale view',
model: 'project',
workspaceId: 'workspace-1'
})
await staleProjectPromise
await staleViewPromise
expect(
store.getState().linearProjectDetailCache['workspace-1::project-detail::project-1'].data?.name
).toBe('Fresh project')
expect(
store.getState().linearCustomViewDetailCache[
'workspace-1::custom-view-detail::project::view-1'
].data?.name
).toBe('Fresh view')
})
it('preserves cached search rows when forced revalidation fails transiently', async () => {
const store = createTestStore()
store.setState({
@ -189,6 +409,78 @@ describe('createLinearSlice caching', () => {
store.getState().getCachedLinearIssues({ kind: 'list', filter: 'all', limit: 36 })
).toEqual([issue('LIN-1')])
})
it('keeps literal search queries separate from list cache keys', async () => {
const store = createTestStore()
store.setState({
linearStatus: { connected: true, viewer: null, selectedWorkspaceId: 'workspace-1' },
linearSearchCache: {
'workspace-1::list::all::36': { data: [issue('LIST')], fetchedAt: Date.now() }
}
})
linearSearchIssues.mockResolvedValueOnce([issue('SEARCH')])
await expect(store.getState().searchLinearIssues('list::all', 36)).resolves.toMatchObject([
{ id: 'SEARCH' }
])
expect(linearSearchIssues).toHaveBeenCalledTimes(1)
expect(
store.getState().getCachedLinearIssues({ kind: 'search', query: 'list::all', limit: 36 })
).toMatchObject([{ id: 'SEARCH' }])
expect(
store.getState().getCachedLinearIssues({ kind: 'list', filter: 'all', limit: 36 })
).toMatchObject([{ id: 'LIST' }])
})
it('caches teams by workspace and dedupes fresh reads', async () => {
const store = createTestStore()
linearListTeams.mockResolvedValueOnce([team('team-1')])
await expect(store.getState().listLinearTeams('workspace-1')).resolves.toMatchObject([
{ id: 'team-1' }
])
await expect(store.getState().listLinearTeams('workspace-1')).resolves.toMatchObject([
{ id: 'team-1' }
])
expect(linearListTeams).toHaveBeenCalledTimes(1)
expect(store.getState().getCachedLinearTeams('workspace-1')).toMatchObject([{ id: 'team-1' }])
})
it('patches issue-cache entries keyed by workspace-qualified ids', () => {
const store = createTestStore()
store.setState({
linearIssueCache: {
'workspace-1::issue-id': { data: issue('issue-id'), fetchedAt: Date.now() }
},
linearProjectIssueCache: {
'workspace-1::project-issues::project-1::20': {
data: { items: [issue('issue-id')] },
fetchedAt: Date.now()
}
},
linearCustomViewIssueCache: {
'workspace-1::custom-view-issues::view-1::20': {
data: { items: [issue('issue-id')] },
fetchedAt: Date.now()
}
}
})
store.getState().patchLinearIssue('issue-id', { title: 'Updated' })
expect(store.getState().linearIssueCache['workspace-1::issue-id'].data?.title).toBe('Updated')
expect(store.getState().linearIssueCache['workspace-1::issue-id'].fetchedAt).toBe(0)
expect(
store.getState().linearProjectIssueCache['workspace-1::project-issues::project-1::20'].data
?.items[0]?.title
).toBe('Updated')
expect(
store.getState().linearCustomViewIssueCache['workspace-1::custom-view-issues::view-1::20']
.data?.items[0]?.title
).toBe('Updated')
})
})
describe('createLinearSlice', () => {

View File

@ -6,9 +6,15 @@ import type { AppState } from '../types'
import type {
LinearViewer,
LinearConnectionStatus,
LinearCollectionResult,
LinearCustomViewModel,
LinearCustomViewSummary,
LinearIssue,
LinearProjectDetail,
LinearProjectSummary,
LinearTeam,
LinearWorkspace,
LinearWorkspaceError,
LinearWorkspaceSelection
} from '../../../../shared/types'
import type { CacheEntry } from './github'
@ -17,8 +23,15 @@ import {
linearConnect,
linearDisconnect,
linearDisconnectWorkspace,
linearGetCustomView,
linearGetProject,
linearGetIssue,
linearListCustomViewIssues,
linearListCustomViewProjects,
linearListCustomViews,
linearListIssues,
linearListProjectIssues,
linearListProjects,
linearListTeams,
linearSearchIssues,
linearSelectWorkspace,
@ -60,12 +73,46 @@ type InflightLinearIssueRequest = {
generation: number
}
function workspaceErrorType(error: unknown): LinearWorkspaceError['type'] {
const record = error as { name?: string; message?: string; status?: number; response?: unknown }
const message = record.message ?? String(error)
const status =
typeof record.status === 'number'
? record.status
: typeof (record.response as { status?: unknown } | undefined)?.status === 'number'
? ((record.response as { status: number }).status as number)
: undefined
if (looksLikeAuthError(error)) {
return 'auth'
}
if (status === 429 || /rate/i.test(record.name ?? '')) {
return 'rate_limited'
}
if ((typeof status === 'number' && status >= 500) || /network/i.test(record.name ?? message)) {
return 'network'
}
return 'unknown'
}
function workspaceErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
const inflightIssueRequests = new Map<string, InflightLinearIssueRequest>()
type InflightLinearListRequest = {
promise: Promise<LinearIssue[]>
force: boolean
generation: number
}
type InflightLinearCollectionRequest<T> = {
promise: Promise<LinearCollectionResult<T>>
force: boolean
generation: number
}
type InflightLinearDetailRequest<T> = {
promise: Promise<T>
force: boolean
}
const inflightSearchRequests = new Map<string, InflightLinearListRequest>()
const inflightListRequests = new Map<string, InflightLinearListRequest>()
@ -76,6 +123,31 @@ type InflightLinearTeamRequest = {
}
const inflightTeamRequests = new Map<string, InflightLinearTeamRequest>()
const inflightProjectRequests = new Map<
string,
InflightLinearCollectionRequest<LinearProjectSummary>
>()
const inflightProjectDetailRequests = new Map<
string,
InflightLinearDetailRequest<LinearProjectDetail | null>
>()
const inflightProjectIssueRequests = new Map<string, InflightLinearCollectionRequest<LinearIssue>>()
const inflightCustomViewRequests = new Map<
string,
InflightLinearCollectionRequest<LinearCustomViewSummary>
>()
const inflightCustomViewDetailRequests = new Map<
string,
InflightLinearDetailRequest<LinearCustomViewSummary | null>
>()
const inflightCustomViewIssueRequests = new Map<
string,
InflightLinearCollectionRequest<LinearIssue>
>()
const inflightCustomViewProjectRequests = new Map<
string,
InflightLinearCollectionRequest<LinearProjectSummary>
>()
let inflightStatusRequest: Promise<void> | null = null
let linearStatusReadGeneration = 0
let linearMutationGeneration = 0
@ -140,6 +212,13 @@ function clearLinearRequestMaps(): void {
inflightSearchRequests.clear()
inflightListRequests.clear()
inflightTeamRequests.clear()
inflightProjectRequests.clear()
inflightProjectDetailRequests.clear()
inflightProjectIssueRequests.clear()
inflightCustomViewRequests.clear()
inflightCustomViewDetailRequests.clear()
inflightCustomViewIssueRequests.clear()
inflightCustomViewProjectRequests.clear()
}
function invalidateLinearCaches(): void {
@ -154,6 +233,66 @@ function shouldRefreshStatusAfterRead(
return workspaceId === 'all'
}
function linearCollectionCacheKey(
workspaceId: LinearWorkspaceSelection | null | undefined,
mode: string,
...parts: (string | number | null | undefined)[]
): string {
return [workspaceId ?? 'default', mode, ...parts.map((part) => part ?? '')].join('::')
}
function emptyLinearCollection<T>(): LinearCollectionResult<T> {
return { items: [] }
}
function collectionWithWorkspaceError<T>(
fallback: LinearCollectionResult<T>,
workspaceId: string,
error: unknown
): LinearCollectionResult<T> {
const existingErrors = (fallback.errors ?? []).filter((item) => item.workspaceId !== workspaceId)
return {
...fallback,
errors: [
...existingErrors,
{
workspaceId,
type: workspaceErrorType(error),
message: workspaceErrorMessage(error) || 'Linear request failed.'
}
]
}
}
function patchLinearIssueCollectionCache(
cache: Record<string, CacheEntry<LinearCollectionResult<LinearIssue>>>,
issueId: string,
patch: Partial<LinearIssue>
): {
cache: Record<string, CacheEntry<LinearCollectionResult<LinearIssue>>>
changed: boolean
} {
let changed = false
const nextCache = { ...cache }
for (const [key, entry] of Object.entries(nextCache)) {
if (!entry?.data) {
continue
}
const idx = entry.data.items.findIndex((item) => item.id === issueId)
if (idx === -1) {
continue
}
const updatedItems = [...entry.data.items]
updatedItems[idx] = { ...updatedItems[idx], ...patch }
nextCache[key] = {
...entry,
data: { ...entry.data, items: updatedItems }
}
changed = true
}
return { cache: nextCache, changed }
}
type LinearIssueReadArgs =
| { kind: 'search'; query: string; limit?: number }
| { kind: 'list'; filter?: 'assigned' | 'created' | 'all' | 'completed'; limit?: number }
@ -176,6 +315,16 @@ export type LinearSlice = {
linearIssueCache: Record<string, CacheEntry<LinearIssue>>
linearSearchCache: Record<string, CacheEntry<LinearIssue[]>>
linearTeamCache: Record<string, CacheEntry<LinearTeam[]>>
linearProjectCache: Record<string, CacheEntry<LinearCollectionResult<LinearProjectSummary>>>
linearProjectDetailCache: Record<string, CacheEntry<LinearProjectDetail | null>>
linearProjectIssueCache: Record<string, CacheEntry<LinearCollectionResult<LinearIssue>>>
linearCustomViewCache: Record<string, CacheEntry<LinearCollectionResult<LinearCustomViewSummary>>>
linearCustomViewDetailCache: Record<string, CacheEntry<LinearCustomViewSummary | null>>
linearCustomViewIssueCache: Record<string, CacheEntry<LinearCollectionResult<LinearIssue>>>
linearCustomViewProjectCache: Record<
string,
CacheEntry<LinearCollectionResult<LinearProjectSummary>>
>
checkLinearConnection: (force?: boolean) => Promise<void>
connectLinear: (
@ -205,6 +354,57 @@ export type LinearSlice = {
workspaceId?: LinearWorkspaceSelection | null,
options?: LinearFetchOptions
) => Promise<LinearTeam[]>
getCachedLinearProjects: (
query?: string,
limit?: number,
workspaceId?: LinearWorkspaceSelection | null
) => LinearCollectionResult<LinearProjectSummary> | null
listLinearProjects: (
query?: string,
limit?: number,
workspaceId?: LinearWorkspaceSelection | null,
options?: LinearFetchOptions
) => Promise<LinearCollectionResult<LinearProjectSummary>>
fetchLinearProject: (
id: string,
workspaceId: string,
options?: LinearFetchOptions
) => Promise<LinearProjectDetail | null>
listLinearProjectIssues: (
projectId: string,
workspaceId: string,
limit?: number,
options?: LinearFetchOptions
) => Promise<LinearCollectionResult<LinearIssue>>
getCachedLinearCustomViews: (
model: LinearCustomViewModel,
limit?: number,
workspaceId?: LinearWorkspaceSelection | null
) => LinearCollectionResult<LinearCustomViewSummary> | null
listLinearCustomViews: (
model: LinearCustomViewModel,
limit?: number,
workspaceId?: LinearWorkspaceSelection | null,
options?: LinearFetchOptions
) => Promise<LinearCollectionResult<LinearCustomViewSummary>>
fetchLinearCustomView: (
viewId: string,
workspaceId: string,
model: LinearCustomViewModel,
options?: LinearFetchOptions
) => Promise<LinearCustomViewSummary | null>
listLinearCustomViewIssues: (
viewId: string,
workspaceId: string,
limit?: number,
options?: LinearFetchOptions
) => Promise<LinearCollectionResult<LinearIssue>>
listLinearCustomViewProjects: (
viewId: string,
workspaceId: string,
limit?: number,
options?: LinearFetchOptions
) => Promise<LinearCollectionResult<LinearProjectSummary>>
patchLinearIssue: (issueId: string, patch: Partial<LinearIssue>) => void
}
@ -214,6 +414,13 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
linearIssueCache: {},
linearSearchCache: {},
linearTeamCache: {},
linearProjectCache: {},
linearProjectDetailCache: {},
linearProjectIssueCache: {},
linearCustomViewCache: {},
linearCustomViewDetailCache: {},
linearCustomViewIssueCache: {},
linearCustomViewProjectCache: {},
checkLinearConnection: async (force = false) => {
if (inflightStatusRequest && !force) {
@ -241,6 +448,13 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
linearIssueCache: {},
linearSearchCache: {},
linearTeamCache: {},
linearProjectCache: {},
linearProjectDetailCache: {},
linearProjectIssueCache: {},
linearCustomViewCache: {},
linearCustomViewDetailCache: {},
linearCustomViewIssueCache: {},
linearCustomViewProjectCache: {},
linearStatusChecked: true
})
} else if (!get().linearStatusChecked) {
@ -255,7 +469,21 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
return
}
if (get().linearStatus.connected) {
set({ linearStatus: { connected: false, viewer: null }, linearStatusChecked: true })
invalidateLinearCaches()
set({
linearStatus: { connected: false, viewer: null },
linearIssueCache: {},
linearSearchCache: {},
linearTeamCache: {},
linearProjectCache: {},
linearProjectDetailCache: {},
linearProjectIssueCache: {},
linearCustomViewCache: {},
linearCustomViewDetailCache: {},
linearCustomViewIssueCache: {},
linearCustomViewProjectCache: {},
linearStatusChecked: true
})
} else if (!get().linearStatusChecked) {
set({ linearStatusChecked: true })
}
@ -285,6 +513,13 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
linearIssueCache: {},
linearSearchCache: {},
linearTeamCache: {},
linearProjectCache: {},
linearProjectDetailCache: {},
linearProjectIssueCache: {},
linearCustomViewCache: {},
linearCustomViewDetailCache: {},
linearCustomViewIssueCache: {},
linearCustomViewProjectCache: {},
linearStatusChecked: true
})
} else {
@ -307,7 +542,14 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
set({
linearIssueCache: {},
linearSearchCache: {},
linearTeamCache: {}
linearTeamCache: {},
linearProjectCache: {},
linearProjectDetailCache: {},
linearProjectIssueCache: {},
linearCustomViewCache: {},
linearCustomViewDetailCache: {},
linearCustomViewIssueCache: {},
linearCustomViewProjectCache: {}
})
const status = await linearStatus(get().settings)
if (!isCurrentLinearMutation(requestGeneration)) {
@ -337,6 +579,13 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
linearIssueCache: {},
linearSearchCache: {},
linearTeamCache: {},
linearProjectCache: {},
linearProjectDetailCache: {},
linearProjectIssueCache: {},
linearCustomViewCache: {},
linearCustomViewDetailCache: {},
linearCustomViewIssueCache: {},
linearCustomViewProjectCache: {},
linearStatusChecked: true
})
},
@ -353,6 +602,13 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
linearIssueCache: {},
linearSearchCache: {},
linearTeamCache: {},
linearProjectCache: {},
linearProjectDetailCache: {},
linearProjectIssueCache: {},
linearCustomViewCache: {},
linearCustomViewDetailCache: {},
linearCustomViewIssueCache: {},
linearCustomViewProjectCache: {},
linearStatusChecked: true
})
},
@ -370,6 +626,13 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
linearIssueCache: {},
linearSearchCache: {},
linearTeamCache: {},
linearProjectCache: {},
linearProjectDetailCache: {},
linearProjectIssueCache: {},
linearCustomViewCache: {},
linearCustomViewDetailCache: {},
linearCustomViewIssueCache: {},
linearCustomViewProjectCache: {},
linearStatusChecked: true
})
},
@ -632,6 +895,380 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
return promise
},
getCachedLinearProjects: (query, limit = 20, workspaceId) => {
const resolvedWorkspaceId = workspaceId ?? getSelectedWorkspaceId(get().linearStatus)
const cacheKey = linearCollectionCacheKey(resolvedWorkspaceId, 'projects', query?.trim(), limit)
return get().linearProjectCache[cacheKey]?.data ?? null
},
listLinearProjects: async (query, limit = 20, workspaceId, options) => {
const resolvedWorkspaceId = workspaceId ?? getSelectedWorkspaceId(get().linearStatus)
const trimmed = query?.trim() || undefined
const cacheKey = linearCollectionCacheKey(resolvedWorkspaceId, 'projects', trimmed, limit)
const cached = get().linearProjectCache[cacheKey]
if (!options?.force && isFresh(cached)) {
return cached.data ?? emptyLinearCollection<LinearProjectSummary>()
}
const inflight = inflightProjectRequests.get(cacheKey)
if (inflight && (!options?.force || inflight.force)) {
return inflight.promise
}
let entry: InflightLinearCollectionRequest<LinearProjectSummary>
const requestCacheGeneration = linearCacheGeneration
const promise = linearListProjects(get().settings, trimmed, limit, resolvedWorkspaceId)
.then((result) => {
if (
inflightProjectRequests.get(cacheKey) === entry &&
requestCacheGeneration === linearCacheGeneration
) {
set((s) => ({
linearProjectCache: evictStaleEntries({
...s.linearProjectCache,
[cacheKey]: { data: result, fetchedAt: Date.now() }
})
}))
}
return result
})
.catch((error) => {
console.warn('[linear] listLinearProjects failed:', error)
if (looksLikeAuthError(error)) {
set({ linearStatus: { connected: false, viewer: null } })
}
const fallback =
get().linearProjectCache[cacheKey]?.data ?? emptyLinearCollection<LinearProjectSummary>()
return collectionWithWorkspaceError(fallback, resolvedWorkspaceId ?? 'default', error)
})
.finally(() => {
if (inflightProjectRequests.get(cacheKey) === entry) {
inflightProjectRequests.delete(cacheKey)
}
if (
shouldRefreshStatusAfterRead(resolvedWorkspaceId) &&
requestCacheGeneration === linearCacheGeneration
) {
void get().checkLinearConnection(true)
}
})
entry = { promise, force: Boolean(options?.force), generation: requestCacheGeneration }
inflightProjectRequests.set(cacheKey, entry)
return promise
},
fetchLinearProject: async (id, workspaceId, options) => {
const cacheKey = linearCollectionCacheKey(workspaceId, 'project-detail', id)
const cached = get().linearProjectDetailCache[cacheKey]
if (!options?.force && isFresh(cached)) {
return cached.data
}
const inflight = inflightProjectDetailRequests.get(cacheKey)
if (inflight && (!options?.force || inflight.force)) {
return inflight.promise
}
let entry: InflightLinearDetailRequest<LinearProjectDetail | null>
const promise = linearGetProject(get().settings, id, workspaceId)
.then((project) => {
if (inflightProjectDetailRequests.get(cacheKey) === entry) {
set((s) => ({
linearProjectDetailCache: evictStaleEntries({
...s.linearProjectDetailCache,
[cacheKey]: { data: project, fetchedAt: Date.now() }
})
}))
}
return project
})
.catch((error) => {
console.warn('[linear] fetchLinearProject failed:', error)
if (looksLikeAuthError(error)) {
set({ linearStatus: { connected: false, viewer: null } })
}
if (options?.force) {
throw error
}
const cachedResult = get().linearProjectDetailCache[cacheKey]
if (cachedResult) {
return cachedResult.data
}
throw error
})
.finally(() => {
if (inflightProjectDetailRequests.get(cacheKey) === entry) {
inflightProjectDetailRequests.delete(cacheKey)
}
})
entry = { promise, force: Boolean(options?.force) }
inflightProjectDetailRequests.set(cacheKey, entry)
return promise
},
listLinearProjectIssues: async (projectId, workspaceId, limit = 20, options) => {
const cacheKey = linearCollectionCacheKey(workspaceId, 'project-issues', projectId, limit)
const cached = get().linearProjectIssueCache[cacheKey]
if (!options?.force && isFresh(cached)) {
return cached.data ?? emptyLinearCollection<LinearIssue>()
}
const inflight = inflightProjectIssueRequests.get(cacheKey)
if (inflight && (!options?.force || inflight.force)) {
return inflight.promise
}
let entry: InflightLinearCollectionRequest<LinearIssue>
const requestCacheGeneration = linearCacheGeneration
const promise = linearListProjectIssues(get().settings, projectId, limit, workspaceId)
.then((result) => {
if (
inflightProjectIssueRequests.get(cacheKey) === entry &&
requestCacheGeneration === linearCacheGeneration
) {
set((s) => ({
linearProjectIssueCache: evictStaleEntries({
...s.linearProjectIssueCache,
[cacheKey]: { data: result, fetchedAt: Date.now() }
})
}))
}
return result
})
.catch((error) => {
console.warn('[linear] listLinearProjectIssues failed:', error)
if (looksLikeAuthError(error)) {
set({ linearStatus: { connected: false, viewer: null } })
}
const fallback =
get().linearProjectIssueCache[cacheKey]?.data ?? emptyLinearCollection<LinearIssue>()
return collectionWithWorkspaceError(fallback, workspaceId, error)
})
.finally(() => {
if (inflightProjectIssueRequests.get(cacheKey) === entry) {
inflightProjectIssueRequests.delete(cacheKey)
}
})
entry = { promise, force: Boolean(options?.force), generation: requestCacheGeneration }
inflightProjectIssueRequests.set(cacheKey, entry)
return promise
},
getCachedLinearCustomViews: (model, limit = 20, workspaceId) => {
const resolvedWorkspaceId = workspaceId ?? getSelectedWorkspaceId(get().linearStatus)
const cacheKey = linearCollectionCacheKey(resolvedWorkspaceId, 'custom-views', model, limit)
return get().linearCustomViewCache[cacheKey]?.data ?? null
},
listLinearCustomViews: async (model, limit = 20, workspaceId, options) => {
const resolvedWorkspaceId = workspaceId ?? getSelectedWorkspaceId(get().linearStatus)
const cacheKey = linearCollectionCacheKey(resolvedWorkspaceId, 'custom-views', model, limit)
const cached = get().linearCustomViewCache[cacheKey]
if (!options?.force && isFresh(cached)) {
return cached.data ?? emptyLinearCollection<LinearCustomViewSummary>()
}
const inflight = inflightCustomViewRequests.get(cacheKey)
if (inflight && (!options?.force || inflight.force)) {
return inflight.promise
}
let entry: InflightLinearCollectionRequest<LinearCustomViewSummary>
const requestCacheGeneration = linearCacheGeneration
const promise = linearListCustomViews(get().settings, model, limit, resolvedWorkspaceId)
.then((result) => {
if (
inflightCustomViewRequests.get(cacheKey) === entry &&
requestCacheGeneration === linearCacheGeneration
) {
set((s) => ({
linearCustomViewCache: evictStaleEntries({
...s.linearCustomViewCache,
[cacheKey]: { data: result, fetchedAt: Date.now() }
})
}))
}
return result
})
.catch((error) => {
console.warn('[linear] listLinearCustomViews failed:', error)
if (looksLikeAuthError(error)) {
set({ linearStatus: { connected: false, viewer: null } })
}
const fallback =
get().linearCustomViewCache[cacheKey]?.data ??
emptyLinearCollection<LinearCustomViewSummary>()
return collectionWithWorkspaceError(fallback, resolvedWorkspaceId ?? 'default', error)
})
.finally(() => {
if (inflightCustomViewRequests.get(cacheKey) === entry) {
inflightCustomViewRequests.delete(cacheKey)
}
if (
shouldRefreshStatusAfterRead(resolvedWorkspaceId) &&
requestCacheGeneration === linearCacheGeneration
) {
void get().checkLinearConnection(true)
}
})
entry = { promise, force: Boolean(options?.force), generation: requestCacheGeneration }
inflightCustomViewRequests.set(cacheKey, entry)
return promise
},
fetchLinearCustomView: async (viewId, workspaceId, model, options) => {
const cacheKey = linearCollectionCacheKey(workspaceId, 'custom-view-detail', model, viewId)
const cached = get().linearCustomViewDetailCache[cacheKey]
if (!options?.force && isFresh(cached)) {
return cached.data
}
const inflight = inflightCustomViewDetailRequests.get(cacheKey)
if (inflight && (!options?.force || inflight.force)) {
return inflight.promise
}
let entry: InflightLinearDetailRequest<LinearCustomViewSummary | null>
const promise = linearGetCustomView(get().settings, viewId, model, workspaceId)
.then((view) => {
if (inflightCustomViewDetailRequests.get(cacheKey) === entry) {
set((s) => ({
linearCustomViewDetailCache: evictStaleEntries({
...s.linearCustomViewDetailCache,
[cacheKey]: { data: view, fetchedAt: Date.now() }
})
}))
}
return view
})
.catch((error) => {
console.warn('[linear] fetchLinearCustomView failed:', error)
if (looksLikeAuthError(error)) {
set({ linearStatus: { connected: false, viewer: null } })
}
if (options?.force) {
throw error
}
const cachedResult = get().linearCustomViewDetailCache[cacheKey]
if (cachedResult) {
return cachedResult.data
}
throw error
})
.finally(() => {
if (inflightCustomViewDetailRequests.get(cacheKey) === entry) {
inflightCustomViewDetailRequests.delete(cacheKey)
}
})
entry = { promise, force: Boolean(options?.force) }
inflightCustomViewDetailRequests.set(cacheKey, entry)
return promise
},
listLinearCustomViewIssues: async (viewId, workspaceId, limit = 20, options) => {
const cacheKey = linearCollectionCacheKey(workspaceId, 'custom-view-issues', viewId, limit)
const cached = get().linearCustomViewIssueCache[cacheKey]
if (!options?.force && isFresh(cached)) {
return cached.data ?? emptyLinearCollection<LinearIssue>()
}
const inflight = inflightCustomViewIssueRequests.get(cacheKey)
if (inflight && (!options?.force || inflight.force)) {
return inflight.promise
}
let entry: InflightLinearCollectionRequest<LinearIssue>
const requestCacheGeneration = linearCacheGeneration
const promise = linearListCustomViewIssues(get().settings, viewId, limit, workspaceId)
.then((result) => {
if (
inflightCustomViewIssueRequests.get(cacheKey) === entry &&
requestCacheGeneration === linearCacheGeneration
) {
set((s) => ({
linearCustomViewIssueCache: evictStaleEntries({
...s.linearCustomViewIssueCache,
[cacheKey]: { data: result, fetchedAt: Date.now() }
})
}))
}
return result
})
.catch((error) => {
console.warn('[linear] listLinearCustomViewIssues failed:', error)
if (looksLikeAuthError(error)) {
set({ linearStatus: { connected: false, viewer: null } })
}
const fallback =
get().linearCustomViewIssueCache[cacheKey]?.data ?? emptyLinearCollection<LinearIssue>()
return collectionWithWorkspaceError(fallback, workspaceId, error)
})
.finally(() => {
if (inflightCustomViewIssueRequests.get(cacheKey) === entry) {
inflightCustomViewIssueRequests.delete(cacheKey)
}
})
entry = { promise, force: Boolean(options?.force), generation: requestCacheGeneration }
inflightCustomViewIssueRequests.set(cacheKey, entry)
return promise
},
listLinearCustomViewProjects: async (viewId, workspaceId, limit = 20, options) => {
const cacheKey = linearCollectionCacheKey(workspaceId, 'custom-view-projects', viewId, limit)
const cached = get().linearCustomViewProjectCache[cacheKey]
if (!options?.force && isFresh(cached)) {
return cached.data ?? emptyLinearCollection<LinearProjectSummary>()
}
const inflight = inflightCustomViewProjectRequests.get(cacheKey)
if (inflight && (!options?.force || inflight.force)) {
return inflight.promise
}
let entry: InflightLinearCollectionRequest<LinearProjectSummary>
const requestCacheGeneration = linearCacheGeneration
const promise = linearListCustomViewProjects(get().settings, viewId, limit, workspaceId)
.then((result) => {
if (
inflightCustomViewProjectRequests.get(cacheKey) === entry &&
requestCacheGeneration === linearCacheGeneration
) {
set((s) => ({
linearCustomViewProjectCache: evictStaleEntries({
...s.linearCustomViewProjectCache,
[cacheKey]: { data: result, fetchedAt: Date.now() }
})
}))
}
return result
})
.catch((error) => {
console.warn('[linear] listLinearCustomViewProjects failed:', error)
if (looksLikeAuthError(error)) {
set({ linearStatus: { connected: false, viewer: null } })
}
const fallback =
get().linearCustomViewProjectCache[cacheKey]?.data ??
emptyLinearCollection<LinearProjectSummary>()
return collectionWithWorkspaceError(fallback, workspaceId, error)
})
.finally(() => {
if (inflightCustomViewProjectRequests.get(cacheKey) === entry) {
inflightCustomViewProjectRequests.delete(cacheKey)
}
})
entry = { promise, force: Boolean(options?.force), generation: requestCacheGeneration }
inflightCustomViewProjectRequests.set(cacheKey, entry)
return promise
},
patchLinearIssue: (issueId, patch) => {
set((s) => {
let changed = false
@ -667,7 +1304,36 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
changed = true
}
return changed ? { linearIssueCache: nextIssueCache, linearSearchCache: nextSearchCache } : {}
const nextProjectIssueCache = patchLinearIssueCollectionCache(
s.linearProjectIssueCache,
issueId,
patch
)
if (nextProjectIssueCache.changed) {
changed = true
}
const nextCustomViewIssueCache = patchLinearIssueCollectionCache(
s.linearCustomViewIssueCache,
issueId,
patch
)
if (nextCustomViewIssueCache.changed) {
changed = true
}
return changed
? {
linearIssueCache: nextIssueCache,
linearSearchCache: nextSearchCache,
linearProjectIssueCache: nextProjectIssueCache.changed
? nextProjectIssueCache.cache
: s.linearProjectIssueCache,
linearCustomViewIssueCache: nextCustomViewIssueCache.changed
? nextCustomViewIssueCache.cache
: s.linearCustomViewIssueCache
}
: {}
})
}
})

View File

@ -122,7 +122,14 @@ function runtimeScopedStateReset(): Partial<AppState> {
linearStatusChecked: false,
linearIssueCache: {},
linearSearchCache: {},
linearTeamCache: {}
linearTeamCache: {},
linearProjectCache: {},
linearProjectDetailCache: {},
linearProjectIssueCache: {},
linearCustomViewCache: {},
linearCustomViewDetailCache: {},
linearCustomViewIssueCache: {},
linearCustomViewProjectCache: {}
}
}

View File

@ -219,6 +219,11 @@ const VALID_LINEAR_PRESETS = new Set<NonNullable<TaskResumeState['linearPreset']
'all',
'completed'
])
const VALID_LINEAR_MODES = new Set<NonNullable<TaskResumeState['linearMode']>>([
'issues',
'projects',
'views'
])
function filterTrustedOrcaHooksToValidRepos(
trust: PersistedTrustedOrcaHooks,
@ -340,9 +345,33 @@ function sanitizeTaskResumeState(value: unknown): TaskResumeState | undefined {
) {
next.linearPreset = input.linearPreset as NonNullable<TaskResumeState['linearPreset']>
}
if (
typeof input.linearMode === 'string' &&
VALID_LINEAR_MODES.has(input.linearMode as NonNullable<TaskResumeState['linearMode']>)
) {
next.linearMode = input.linearMode as NonNullable<TaskResumeState['linearMode']>
}
if (typeof input.linearQuery === 'string') {
next.linearQuery = input.linearQuery
}
if (input.linearContext && typeof input.linearContext === 'object') {
const context = input.linearContext as Record<string, unknown>
if (
(context.kind === 'project' || context.kind === 'view') &&
typeof context.id === 'string' &&
context.id.trim() &&
typeof context.workspaceId === 'string' &&
context.workspaceId.trim() &&
context.workspaceId !== 'all'
) {
next.linearContext = {
kind: context.kind,
id: context.id,
workspaceId: context.workspaceId,
model: context.model === 'issue' || context.model === 'project' ? context.model : undefined
}
}
}
return Object.keys(next).length > 0 ? next : undefined
}

View File

@ -1066,6 +1066,21 @@ export type LinearWorkspace = LinearViewer & {
}
export type LinearWorkspaceSelection = string | 'all'
export type LinearWorkspaceSelector = LinearWorkspaceSelection | undefined
export type LinearConcreteWorkspaceId = string
export type LinearWorkspaceError = {
workspaceId: string
workspaceName?: string
type: 'auth' | 'rate_limited' | 'network' | 'unknown'
message: string
}
export type LinearCollectionResult<T> = {
items: T[]
errors?: LinearWorkspaceError[]
hasMore?: boolean
}
export type LinearConnectionStatus = {
connected: boolean
@ -1109,9 +1124,109 @@ export type LinearIssue = {
export type LinearProjectSummary = {
id: string
workspaceId?: string
workspaceName?: string
name: string
url?: string
color?: string
icon?: string
description?: string
content?: string
status?: LinearProjectStatusSummary
health?: string | null
priority?: number | null
priorityLabel?: string | null
lead?: LinearProjectMemberSummary
members?: LinearProjectMemberSummary[]
teams?: {
id: string
name: string
key?: string
}[]
labels?: {
id: string
name: string
color?: string
}[]
startDate?: string | null
targetDate?: string | null
createdAt?: string
updatedAt?: string
completedAt?: string | null
canceledAt?: string | null
startedAt?: string | null
progress?: number | null
scope?: number | null
issueCount?: number
completedIssueCount?: number
}
export type LinearProjectStatusSummary = {
id: string
name: string
type?: string
color?: string
}
export type LinearProjectMemberSummary = {
id: string
displayName: string
avatarUrl?: string
}
export type LinearProjectMilestoneSummary = {
id: string
name: string
status?: string
targetDate?: string | null
progress?: number | null
}
export type LinearProjectResourceSummary = {
id: string
title: string
url: string
type?: string
}
export type LinearProjectUpdateSummary = {
id: string
body?: string
health?: string | null
url?: string
createdAt?: string
updatedAt?: string
user?: LinearProjectMemberSummary
}
export type LinearProjectDetail = LinearProjectSummary & {
milestones?: LinearProjectMilestoneSummary[]
resources?: LinearProjectResourceSummary[]
latestUpdate?: LinearProjectUpdateSummary
}
export type LinearCustomViewModel = 'issue' | 'project'
export type LinearCustomViewSummary = {
id: string
workspaceId?: string
workspaceName?: string
name: string
description?: string
model: LinearCustomViewModel
url?: string
color?: string
icon?: string
shared?: boolean
team?: {
id: string
name?: string
key?: string
}
owner?: LinearProjectMemberSummary
creator?: LinearProjectMemberSummary
createdAt?: string
updatedAt?: string
}
export type LinearIssueChildSummary = {
@ -2250,8 +2365,15 @@ export type TaskResumeState = {
githubItemsPreset?: TaskViewPresetId | null
githubItemsQuery?: string
githubProjectHiddenFieldIdsByView?: Record<string, string[]>
linearMode?: 'issues' | 'projects' | 'views'
linearPreset?: 'assigned' | 'created' | 'all' | 'completed'
linearQuery?: string
linearContext?: {
kind: 'project' | 'view'
id: string
workspaceId: LinearConcreteWorkspaceId
model?: LinearCustomViewModel
}
}
export type RightSidebarTab = 'explorer' | 'search' | 'source-control' | 'checks' | 'ports'