diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 2a5bac41118..ffac70d432b 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -82,6 +82,7 @@ function createSettings(overrides: Partial = {}): GlobalSettings defaultTaskViewPreset: 'all', defaultTaskSource: 'github', defaultRepoSelection: null, + defaultLinearTeamSelection: null, agentCmdOverrides: {}, terminalMacOptionAsAlt: 'false', terminalMacOptionAsAltMigrated: true, diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index 63775e32c54..f4831d4bd51 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -76,6 +76,7 @@ function createSettings(overrides: Partial = {}): GlobalSettings defaultTaskViewPreset: 'all', defaultTaskSource: 'github', defaultRepoSelection: null, + defaultLinearTeamSelection: null, agentCmdOverrides: {}, terminalMacOptionAsAlt: 'false', terminalMacOptionAsAltMigrated: true, diff --git a/src/main/ipc/linear.ts b/src/main/ipc/linear.ts index 75a16370b31..71fbeb15d28 100644 --- a/src/main/ipc/linear.ts +++ b/src/main/ipc/linear.ts @@ -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 [] diff --git a/src/main/linear/teams.ts b/src/main/linear/teams.ts new file mode 100644 index 00000000000..152fe572c8f --- /dev/null +++ b/src/main/linear/teams.ts @@ -0,0 +1,26 @@ +import type { LinearTeam } from '../../shared/types' +import { acquire, release, getClient, isAuthError, clearToken } from './client' + +export async function listTeams(): Promise { + 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() + } +} diff --git a/src/preload/api-types.d.ts b/src/preload/api-types.d.ts index 2767f523e6d..a706938b428 100644 --- a/src/preload/api-types.d.ts +++ b/src/preload/api-types.d.ts @@ -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 + listTeams: () => Promise teamStates: (args: { teamId: string }) => Promise teamLabels: (args: { teamId: string }) => Promise teamMembers: (args: { teamId: string }) => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 5c5c7b08d44..bfc2732f8aa 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -472,6 +472,8 @@ const api = { issueComments: (args: { issueId: string }): Promise => ipcRenderer.invoke('linear:issueComments', args), + listTeams: (): Promise => ipcRenderer.invoke('linear:listTeams'), + teamStates: (args: { teamId: string }): Promise => ipcRenderer.invoke('linear:teamStates', args), diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index fded43441e2..ae18d1c8146 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -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('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>(() => { + if (!defaultLinearTeamSelection) { + return new Set() + } + 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 { ) })} - {/* Why: Linear issues are not repo-scoped, so the repo - selector is only relevant for the GitHub tab. */} -
- { - 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" - /> +
+ {taskSource === 'github' ? ( + { + 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 ? ( + { + 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}
@@ -1819,8 +1880,19 @@ export default function TaskPage(): React.JSX.Element { ) : null} + {!linearLoading && linearIssues.length > 0 && filteredLinearIssues.length === 0 ? ( +
+

+ No issues match the selected teams +

+

+ Try selecting more teams or click “All teams”. +

+
+ ) : null} +
- {linearIssues.map((issue) => ( + {filteredLinearIssues.map((issue) => (
+ onChange: (next: ReadonlySet) => void + onSelectAll: () => void + triggerClassName?: string +} + +function renderTriggerLabel(teams: LinearTeam[], selected: ReadonlySet): React.JSX.Element { + if (teams.length === 0) { + return No teams + } + if (selected.size === teams.length) { + return All teams + } + const selectedTeams = teams.filter((t) => selected.has(t.id)) + const [first, second, ...rest] = selectedTeams + return ( + + {first ? {first.key} : null} + {second ? , {second.key} : null} + {rest.length > 0 ? +{rest.length} : null} + + ) +} + +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 ( + + + + + + + +
+ +
+ + No teams match your search. + {filteredTeams.map((team) => { + const isSelected = selected.has(team.id) + const isLastSelected = isSelected && selected.size <= 1 + return ( + toggle(team.id)} + disabled={isLastSelected} + className="items-center gap-2 px-3 py-1.5 text-xs" + > + +
+ + {team.name} + + {team.key} + + +
+
+ ) + })} +
+
+
+
+ ) +} diff --git a/src/shared/constants.ts b/src/shared/constants.ts index c7fd2e8f2a5..5d948e89bd2 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -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 diff --git a/src/shared/types.ts b/src/shared/types.ts index dec6343e332..01e4ab3c369 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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> /** Why: macOS terminals must choose between letting Option compose layout