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
}