Add live run issue filter

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-04-24 13:39:10 -05:00
1 parent 2d57b8dafa
commit fbbf2e9c41
13 files changed
+266 -50

No files matched your search

@@ -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");
});
});
+12 -5
View File
@@ -344,9 +344,16 @@ export function IssueFiltersPopover({
</div>
) : null}
{enableRoutineVisibilityFilter ? (
<div className="space-y-1">
<span className="text-xs text-muted-foreground">Visibility</span>
<div className="space-y-1">
<span className="text-xs text-muted-foreground">Visibility</span>
<label className="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1 hover:bg-accent/50">
<Checkbox
checked={state.liveOnly}
onCheckedChange={(checked) => onChange({ liveOnly: checked === true })}
/>
<span className="text-sm">Live runs only</span>
</label>
{enableRoutineVisibilityFilter ? (
<label className="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1 hover:bg-accent/50">
<Checkbox
checked={state.hideRoutineExecutions}
@@ -354,8 +361,8 @@ export function IssueFiltersPopover({
/>
<span className="text-sm">Hide routine runs</span>
</label>
</div>
) : null}
) : null}
</div>
</div>
</div>
</div>
+52 -6
View File
@@ -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<string, {
@@ -499,17 +504,27 @@ export function IssuesList({
}
return map;
}, [executionWorkspaces]);
const issueFilterWorkspaceContext = useMemo(() => ({
executionWorkspaceById,
defaultProjectWorkspaceIdByProjectId,
}), [defaultProjectWorkspaceIdByProjectId, executionWorkspaceById]);
const workspaceNameMap = useMemo(() => {
const map = new Map<string, string>();
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<string, string>();
@@ -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,
+9
View File
@@ -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,
},
});
+3 -1
View File
@@ -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<string>;
}): 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));
}
+103 -1
View File
@@ -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> = {}): 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);
});
});
+54 -3
View File
@@ -1,5 +1,15 @@
import type { Issue } from "@paperclipai/shared";
export type IssueFilterWorkspaceLookup = {
mode?: string | null;
projectWorkspaceId?: string | null;
};
export type IssueFilterWorkspaceContext = {
executionWorkspaceById?: ReadonlyMap<string, IssueFilterWorkspaceLookup>;
defaultProjectWorkspaceIdByProjectId?: ReadonlyMap<string, string>;
};
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, "executionWorkspaceId" | "projectWorkspaceId">,
issue: Pick<Issue, "executionWorkspaceId" | "projectId" | "projectWorkspaceId">,
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<string>,
): 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<string>,
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;
}
+13
View File
@@ -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"]);
+5 -1
View File
@@ -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<string> {
const ids = new Set<string>();
for (const run of liveRuns ?? []) {
if (run.issueId) ids.add(run.issueId);
if (run.issueId && isLiveRunStatus(run.status)) ids.add(run.issueId);
}
return ids;
}
+2 -7
View File
@@ -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<string>();
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<string, unknown> }) => issuesApi.update(id, data),
+8 -12
View File
@@ -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<string>();
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,
],
+2 -7
View File
@@ -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<string>();
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),
+2 -7
View File
@@ -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<string>();
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],