feat(tasks): add Linear team selector (#1074)

This commit is contained in:
Jinwoo Hong
2026-04-24 21:03:30 -07:00
committed by GitHub
parent f1a1f18cf8
commit cc47cad95f
10 changed files with 323 additions and 26 deletions
@@ -82,6 +82,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
defaultTaskViewPreset: 'all',
defaultTaskSource: 'github',
defaultRepoSelection: null,
defaultLinearTeamSelection: null,
agentCmdOverrides: {},
terminalMacOptionAsAlt: 'false',
terminalMacOptionAsAltMigrated: true,
+1
View File
@@ -76,6 +76,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
defaultTaskViewPreset: 'all',
defaultTaskSource: 'github',
defaultRepoSelection: null,
defaultLinearTeamSelection: null,
agentCmdOverrides: {},
terminalMacOptionAsAlt: 'false',
terminalMacOptionAsAltMigrated: true,
+5
View File
@@ -12,6 +12,7 @@ import {
getTeamLabels,
getTeamMembers
} from '../linear/issues'
import { listTeams } from '../linear/teams'
import type { LinearListFilter } from '../linear/issues'
import type { LinearIssueUpdate } from '../../shared/types'
@@ -116,6 +117,10 @@ export function registerLinearHandlers(): void {
return getIssueComments(args.issueId.trim())
})
ipcMain.handle('linear:listTeams', async () => {
return listTeams()
})
ipcMain.handle('linear:teamStates', async (_event, args: { teamId: string }) => {
if (typeof args?.teamId !== 'string' || !args.teamId.trim()) {
return []
+26
View File
@@ -0,0 +1,26 @@
import type { LinearTeam } from '../../shared/types'
import { acquire, release, getClient, isAuthError, clearToken } from './client'
export async function listTeams(): Promise<LinearTeam[]> {
const client = getClient()
if (!client) {
return []
}
await acquire()
try {
const teams = await client.teams()
return teams.nodes
.map((t) => ({ id: t.id, name: t.name, key: t.key }))
.sort((a, b) => a.name.localeCompare(b.name))
} catch (error) {
if (isAuthError(error)) {
clearToken()
throw error
}
console.warn('[linear] listTeams failed:', error)
return []
} finally {
release()
}
}
+2
View File
@@ -30,6 +30,7 @@ import type {
LinearWorkflowState,
LinearLabel,
LinearMember,
LinearTeam,
GitHubIssueUpdate,
NotificationDispatchRequest,
NotificationDispatchResult,
@@ -452,6 +453,7 @@ export type PreloadApi = {
body: string
}) => Promise<{ ok: true; id: string } | { ok: false; error: string }>
issueComments: (args: { issueId: string }) => Promise<LinearComment[]>
listTeams: () => Promise<LinearTeam[]>
teamStates: (args: { teamId: string }) => Promise<LinearWorkflowState[]>
teamLabels: (args: { teamId: string }) => Promise<LinearLabel[]>
teamMembers: (args: { teamId: string }) => Promise<LinearMember[]>
+2
View File
@@ -472,6 +472,8 @@ const api = {
issueComments: (args: { issueId: string }): Promise<unknown[]> =>
ipcRenderer.invoke('linear:issueComments', args),
listTeams: (): Promise<unknown[]> => ipcRenderer.invoke('linear:listTeams'),
teamStates: (args: { teamId: string }): Promise<unknown[]> =>
ipcRenderer.invoke('linear:teamStates', args),
+98 -26
View File
@@ -47,6 +47,7 @@ import {
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import RepoMultiCombobox from '@/components/ui/repo-multi-combobox'
import TeamMultiCombobox from '@/components/ui/team-multi-combobox'
import RepoDotLabel from '@/components/repo/RepoDotLabel'
import { stripRepoQualifiers } from '../../../shared/task-query'
import GitHubItemDrawer from '@/components/GitHubItemDrawer'
@@ -735,6 +736,50 @@ export default function TaskPage(): React.JSX.Element {
const [linearSearchInput, setLinearSearchInput] = useState('')
const [activeLinearPreset, setActiveLinearPreset] = useState<LinearPresetId>('all')
const [linearRefreshNonce, setLinearRefreshNonce] = useState(0)
// Why: fetch the full team list from the Linear API so the selector shows
// all teams the user belongs to, not just teams with issues in the current
// fetch window. Fetched once when the Linear tab is active and connected.
const [availableTeams, setAvailableTeams] = useState<{ id: string; name: string; key: string }[]>(
[]
)
useEffect(() => {
if (taskSource !== 'linear' || !linearStatus.connected) {
return
}
void window.api.linear
.listTeams()
.then(setAvailableTeams)
.catch(() => {
console.warn('[TaskPage] Failed to fetch Linear teams')
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [taskSource, linearStatus.connected])
const defaultLinearTeamSelection = settings?.defaultLinearTeamSelection
const [linearTeamSelection, setLinearTeamSelection] = useState<ReadonlySet<string>>(() => {
if (!defaultLinearTeamSelection) {
return new Set<string>()
}
return new Set(defaultLinearTeamSelection)
})
// Why: in sticky-all mode, auto-include all teams once the list arrives.
// In explicit-selection mode, the set is already correct from the initializer.
useEffect(() => {
if (availableTeams.length === 0) {
return
}
if (!defaultLinearTeamSelection) {
setLinearTeamSelection(new Set(availableTeams.map((t) => t.id)))
}
}, [availableTeams, defaultLinearTeamSelection])
const filteredLinearIssues = useMemo(
() => linearIssues.filter((issue) => linearTeamSelection.has(issue.team.id)),
[linearIssues, linearTeamSelection]
)
const [linearConnectOpen, setLinearConnectOpen] = useState(false)
const [linearApiKeyDraft, setLinearApiKeyDraft] = useState('')
const [linearConnectState, setLinearConnectState] = useState<'idle' | 'connecting' | 'error'>(
@@ -1289,31 +1334,47 @@ export default function TaskPage(): React.JSX.Element {
)
})}
</div>
{/* Why: Linear issues are not repo-scoped, so the repo
selector is only relevant for the GitHub tab. */}
<div className={cn('w-[200px]', taskSource !== 'github' && 'invisible')}>
<RepoMultiCombobox
repos={eligibleRepos}
selected={repoSelection}
onChange={(next) => {
setRepoSelection(next)
// Why: persist the curated subset so the same set reopens
// next launch. Sticky-all uses onSelectAll instead.
void updateSettings({ defaultRepoSelection: [...next] }).catch(() => {
toast.error('Failed to save repo selection.')
})
}}
onSelectAll={() => {
const allIds = new Set(eligibleRepos.map((r) => r.id))
setRepoSelection(allIds)
// Why: persist `null` so new repos added later are
// automatically included — a frozen array would exclude them.
void updateSettings({ defaultRepoSelection: null }).catch(() => {
toast.error('Failed to save repo selection.')
})
}}
triggerClassName="h-8 w-full rounded-md border border-border/50 bg-muted/50 px-2 text-xs font-medium shadow-sm transition hover:bg-muted/50 focus:ring-2 focus:ring-ring/20 focus:outline-none"
/>
<div className="w-[200px]">
{taskSource === 'github' ? (
<RepoMultiCombobox
repos={eligibleRepos}
selected={repoSelection}
onChange={(next) => {
setRepoSelection(next)
void updateSettings({ defaultRepoSelection: [...next] }).catch(() => {
toast.error('Failed to save repo selection.')
})
}}
onSelectAll={() => {
const allIds = new Set(eligibleRepos.map((r) => r.id))
setRepoSelection(allIds)
void updateSettings({ defaultRepoSelection: null }).catch(() => {
toast.error('Failed to save repo selection.')
})
}}
triggerClassName="h-8 w-full rounded-md border border-border/50 bg-muted/50 px-2 text-xs font-medium shadow-sm transition hover:bg-muted/50 focus:ring-2 focus:ring-ring/20 focus:outline-none"
/>
) : availableTeams.length > 0 ? (
<TeamMultiCombobox
teams={availableTeams}
selected={linearTeamSelection}
onChange={(next) => {
setLinearTeamSelection(next)
void updateSettings({ defaultLinearTeamSelection: [...next] }).catch(
() => {
toast.error('Failed to save team selection.')
}
)
}}
onSelectAll={() => {
setLinearTeamSelection(new Set(availableTeams.map((t) => t.id)))
void updateSettings({ defaultLinearTeamSelection: null }).catch(() => {
toast.error('Failed to save team selection.')
})
}}
triggerClassName="h-8 w-full rounded-md border border-border/50 bg-muted/50 px-2 text-xs font-medium shadow-sm transition hover:bg-muted/50 focus:ring-2 focus:ring-ring/20 focus:outline-none"
/>
) : null}
</div>
</div>
@@ -1819,8 +1880,19 @@ export default function TaskPage(): React.JSX.Element {
</div>
) : null}
{!linearLoading && linearIssues.length > 0 && filteredLinearIssues.length === 0 ? (
<div className="px-4 py-10 text-center">
<p className="text-base font-medium text-foreground">
No issues match the selected teams
</p>
<p className="mt-2 text-sm text-muted-foreground">
Try selecting more teams or click &ldquo;All teams&rdquo;.
</p>
</div>
) : null}
<div className="divide-y divide-border/50">
{linearIssues.map((issue) => (
{filteredLinearIssues.map((issue) => (
<div
key={issue.id}
role="button"
@@ -0,0 +1,177 @@
import React, { useCallback, useMemo, useState } from 'react'
import { Check, ChevronsUpDown } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandInput,
CommandItem,
CommandList
} from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { cn } from '@/lib/utils'
import type { LinearTeam } from '../../../../shared/types'
type TeamMultiComboboxProps = {
teams: LinearTeam[]
selected: ReadonlySet<string>
onChange: (next: ReadonlySet<string>) => void
onSelectAll: () => void
triggerClassName?: string
}
function renderTriggerLabel(teams: LinearTeam[], selected: ReadonlySet<string>): React.JSX.Element {
if (teams.length === 0) {
return <span className="text-muted-foreground">No teams</span>
}
if (selected.size === teams.length) {
return <span className="inline-flex min-w-0 items-center gap-1.5">All teams</span>
}
const selectedTeams = teams.filter((t) => selected.has(t.id))
const [first, second, ...rest] = selectedTeams
return (
<span className="inline-flex min-w-0 items-center gap-1.5 truncate">
{first ? <span>{first.key}</span> : null}
{second ? <span className="text-muted-foreground">, {second.key}</span> : null}
{rest.length > 0 ? <span className="text-muted-foreground">+{rest.length}</span> : null}
</span>
)
}
export default function TeamMultiCombobox({
teams,
selected,
onChange,
onSelectAll,
triggerClassName
}: TeamMultiComboboxProps): React.JSX.Element {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [commandValue, setCommandValue] = useState('')
const filteredTeams = useMemo(() => {
if (!query) {
return teams
}
const lower = query.toLowerCase()
return teams.filter(
(t) => t.name.toLowerCase().includes(lower) || t.key.toLowerCase().includes(lower)
)
}, [teams, query])
const allSelected = selected.size === teams.length && teams.length > 0
const handleOpenChange = useCallback((nextOpen: boolean) => {
setOpen(nextOpen)
if (!nextOpen) {
setQuery('')
}
}, [])
const toggle = useCallback(
(teamId: string) => {
const next = new Set(selected)
if (next.has(teamId)) {
if (next.size <= 1) {
return
}
next.delete(teamId)
} else {
next.add(teamId)
}
onChange(next)
},
[onChange, selected]
)
const handleSelectAll = useCallback(() => {
if (allSelected) {
const first = teams[0]
if (!first) {
return
}
onChange(new Set([first.id]))
return
}
onSelectAll()
}, [allSelected, onChange, onSelectAll, teams])
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
className={cn('h-8 w-full justify-between px-3 text-xs font-normal', triggerClassName)}
>
{renderTriggerLabel(teams, selected)}
<ChevronsUpDown className="size-3.5 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-[var(--radix-popover-trigger-width)] p-0">
<Command shouldFilter={false} value={commandValue} onValueChange={setCommandValue}>
<CommandInput
autoFocus
placeholder="Search teams..."
value={query}
onValueChange={setQuery}
className="text-xs"
/>
<div className="border-b border-border">
<button
type="button"
onClick={handleSelectAll}
onMouseDown={(event) => event.preventDefault()}
onMouseEnter={() => setCommandValue('')}
className={cn(
'flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs text-foreground transition-colors hover:bg-accent hover:text-accent-foreground',
allSelected && 'opacity-80'
)}
>
<Check
className={cn(
'size-3 text-muted-foreground',
allSelected ? 'opacity-70' : 'opacity-0'
)}
/>
<span>All teams</span>
</button>
</div>
<CommandList>
<CommandEmpty>No teams match your search.</CommandEmpty>
{filteredTeams.map((team) => {
const isSelected = selected.has(team.id)
const isLastSelected = isSelected && selected.size <= 1
return (
<CommandItem
key={team.id}
value={team.id}
onSelect={() => toggle(team.id)}
disabled={isLastSelected}
className="items-center gap-2 px-3 py-1.5 text-xs"
>
<Check
className={cn(
'size-3 text-muted-foreground',
isSelected ? 'opacity-70' : 'opacity-0'
)}
/>
<div className="min-w-0 flex-1">
<span className="inline-flex items-center gap-1.5 text-xs">
<span>{team.name}</span>
<span className="shrink-0 rounded bg-muted px-1 py-0.5 text-[9px] font-medium leading-none text-muted-foreground">
{team.key}
</span>
</span>
</div>
</CommandItem>
)
})}
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
+1
View File
@@ -159,6 +159,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
defaultTaskViewPreset: 'all',
defaultTaskSource: 'github',
defaultRepoSelection: null,
defaultLinearTeamSelection: null,
agentCmdOverrides: {},
// Why: 'auto' runs a layout-aware probe at boot (see
// src/renderer/src/lib/keyboard-layout/*) that picks 'true' for US and
+10
View File
@@ -539,6 +539,12 @@ export type LinearMember = {
avatarUrl?: string
}
export type LinearTeam = {
id: string
name: string
key: string
}
// ─── Hooks (orca.yaml) ──────────────────────────────────────────────
export type OrcaHooks = {
scripts: {
@@ -827,6 +833,10 @@ export type GlobalSettings = {
* eligible are silently dropped on load. An empty array after that drop
* is treated as `null`. */
defaultRepoSelection: string[] | null
/** Why: persists the user's Linear team selection in the tasks view.
* Same nullable-array pattern as `defaultRepoSelection`: `null` = sticky-all,
* `string[]` = frozen subset of team IDs. */
defaultLinearTeamSelection: string[] | null
/** Per-agent CLI command overrides. A missing key means use the catalog default binary name. */
agentCmdOverrides: Partial<Record<TuiAgent, string>>
/** Why: macOS terminals must choose between letting Option compose layout