diff --git a/ui/src/components/IssueFiltersPopover.test.tsx b/ui/src/components/IssueFiltersPopover.test.tsx index d09c48c9406..74a40304e4f 100644 --- a/ui/src/components/IssueFiltersPopover.test.tsx +++ b/ui/src/components/IssueFiltersPopover.test.tsx @@ -79,5 +79,6 @@ describe("IssueFiltersPopover", () => { element.className.includes("md:grid-cols-3"), ); expect(layoutGrid?.className).toContain("grid-cols-1"); + expect(popoverContent?.textContent).toContain("Live runs only"); }); }); diff --git a/ui/src/components/IssueFiltersPopover.tsx b/ui/src/components/IssueFiltersPopover.tsx index 2702ea0c7e5..19a844ad2c3 100644 --- a/ui/src/components/IssueFiltersPopover.tsx +++ b/ui/src/components/IssueFiltersPopover.tsx @@ -344,9 +344,16 @@ export function IssueFiltersPopover({ ) : null} - {enableRoutineVisibilityFilter ? ( -
- Visibility +
+ Visibility + + {enableRoutineVisibilityFilter ? ( -
- ) : null} + ) : null} +
diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index 7edbee6df94..01d2479ea49 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -23,6 +23,7 @@ import { issuePriorityOrder, normalizeIssueFilterState, resolveIssueFilterWorkspaceId, + shouldIncludeIssueFilterWorkspaceOption, issueStatusOrder, type IssueFilterState, } from "../lib/issue-filters"; @@ -483,6 +484,10 @@ export function IssuesList({ } return map; }, [projects]); + const defaultProjectWorkspaceIds = useMemo( + () => new Set(defaultProjectWorkspaceIdByProjectId.values()), + [defaultProjectWorkspaceIdByProjectId], + ); const executionWorkspaceById = useMemo(() => { const map = new Map ({ + executionWorkspaceById, + defaultProjectWorkspaceIdByProjectId, + }), [defaultProjectWorkspaceIdByProjectId, executionWorkspaceById]); const workspaceNameMap = useMemo(() => { const map = new Map(); for (const [workspaceId, workspace] of projectWorkspaceById) { + if (!shouldIncludeIssueFilterWorkspaceOption({ id: workspaceId }, defaultProjectWorkspaceIds)) continue; map.set(workspaceId, workspace.name); } for (const [workspaceId, workspace] of executionWorkspaceById) { + if (!shouldIncludeIssueFilterWorkspaceOption({ + id: workspaceId, + mode: workspace.mode, + projectWorkspaceId: workspace.projectWorkspaceId, + }, defaultProjectWorkspaceIds)) continue; map.set(workspaceId, workspace.name); } return map; - }, [executionWorkspaceById, projectWorkspaceById]); + }, [defaultProjectWorkspaceIds, executionWorkspaceById, projectWorkspaceById]); const workspaceOptions = useMemo(() => { const options = new Map(); @@ -635,9 +650,27 @@ export function IssuesList({ const searchScopedIssues = normalizedIssueSearch.length > 0 && searchWithinLoadedIssues ? sourceIssues.filter((issue) => issueMatchesLocalSearch(issue, normalizedIssueSearch)) : sourceIssues; - const filteredByControls = applyIssueFilters(searchScopedIssues, viewState, currentUserId, enableRoutineVisibilityFilter); + const filteredByControls = applyIssueFilters( + searchScopedIssues, + viewState, + currentUserId, + enableRoutineVisibilityFilter, + liveIssueIds, + issueFilterWorkspaceContext, + ); return sortIssues(filteredByControls, viewState); - }, [boardIssues, issues, searchedIssues, searchWithinLoadedIssues, viewState, normalizedIssueSearch, currentUserId, enableRoutineVisibilityFilter]); + }, [ + boardIssues, + issues, + searchedIssues, + searchWithinLoadedIssues, + viewState, + normalizedIssueSearch, + currentUserId, + enableRoutineVisibilityFilter, + liveIssueIds, + issueFilterWorkspaceContext, + ]); const { data: labels } = useQuery({ queryKey: queryKeys.issues.labels(selectedCompanyId!), @@ -664,7 +697,10 @@ export function IssuesList({ .map((p) => ({ key: p, label: issueFilterLabel(p), items: groups[p]! })); } if (viewState.groupBy === "workspace") { - const groups = groupBy(filtered, (issue) => resolveIssueFilterWorkspaceId(issue) ?? "__no_workspace"); + const groups = groupBy( + filtered, + (issue) => resolveIssueFilterWorkspaceId(issue, issueFilterWorkspaceContext) ?? "__no_workspace", + ); return Object.keys(groups) .sort((a, b) => { // Groups with items first, "no workspace" last @@ -708,7 +744,17 @@ export function IssuesList({ : (agentName(key) ?? key.slice(0, 8)), items: groups[key]!, })); - }, [filtered, viewState.groupBy, agents, agentName, currentUserId, workspaceNameMap, issueTitleMap, companyUserLabelMap]); + }, [ + filtered, + issueFilterWorkspaceContext, + viewState.groupBy, + agents, + agentName, + currentUserId, + workspaceNameMap, + issueTitleMap, + companyUserLabelMap, + ]); useEffect(() => { if (viewState.viewMode !== "list") return; @@ -1125,7 +1171,7 @@ export function IssuesList({ columns={visibleTrailingIssueColumns} projectName={issueProject?.name ?? null} projectColor={issueProject?.color ?? null} - workspaceId={resolveIssueFilterWorkspaceId(issue)} + workspaceId={resolveIssueFilterWorkspaceId(issue, issueFilterWorkspaceContext)} workspaceName={resolveIssueWorkspaceName(issue, { executionWorkspaceById, projectWorkspaceById, diff --git a/ui/src/lib/inbox.test.ts b/ui/src/lib/inbox.test.ts index 6fef7bfbb9a..cae820df6cc 100644 --- a/ui/src/lib/inbox.test.ts +++ b/ui/src/lib/inbox.test.ts @@ -841,6 +841,7 @@ describe("inbox helpers", () => { labels: [], projects: [], workspaces: [], + liveOnly: false, hideRoutineExecutions: true, }, }).map((issue) => issue.id), @@ -860,6 +861,7 @@ describe("inbox helpers", () => { labels: [], projects: [], workspaces: [], + liveOnly: false, hideRoutineExecutions: true, }, }), @@ -879,6 +881,7 @@ describe("inbox helpers", () => { labels: [], projects: [], workspaces: [], + liveOnly: false, hideRoutineExecutions: true, }, }), @@ -944,6 +947,7 @@ describe("inbox helpers", () => { labels: ["label-1"], projects: ["project-1"], workspaces: ["workspace-1"], + liveOnly: true, hideRoutineExecutions: false, }, }); @@ -958,6 +962,7 @@ describe("inbox helpers", () => { labels: [], projects: [], workspaces: [], + liveOnly: false, hideRoutineExecutions: true, }, }); @@ -973,6 +978,7 @@ describe("inbox helpers", () => { labels: ["label-1"], projects: ["project-1"], workspaces: ["workspace-1"], + liveOnly: true, hideRoutineExecutions: false, }, }); @@ -987,6 +993,7 @@ describe("inbox helpers", () => { labels: [], projects: [], workspaces: [], + liveOnly: false, hideRoutineExecutions: true, }, }); @@ -1004,6 +1011,7 @@ describe("inbox helpers", () => { labels: null, projects: ["project-1"], workspaces: ["workspace-1", false], + liveOnly: "yes", hideRoutineExecutions: "yes", }, })); @@ -1019,6 +1027,7 @@ describe("inbox helpers", () => { labels: [], projects: ["project-1"], workspaces: ["workspace-1"], + liveOnly: false, hideRoutineExecutions: false, }, }); diff --git a/ui/src/lib/inbox.ts b/ui/src/lib/inbox.ts index 355b14d63de..37e0d90f7be 100644 --- a/ui/src/lib/inbox.ts +++ b/ui/src/lib/inbox.ts @@ -445,6 +445,7 @@ export function getInboxSearchSupplementIssues({ issueFilters, currentUserId, enableRoutineVisibilityFilter = false, + liveIssueIds, }: { query: string; filteredWorkItems: InboxWorkItem[]; @@ -453,6 +454,7 @@ export function getInboxSearchSupplementIssues({ issueFilters: IssueFilterState; currentUserId?: string | null; enableRoutineVisibilityFilter?: boolean; + liveIssueIds?: ReadonlySet; }): Issue[] { const normalizedQuery = query.trim(); if (!normalizedQuery) return []; @@ -462,7 +464,7 @@ export function getInboxSearchSupplementIssues({ .map((item) => item.issue.id), ...archivedSearchIssues.map((issue) => issue.id), ]); - return applyIssueFilters(remoteIssues, issueFilters, currentUserId, enableRoutineVisibilityFilter) + return applyIssueFilters(remoteIssues, issueFilters, currentUserId, enableRoutineVisibilityFilter, liveIssueIds) .filter((issue) => !visibleIssueIds.has(issue.id)); } diff --git a/ui/src/lib/issue-filters.test.ts b/ui/src/lib/issue-filters.test.ts index 523a2222d6e..504182b7655 100644 --- a/ui/src/lib/issue-filters.test.ts +++ b/ui/src/lib/issue-filters.test.ts @@ -2,7 +2,13 @@ import { describe, expect, it } from "vitest"; import type { Issue } from "@paperclipai/shared"; -import { applyIssueFilters, countActiveIssueFilters, defaultIssueFilterState } from "./issue-filters"; +import { + applyIssueFilters, + countActiveIssueFilters, + defaultIssueFilterState, + resolveIssueFilterWorkspaceId, + shouldIncludeIssueFilterWorkspaceOption, +} from "./issue-filters"; function makeIssue(overrides: Partial = {}): Issue { return { @@ -66,4 +72,100 @@ describe("issue filters", () => { creators: ["user:user-1"], })).toBe(1); }); + + it("filters issues to live issue ids when live-only is enabled", () => { + const issues = [ + makeIssue({ id: "live-issue" }), + makeIssue({ id: "idle-issue" }), + ]; + + const filtered = applyIssueFilters( + issues, + { ...defaultIssueFilterState, liveOnly: true }, + null, + false, + new Set(["live-issue"]), + ); + + expect(filtered.map((issue) => issue.id)).toEqual(["live-issue"]); + }); + + it("counts the live-only filter as an active filter group", () => { + expect(countActiveIssueFilters({ + ...defaultIssueFilterState, + liveOnly: true, + })).toBe(1); + }); + + it("does not treat default project workspaces as workspace filter matches", () => { + const issue = makeIssue({ + id: "default-workspace-issue", + projectId: "project-1", + projectWorkspaceId: "workspace-default", + }); + const workspaceContext = { + defaultProjectWorkspaceIdByProjectId: new Map([["project-1", "workspace-default"]]), + }; + + expect(resolveIssueFilterWorkspaceId(issue, workspaceContext)).toBeNull(); + expect(applyIssueFilters( + [issue], + { ...defaultIssueFilterState, workspaces: ["workspace-default"] }, + null, + false, + undefined, + workspaceContext, + )).toEqual([]); + }); + + it("does not treat shared default execution workspaces as workspace filter matches", () => { + const issue = makeIssue({ + id: "shared-default-issue", + projectId: "project-1", + projectWorkspaceId: "workspace-default", + executionWorkspaceId: "execution-shared-default", + }); + const workspaceContext = { + executionWorkspaceById: new Map([[ + "execution-shared-default", + { mode: "shared_workspace", projectWorkspaceId: "workspace-default" }, + ]]), + defaultProjectWorkspaceIdByProjectId: new Map([["project-1", "workspace-default"]]), + }; + + expect(resolveIssueFilterWorkspaceId(issue, workspaceContext)).toBeNull(); + expect(shouldIncludeIssueFilterWorkspaceOption( + { id: "execution-shared-default", mode: "shared_workspace", projectWorkspaceId: "workspace-default" }, + new Set(["workspace-default"]), + )).toBe(false); + }); + + it("keeps non-default project and isolated execution workspaces filterable", () => { + const featureIssue = makeIssue({ + id: "feature-issue", + projectId: "project-1", + projectWorkspaceId: "workspace-feature", + }); + const executionIssue = makeIssue({ + id: "execution-issue", + projectId: "project-1", + projectWorkspaceId: "workspace-default", + executionWorkspaceId: "execution-isolated", + }); + const workspaceContext = { + executionWorkspaceById: new Map([[ + "execution-isolated", + { mode: "isolated_workspace", projectWorkspaceId: "workspace-default" }, + ]]), + defaultProjectWorkspaceIdByProjectId: new Map([["project-1", "workspace-default"]]), + }; + + expect(resolveIssueFilterWorkspaceId(featureIssue, workspaceContext)).toBe("workspace-feature"); + expect(resolveIssueFilterWorkspaceId(executionIssue, workspaceContext)).toBe("execution-isolated"); + expect(shouldIncludeIssueFilterWorkspaceOption({ id: "workspace-feature" }, new Set(["workspace-default"]))).toBe(true); + expect(shouldIncludeIssueFilterWorkspaceOption( + { id: "execution-isolated", mode: "isolated_workspace", projectWorkspaceId: "workspace-default" }, + new Set(["workspace-default"]), + )).toBe(true); + }); }); diff --git a/ui/src/lib/issue-filters.ts b/ui/src/lib/issue-filters.ts index 4a64b839f3f..63ce019378d 100644 --- a/ui/src/lib/issue-filters.ts +++ b/ui/src/lib/issue-filters.ts @@ -1,5 +1,15 @@ import type { Issue } from "@paperclipai/shared"; +export type IssueFilterWorkspaceLookup = { + mode?: string | null; + projectWorkspaceId?: string | null; +}; + +export type IssueFilterWorkspaceContext = { + executionWorkspaceById?: ReadonlyMap; + defaultProjectWorkspaceIdByProjectId?: ReadonlyMap; +}; + export type IssueFilterState = { statuses: string[]; priorities: string[]; @@ -8,6 +18,7 @@ export type IssueFilterState = { labels: string[]; projects: string[]; workspaces: string[]; + liveOnly?: boolean; hideRoutineExecutions: boolean; }; @@ -19,6 +30,7 @@ export const defaultIssueFilterState: IssueFilterState = { labels: [], projects: [], workspaces: [], + liveOnly: false, hideRoutineExecutions: false, }; @@ -59,6 +71,7 @@ export function normalizeIssueFilterState(value: unknown): IssueFilterState { labels: normalizeIssueFilterValueArray(candidate.labels), projects: normalizeIssueFilterValueArray(candidate.projects), workspaces: normalizeIssueFilterValueArray(candidate.workspaces), + liveOnly: candidate.liveOnly === true, hideRoutineExecutions: candidate.hideRoutineExecutions === true, }; } @@ -68,9 +81,41 @@ export function toggleIssueFilterValue(values: string[], value: string): string[ } export function resolveIssueFilterWorkspaceId( - issue: Pick, + issue: Pick, + context: IssueFilterWorkspaceContext = {}, ): string | null { - return issue.executionWorkspaceId ?? issue.projectWorkspaceId ?? null; + const defaultProjectWorkspaceId = issue.projectId + ? context.defaultProjectWorkspaceIdByProjectId?.get(issue.projectId) ?? null + : null; + + if (issue.executionWorkspaceId) { + const executionWorkspace = context.executionWorkspaceById?.get(issue.executionWorkspaceId) ?? null; + const linkedProjectWorkspaceId = + executionWorkspace?.projectWorkspaceId ?? issue.projectWorkspaceId ?? null; + const isDefaultSharedExecutionWorkspace = + executionWorkspace?.mode === "shared_workspace" + && linkedProjectWorkspaceId != null + && linkedProjectWorkspaceId === defaultProjectWorkspaceId; + if (isDefaultSharedExecutionWorkspace) return null; + return issue.executionWorkspaceId; + } + + if (issue.projectWorkspaceId) { + if (issue.projectWorkspaceId === defaultProjectWorkspaceId) return null; + return issue.projectWorkspaceId; + } + + return null; +} + +export function shouldIncludeIssueFilterWorkspaceOption( + workspace: { id: string; mode?: string | null; projectWorkspaceId?: string | null }, + defaultProjectWorkspaceIds: ReadonlySet, +): boolean { + if (defaultProjectWorkspaceIds.has(workspace.id)) return false; + return !(workspace.mode === "shared_workspace" + && workspace.projectWorkspaceId != null + && defaultProjectWorkspaceIds.has(workspace.projectWorkspaceId)); } export function applyIssueFilters( @@ -78,8 +123,13 @@ export function applyIssueFilters( state: IssueFilterState, currentUserId?: string | null, enableRoutineVisibilityFilter = false, + liveIssueIds?: ReadonlySet, + workspaceContext: IssueFilterWorkspaceContext = {}, ): Issue[] { let result = issues; + if (state.liveOnly) { + result = result.filter((issue) => liveIssueIds?.has(issue.id) === true); + } if (enableRoutineVisibilityFilter && state.hideRoutineExecutions) { result = result.filter((issue) => issue.originKind !== "routine_execution"); } @@ -112,7 +162,7 @@ export function applyIssueFilters( } if (state.workspaces.length > 0) { result = result.filter((issue) => { - const workspaceId = resolveIssueFilterWorkspaceId(issue); + const workspaceId = resolveIssueFilterWorkspaceId(issue, workspaceContext); return workspaceId != null && state.workspaces.includes(workspaceId); }); } @@ -131,6 +181,7 @@ export function countActiveIssueFilters( if (state.labels.length > 0) count += 1; if (state.projects.length > 0) count += 1; if (state.workspaces.length > 0) count += 1; + if (state.liveOnly) count += 1; if (enableRoutineVisibilityFilter && state.hideRoutineExecutions) count += 1; return count; } diff --git a/ui/src/lib/liveIssueIds.test.ts b/ui/src/lib/liveIssueIds.test.ts index b4db99910e3..c94a3ff3ad0 100644 --- a/ui/src/lib/liveIssueIds.test.ts +++ b/ui/src/lib/liveIssueIds.test.ts @@ -57,6 +57,19 @@ describe("collectLiveIssueIds", () => { adapterType: "codex_local", issueId: "issue-2", }, + { + id: "run-5", + status: "succeeded", + invocationSource: "scheduler", + triggerDetail: null, + startedAt: "2026-04-20T10:04:00.000Z", + finishedAt: "2026-04-20T10:05:00.000Z", + createdAt: "2026-04-20T10:04:00.000Z", + agentId: "agent-5", + agentName: "Done", + adapterType: "codex_local", + issueId: "completed-issue", + }, ]; expect([...collectLiveIssueIds(liveRuns)]).toEqual(["issue-1", "issue-2"]); diff --git a/ui/src/lib/liveIssueIds.ts b/ui/src/lib/liveIssueIds.ts index 40865fe95ae..5a98875096a 100644 --- a/ui/src/lib/liveIssueIds.ts +++ b/ui/src/lib/liveIssueIds.ts @@ -1,9 +1,13 @@ import type { LiveRunForIssue } from "../api/heartbeats"; +function isLiveRunStatus(status: string): boolean { + return status === "queued" || status === "running"; +} + export function collectLiveIssueIds(liveRuns: readonly LiveRunForIssue[] | null | undefined): Set { const ids = new Set(); for (const run of liveRuns ?? []) { - if (run.issueId) ids.add(run.issueId); + if (run.issueId && isLiveRunStatus(run.status)) ids.add(run.issueId); } return ids; } diff --git a/ui/src/pages/ExecutionWorkspaceDetail.tsx b/ui/src/pages/ExecutionWorkspaceDetail.tsx index 0e5aedaf8ce..78dd1700049 100644 --- a/ui/src/pages/ExecutionWorkspaceDetail.tsx +++ b/ui/src/pages/ExecutionWorkspaceDetail.tsx @@ -25,6 +25,7 @@ import { } from "../components/WorkspaceRuntimeControls"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useCompany } from "../context/CompanyContext"; +import { collectLiveIssueIds } from "../lib/liveIssueIds"; import { queryKeys } from "../lib/queryKeys"; import { cn, formatDateTime, issueUrl, projectRouteRef, projectWorkspaceUrl } from "../lib/utils"; @@ -271,13 +272,7 @@ function ExecutionWorkspaceIssuesList({ refetchInterval: 5000, }); - const liveIssueIds = useMemo(() => { - const ids = new Set(); - for (const run of liveRuns ?? []) { - if (run.issueId) ids.add(run.issueId); - } - return ids; - }, [liveRuns]); + const liveIssueIds = useMemo(() => collectLiveIssueIds(liveRuns), [liveRuns]); const updateIssue = useMutation({ mutationFn: ({ id, data }: { id: string; data: Record }) => issuesApi.update(id, data), diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index a2d1b54142b..aa7707a03d1 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -23,6 +23,7 @@ import { countActiveIssueFilters, type IssueFilterState, } from "../lib/issue-filters"; +import { collectLiveIssueIds } from "../lib/liveIssueIds"; import { formatAssigneeUserLabel } from "../lib/assignees"; import { buildCompanyUserLabelMap, buildCompanyUserProfileMap } from "../lib/company-members"; import { @@ -826,6 +827,7 @@ export function Inbox() { enabled: !!selectedCompanyId, refetchInterval: 5000, }); + const liveIssueIds = useMemo(() => collectLiveIssueIds(liveRuns), [liveRuns]); const { data: companyMembers } = useQuery({ queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId!), queryFn: () => accessApi.listUserDirectory(selectedCompanyId!), @@ -845,12 +847,12 @@ export function Inbox() { const mineIssues = useMemo(() => getRecentTouchedIssues(mineIssuesRaw), [mineIssuesRaw]); const touchedIssues = useMemo(() => getRecentTouchedIssues(touchedIssuesRaw), [touchedIssuesRaw]); const visibleMineIssues = useMemo( - () => applyIssueFilters(mineIssues, issueFilters, currentUserId, true), - [mineIssues, issueFilters, currentUserId], + () => applyIssueFilters(mineIssues, issueFilters, currentUserId, true, liveIssueIds), + [mineIssues, issueFilters, currentUserId, liveIssueIds], ); const visibleTouchedIssues = useMemo( - () => applyIssueFilters(touchedIssues, issueFilters, currentUserId, true), - [touchedIssues, issueFilters, currentUserId], + () => applyIssueFilters(touchedIssues, issueFilters, currentUserId, true, liveIssueIds), + [touchedIssues, issueFilters, currentUserId, liveIssueIds], ); const unreadTouchedIssues = useMemo( () => visibleTouchedIssues.filter((issue) => issue.isUnreadForMe), @@ -1004,14 +1006,6 @@ export function Inbox() { ), [heartbeatRuns, dismissedAtByKey], ); - const liveIssueIds = useMemo(() => { - const ids = new Set(); - for (const run of liveRuns ?? []) { - if (run.issueId) ids.add(run.issueId); - } - return ids; - }, [liveRuns]); - const approvalsToRender = useMemo(() => { let filtered = getApprovalsForTab(approvals ?? [], tab, allApprovalFilter, currentUserId); if (tab === "mine") { @@ -1159,12 +1153,14 @@ export function Inbox() { issueFilters, currentUserId, enableRoutineVisibilityFilter: true, + liveIssueIds, }), [ archivedSearchIssues, currentUserId, filteredWorkItems, issueFilters, + liveIssueIds, normalizedSearchQuery, remoteIssueSearchResults, ], diff --git a/ui/src/pages/ProjectDetail.tsx b/ui/src/pages/ProjectDetail.tsx index 5fc126310a5..e49f066839a 100644 --- a/ui/src/pages/ProjectDetail.tsx +++ b/ui/src/pages/ProjectDetail.tsx @@ -24,6 +24,7 @@ import { PageSkeleton } from "../components/PageSkeleton"; import { PageTabBar } from "../components/PageTabBar"; import { ProjectWorkspacesContent } from "../components/ProjectWorkspacesContent"; import { buildProjectWorkspaceSummaries } from "../lib/project-workspaces-tab"; +import { collectLiveIssueIds } from "../lib/liveIssueIds"; import { projectRouteRef } from "../lib/utils"; import { Button } from "@/components/ui/button"; import { Tabs } from "@/components/ui/tabs"; @@ -175,13 +176,7 @@ function ProjectIssuesList({ projectId, companyId }: { projectId: string; compan enabled: !!companyId, }); - const liveIssueIds = useMemo(() => { - const ids = new Set(); - for (const run of liveRuns ?? []) { - if (run.issueId) ids.add(run.issueId); - } - return ids; - }, [liveRuns]); + const liveIssueIds = useMemo(() => collectLiveIssueIds(liveRuns), [liveRuns]); const { data: issues, isLoading, error } = useQuery({ queryKey: queryKeys.issues.listByProject(companyId, projectId), diff --git a/ui/src/pages/Routines.tsx b/ui/src/pages/Routines.tsx index 79d6b707ae2..3c00462fbca 100644 --- a/ui/src/pages/Routines.tsx +++ b/ui/src/pages/Routines.tsx @@ -13,6 +13,7 @@ import { useToastActions } from "../context/ToastContext"; import { queryKeys } from "../lib/queryKeys"; import { groupBy } from "../lib/groupBy"; import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb"; +import { collectLiveIssueIds } from "../lib/liveIssueIds"; import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees"; import { getRecentProjectIds, trackRecentProject } from "../lib/recent-projects"; import { ToggleSwitch } from "@/components/ui/toggle-switch"; @@ -492,13 +493,7 @@ export function Routines() { () => new Map((projects ?? []).map((project) => [project.id, project])), [projects], ); - const liveIssueIds = useMemo(() => { - const ids = new Set(); - for (const run of liveRuns ?? []) { - if (run.issueId) ids.add(run.issueId); - } - return ids; - }, [liveRuns]); + const liveIssueIds = useMemo(() => collectLiveIssueIds(liveRuns), [liveRuns]); const routineGroups = useMemo( () => buildRoutineGroups(routines ?? [], routineViewState.groupBy, projectById, agentById), [agentById, projectById, routineViewState.groupBy, routines],