diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index a5b260ae0..37cc7d74a 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -58,6 +58,7 @@ import RepoDotLabel from '@/components/repo/RepoDotLabel' import IssueSourceIndicator, { sameGitHubOwnerRepo } from '@/components/github/IssueSourceIndicator' import IssueSourceSelector, { issueSourceChipClass } from '@/components/github/IssueSourceSelector' import GitHubRateLimitPill from '@/components/github/GitHubRateLimitPill' +import { reconcileLinearTeamSelection } from '@/components/task-page-linear-team-selection' import { stripRepoQualifiers } from '../../../shared/task-query' import GitHubItemDialog from '@/components/GitHubItemDialog' import GitLabItemDialog from '@/components/GitLabItemDialog' @@ -1078,15 +1079,10 @@ export default function TaskPage(): React.JSX.Element { 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. + // Why: team IDs belong to one Linear workspace. Switching workspaces while a + // saved subset exists must not leave the task list filtered by stale team IDs. useEffect(() => { - if (availableTeams.length === 0) { - return - } - if (!defaultLinearTeamSelection) { - setLinearTeamSelection(new Set(availableTeams.map((t) => t.id))) - } + setLinearTeamSelection(reconcileLinearTeamSelection(availableTeams, defaultLinearTeamSelection)) }, [availableTeams, defaultLinearTeamSelection]) const displayedLinearIssues = useMemo( diff --git a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx index 6b9f841b8..e8c6cbcea 100644 --- a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx +++ b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx @@ -1,7 +1,6 @@ import React, { useState, useCallback } from 'react' import { X, Wrench, ChevronDown } from 'lucide-react' import { cn } from '@/lib/utils' -import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip' import { AgentStateDot, agentStateLabel, type AgentDotState } from '@/components/AgentStateDot' import { AgentIcon } from '@/lib/agent-catalog' import { agentTypeToIconAgent, formatAgentTypeLabel } from '@/lib/agent-status' @@ -222,16 +221,12 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ bar + right-side dot combo, which double-encoded state. Size md gives the glyph enough presence for the leading slot without overpowering the prompt text. */} - - - - - - - - {agent.entry.interrupted ? 'Interrupted' : agentStateLabel(asDotState(agent.state))} - - + + + {/* Why: identity (Claude/Codex/Gemini/…) sits inline with the prompt so the reader gets "state → who → what they said" left-to-right on the top row. The sub-rows (tool step, assistant response) are @@ -239,16 +234,9 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ them — keeping the icon only on the prompt row lets the sub-rows indent under the prompt text cleanly. */} {!hideIdentityIcon && ( - - - - - - - - {formatAgentTypeLabel(agent.agentType)} - - + + + )} {/* Why: animate between a 1-line clipped height and the content's natural height using Chromium's `interpolate-size: allow-keywords` @@ -321,27 +309,21 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ ? formatTimeAgo(startedAt, now) : null} - - - - - - Dismiss - - + )} {/* Why: when there is no timestamp yet (fresh agent, never @@ -350,27 +332,21 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ reachable. Rare path; most rows have a timestamp the moment they start. */} {startedAt === null && doneAt === null && ( - - - - - - Dismiss - - + )} {/* Why: chevron points down when collapsed (content below is available) and rotates 180° to point up when expanded diff --git a/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts b/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts index 20d13af57..d0286bbeb 100644 --- a/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts +++ b/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts @@ -115,7 +115,7 @@ export function useWorktreeAgentRows(worktreeId: string): DashboardAgentRow[] { } out.push(entry) } - for (const unsupported of Object.values(s.migrationUnsupportedByPtyId)) { + for (const unsupported of Object.values(s.migrationUnsupportedByPtyId ?? {})) { const entry = migrationUnsupportedToAgentStatusEntry(unsupported) if (!entry) { continue diff --git a/src/renderer/src/components/task-page-linear-team-selection.test.ts b/src/renderer/src/components/task-page-linear-team-selection.test.ts new file mode 100644 index 000000000..a3d1a207b --- /dev/null +++ b/src/renderer/src/components/task-page-linear-team-selection.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import type { LinearTeam } from '../../../shared/types' +import { reconcileLinearTeamSelection } from './task-page-linear-team-selection' + +function team(id: string): LinearTeam { + return { + id, + name: id, + key: id.toUpperCase() + } +} + +describe('reconcileLinearTeamSelection', () => { + it('selects every available team when the saved selection is sticky-all', () => { + expect(Array.from(reconcileLinearTeamSelection([team('a'), team('b')], null))).toEqual([ + 'a', + 'b' + ]) + }) + + it('preserves saved teams that still exist', () => { + expect(Array.from(reconcileLinearTeamSelection([team('a'), team('b')], ['b']))).toEqual(['b']) + }) + + it('drops stale saved teams after switching workspaces', () => { + expect(Array.from(reconcileLinearTeamSelection([team('c'), team('d')], ['a', 'd']))).toEqual([ + 'd' + ]) + }) + + it('falls back to all current teams when every saved team is stale', () => { + expect(Array.from(reconcileLinearTeamSelection([team('c'), team('d')], ['a', 'b']))).toEqual([ + 'c', + 'd' + ]) + }) +}) diff --git a/src/renderer/src/components/task-page-linear-team-selection.ts b/src/renderer/src/components/task-page-linear-team-selection.ts new file mode 100644 index 000000000..f6d7e52fa --- /dev/null +++ b/src/renderer/src/components/task-page-linear-team-selection.ts @@ -0,0 +1,19 @@ +import type { LinearTeam } from '../../../shared/types' + +export function reconcileLinearTeamSelection( + availableTeams: LinearTeam[], + storedSelection: readonly string[] | null | undefined +): ReadonlySet { + const availableIds = availableTeams.map((team) => team.id) + if (availableIds.length === 0) { + return new Set() + } + + const availableIdSet = new Set(availableIds) + const validStoredSelection = (storedSelection ?? []).filter((id) => availableIdSet.has(id)) + if (validStoredSelection.length > 0) { + return new Set(validStoredSelection) + } + + return new Set(availableIds) +} diff --git a/src/renderer/src/components/ui/button.tsx b/src/renderer/src/components/ui/button.tsx index d87333920..d73a70461 100644 --- a/src/renderer/src/components/ui/button.tsx +++ b/src/renderer/src/components/ui/button.tsx @@ -36,20 +36,21 @@ const buttonVariants = cva( } ) -function Button({ - className, - variant = 'default', - size = 'default', - asChild = false, - ...props -}: React.ComponentProps<'button'> & - VariantProps & { - asChild?: boolean - }) { +const Button = React.forwardRef< + HTMLButtonElement, + React.ComponentProps<'button'> & + VariantProps & { + asChild?: boolean + } +>(function Button( + { className, variant = 'default', size = 'default', asChild = false, ...props }, + ref +) { const Comp = asChild ? Slot.Root : 'button' return ( ) -} +}) export { Button, buttonVariants } diff --git a/src/renderer/src/lib/migration-unsupported-agent-entry.test.ts b/src/renderer/src/lib/migration-unsupported-agent-entry.test.ts new file mode 100644 index 000000000..cdb59cc02 --- /dev/null +++ b/src/renderer/src/lib/migration-unsupported-agent-entry.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { migrationUnsupportedToAgentStatusEntry } from './migration-unsupported-agent-entry' +import type { MigrationUnsupportedPtyEntry } from '../../../shared/agent-status-types' + +describe('migrationUnsupportedToAgentStatusEntry', () => { + it('returns a stable synthetic entry for the same migration record', () => { + const unsupported: MigrationUnsupportedPtyEntry = { + ptyId: 'pty-1', + paneKey: 'tab-1:11111111-1111-4111-8111-111111111111', + reason: 'legacy-numeric-pane-key', + source: 'local', + updatedAt: 1234 + } + + const first = migrationUnsupportedToAgentStatusEntry(unsupported) + const second = migrationUnsupportedToAgentStatusEntry(unsupported) + + expect(second).toBe(first) + expect(first?.updatedAt).toBe(Number.MAX_SAFE_INTEGER) + }) + + it('caches null for records that cannot be projected to a pane key', () => { + const unsupported: MigrationUnsupportedPtyEntry = { + ptyId: 'pty-1', + reason: 'legacy-numeric-pane-key', + source: 'ssh', + updatedAt: 1234 + } + + expect(migrationUnsupportedToAgentStatusEntry(unsupported)).toBeNull() + expect(migrationUnsupportedToAgentStatusEntry(unsupported)).toBeNull() + }) +}) diff --git a/src/renderer/src/lib/migration-unsupported-agent-entry.ts b/src/renderer/src/lib/migration-unsupported-agent-entry.ts index 5f3e83496..20033a486 100644 --- a/src/renderer/src/lib/migration-unsupported-agent-entry.ts +++ b/src/renderer/src/lib/migration-unsupported-agent-entry.ts @@ -3,25 +3,37 @@ import type { MigrationUnsupportedPtyEntry } from '../../../shared/agent-status-types' +const cachedMigrationUnsupportedEntries = new WeakMap< + MigrationUnsupportedPtyEntry, + AgentStatusEntry | null +>() + export function migrationUnsupportedToAgentStatusEntry( entry: MigrationUnsupportedPtyEntry ): AgentStatusEntry | null { - if (!entry.paneKey) { - return null - } - const now = Date.now() - return { - state: 'blocked', - prompt: 'Agent unavailable after pane identity migration', - // Why: this is a persistent migration block, not a hook heartbeat. Keep it - // fresh while present so normal stale-status decay does not hide it. - updatedAt: Math.max(entry.updatedAt, now), - stateStartedAt: entry.updatedAt, - agentType: 'unknown', - paneKey: entry.paneKey, - terminalTitle: 'Migration unsupported', - stateHistory: [], - lastAssistantMessage: - 'Restart this terminal so Orca can attach a stable UUID pane key to agent hooks.' + const cached = cachedMigrationUnsupportedEntries.get(entry) + if (cached !== undefined) { + return cached } + + const converted: AgentStatusEntry | null = !entry.paneKey + ? null + : { + state: 'blocked', + prompt: 'Agent unavailable after pane identity migration', + // Why: this synthetic row represents a persistent migration block. Keep + // it "fresh" without Date.now() so Zustand selectors can return a stable + // cached object for the same store snapshot. + updatedAt: Number.MAX_SAFE_INTEGER, + stateStartedAt: entry.updatedAt, + agentType: 'unknown', + paneKey: entry.paneKey, + terminalTitle: 'Migration unsupported', + stateHistory: [], + lastAssistantMessage: + 'Restart this terminal so Orca can attach a stable UUID pane key to agent hooks.' + } + + cachedMigrationUnsupportedEntries.set(entry, converted) + return converted }