From a2059d118e2c83aca6af17bac18f140235086709 Mon Sep 17 00:00:00 2001 From: testvalue Date: Sun, 19 Apr 2026 15:35:11 -0400 Subject: [PATCH 01/11] fix(view): locked repos always visible, de-emphasized when empty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ensureLockedRepoGroups injects empty group stubs for locked repos filtered out by tab filters, so they remain in the group list at their locked position. Empty groups render collapsed with muted styling and 'No items match current filters' text. This eliminates the class of bugs where invisible locked repos cause confusing reorder behavior — moveLockedRepo stays with simple adjacent swap since all locked repos are always present. Closes #71 --- src/app/components/dashboard/ActionsTab.tsx | 30 +++++++-- src/app/components/dashboard/IssuesTab.tsx | 58 +++++++++++------ .../components/dashboard/PullRequestsTab.tsx | 30 +++++++-- src/app/lib/grouping.ts | 11 ++++ tests/lib/grouping.test.ts | 65 ++++++++++++++++++- 5 files changed, 158 insertions(+), 36 deletions(-) diff --git a/src/app/components/dashboard/ActionsTab.tsx b/src/app/components/dashboard/ActionsTab.tsx index 577ea195..1e997259 100644 --- a/src/app/components/dashboard/ActionsTab.tsx +++ b/src/app/components/dashboard/ActionsTab.tsx @@ -11,7 +11,7 @@ import RepoGroupHeader from "../shared/RepoGroupHeader"; import ExpandCollapseButtons from "../shared/ExpandCollapseButtons"; import RepoLockControls from "../shared/RepoLockControls"; import RepoGitHubLink from "../shared/RepoGitHubLink"; -import { orderRepoGroups } from "../../lib/grouping"; +import { orderRepoGroups, ensureLockedRepoGroups } from "../../lib/grouping"; import { createReorderHighlight } from "../../lib/reorderHighlight"; import { createFlashDetection } from "../../lib/flashDetection"; @@ -201,9 +201,15 @@ export default function ActionsTab(props: ActionsTabProps) { }); }); - const repoGroups = createMemo(() => - orderRepoGroups(groupRuns(filteredRuns()), viewState.lockedRepos) - ); + const repoGroups = createMemo(() => { + const groups = groupRuns(filteredRuns()); + const withLocked = ensureLockedRepoGroups( + groups, + viewState.lockedRepos, + (name) => ({ repoFullName: name, workflows: [] }), + ); + return orderRepoGroups(withLocked, viewState.lockedRepos); + }); createEffect(() => { const names = activeRepoNames(); @@ -271,7 +277,8 @@ export default function ActionsTab(props: ActionsTabProps) { 0}> {(repoGroup) => { - const isExpanded = () => !!viewState.expandedRepos.actions[repoGroup.repoFullName]; + const isEmpty = () => repoGroup.workflows.length === 0; + const isExpanded = () => !isEmpty() && !!viewState.expandedRepos.actions[repoGroup.repoFullName]; const sortedWorkflows = createMemo(() => sortWorkflowsByStatus(repoGroup.workflows) @@ -293,12 +300,12 @@ export default function ActionsTab(props: ActionsTabProps) { }); return ( -
+
toggleExpandedRepo("actions", repoGroup.repoFullName)} + onToggle={() => { if (!isEmpty()) toggleExpandedRepo("actions", repoGroup.repoFullName); }} trailing={ <> @@ -306,6 +313,14 @@ export default function ActionsTab(props: ActionsTabProps) { } collapsedSummary={ + + No items match current filters + + } + > {workflowCounts().total} workflow{workflowCounts().total !== 1 ? "s" : ""} 0 || workflowCounts().failed > 0 || workflowCounts().running > 0}> @@ -327,6 +342,7 @@ export default function ActionsTab(props: ActionsTabProps) { + } /> diff --git a/src/app/components/dashboard/IssuesTab.tsx b/src/app/components/dashboard/IssuesTab.tsx index 60013914..1ec93d5d 100644 --- a/src/app/components/dashboard/IssuesTab.tsx +++ b/src/app/components/dashboard/IssuesTab.tsx @@ -13,7 +13,7 @@ import SkeletonRows from "../shared/SkeletonRows"; import ExpandCollapseButtons from "../shared/ExpandCollapseButtons"; import { deriveInvolvementRoles } from "../../lib/format"; import RepoGroupHeader from "../shared/RepoGroupHeader"; -import { groupByRepo, computePageLayout, slicePageGroups, orderRepoGroups, isUserInvolved } from "../../lib/grouping"; +import { groupByRepo, computePageLayout, slicePageGroups, orderRepoGroups, ensureLockedRepoGroups, isUserInvolved } from "../../lib/grouping"; import { createReorderHighlight } from "../../lib/reorderHighlight"; import RepoLockControls from "../shared/RepoLockControls"; import RepoGitHubLink from "../shared/RepoGitHubLink"; @@ -191,9 +191,15 @@ export default function IssuesTab(props: IssuesTabProps) { const filteredSorted = createMemo(() => filteredSortedWithMeta().items); const issueMeta = createMemo(() => filteredSortedWithMeta().meta); - const repoGroups = createMemo(() => - orderRepoGroups(groupByRepo(filteredSorted()), viewState.lockedRepos) - ); + const repoGroups = createMemo(() => { + const groups = groupByRepo(filteredSorted()); + const withLocked = ensureLockedRepoGroups( + groups, + viewState.lockedRepos, + (name) => ({ repoFullName: name, items: [] as typeof groups[0]["items"] }), + ); + return orderRepoGroups(withLocked, viewState.lockedRepos); + }); const pageLayout = createMemo(() => computePageLayout(repoGroups(), config.itemsPerPage)); const pageCount = createMemo(() => pageLayout().pageCount); const pageGroups = createMemo(() => @@ -334,7 +340,8 @@ export default function IssuesTab(props: IssuesTabProps) {
{(repoGroup) => { - const isExpanded = () => !!viewState.expandedRepos.issues[repoGroup.repoFullName]; + const isEmpty = () => repoGroup.items.length === 0; + const isExpanded = () => !isEmpty() && !!viewState.expandedRepos.issues[repoGroup.repoFullName]; const roleSummary = createMemo(() => { const counts: Record = {}; @@ -350,13 +357,13 @@ export default function IssuesTab(props: IssuesTabProps) { }); return ( -
+
toggleExpandedRepo("issues", repoGroup.repoFullName)} + onToggle={() => { if (!isEmpty()) toggleExpandedRepo("issues", repoGroup.repoFullName); }} badges={ @@ -371,20 +378,29 @@ export default function IssuesTab(props: IssuesTabProps) { } collapsedSummary={ - - {repoGroup.items.length} {repoGroup.items.length === 1 ? "issue" : "issues"} - - {([role, count]) => ( - - {role} ×{count} - - )} - - + + No items match current filters + + } + > + + {repoGroup.items.length} {repoGroup.items.length === 1 ? "issue" : "issues"} + + {([role, count]) => ( + + {role} ×{count} + + )} + + + } /> diff --git a/src/app/components/dashboard/PullRequestsTab.tsx b/src/app/components/dashboard/PullRequestsTab.tsx index 094b7f1e..6aff4c94 100644 --- a/src/app/components/dashboard/PullRequestsTab.tsx +++ b/src/app/components/dashboard/PullRequestsTab.tsx @@ -17,7 +17,7 @@ import SizeBadge from "../shared/SizeBadge"; import RoleBadge from "../shared/RoleBadge"; import SkeletonRows from "../shared/SkeletonRows"; import RepoGroupHeader from "../shared/RepoGroupHeader"; -import { groupByRepo, computePageLayout, slicePageGroups, orderRepoGroups, isUserInvolved } from "../../lib/grouping"; +import { groupByRepo, computePageLayout, slicePageGroups, orderRepoGroups, ensureLockedRepoGroups, isUserInvolved } from "../../lib/grouping"; import { createReorderHighlight } from "../../lib/reorderHighlight"; import { createFlashDetection } from "../../lib/flashDetection"; import RepoLockControls from "../shared/RepoLockControls"; @@ -288,9 +288,15 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { const filteredSorted = createMemo(() => filteredSortedWithMeta().items); const prMeta = createMemo(() => filteredSortedWithMeta().meta); - const repoGroups = createMemo(() => - orderRepoGroups(groupByRepo(filteredSorted()), viewState.lockedRepos) - ); + const repoGroups = createMemo(() => { + const groups = groupByRepo(filteredSorted()); + const withLocked = ensureLockedRepoGroups( + groups, + viewState.lockedRepos, + (name) => ({ repoFullName: name, items: [] as typeof groups[0]["items"] }), + ); + return orderRepoGroups(withLocked, viewState.lockedRepos); + }); const pageLayout = createMemo(() => computePageLayout(repoGroups(), config.itemsPerPage)); const pageCount = createMemo(() => pageLayout().pageCount); const pageGroups = createMemo(() => @@ -428,7 +434,8 @@ export default function PullRequestsTab(props: PullRequestsTabProps) {
{(repoGroup) => { - const isExpanded = () => !!viewState.expandedRepos.pullRequests[repoGroup.repoFullName]; + const isEmpty = () => repoGroup.items.length === 0; + const isExpanded = () => !isEmpty() && !!viewState.expandedRepos.pullRequests[repoGroup.repoFullName]; const summaryMeta = createMemo(() => { const checks = { success: 0, failure: 0, pending: 0, conflict: 0 }; @@ -457,13 +464,13 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { }); return ( -
+
toggleExpandedRepo("pullRequests", repoGroup.repoFullName)} + onToggle={() => { if (!isEmpty()) toggleExpandedRepo("pullRequests", repoGroup.repoFullName); }} badges={ @@ -478,6 +485,14 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { } collapsedSummary={ + + No items match current filters + + } + > {repoGroup.items.length} {repoGroup.items.length === 1 ? "PR" : "PRs"} 0}> @@ -534,6 +549,7 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { )} + } /> diff --git a/src/app/lib/grouping.ts b/src/app/lib/grouping.ts index 3e771a8e..5801210d 100644 --- a/src/app/lib/grouping.ts +++ b/src/app/lib/grouping.ts @@ -50,6 +50,17 @@ export function slicePageGroups( return groups.slice(start, end); } +export function ensureLockedRepoGroups( + groups: G[], + lockedOrder: readonly string[], + emptyFactory: (repoFullName: string) => G, +): G[] { + const present = new Set(groups.map(g => g.repoFullName)); + const missing = lockedOrder.filter(name => !present.has(name)); + if (missing.length === 0) return groups; + return [...groups, ...missing.map(emptyFactory)]; +} + export function orderRepoGroups( groups: G[], lockedOrder: string[] diff --git a/tests/lib/grouping.test.ts b/tests/lib/grouping.test.ts index 79165c94..e0b01ecb 100644 --- a/tests/lib/grouping.test.ts +++ b/tests/lib/grouping.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { groupByRepo, computePageLayout, slicePageGroups, isUserInvolved, type RepoGroup } from "../../src/app/lib/grouping"; +import { groupByRepo, computePageLayout, slicePageGroups, isUserInvolved, ensureLockedRepoGroups, type RepoGroup } from "../../src/app/lib/grouping"; interface Item { repoFullName: string; @@ -224,3 +224,66 @@ describe("slicePageGroups", () => { expect(result[1].repoFullName).toBe("org/b"); }); }); + +describe("ensureLockedRepoGroups", () => { + const emptyFactory = (name: string): RepoGroup => ({ + repoFullName: name, + items: [], + }); + + it("returns groups unchanged when all locked repos are present", () => { + const groups = [makeGroup("org/a", 3), makeGroup("org/b", 2)]; + const result = ensureLockedRepoGroups(groups, ["org/a", "org/b"], emptyFactory); + expect(result).toBe(groups); // same reference — no copy + }); + + it("injects empty stubs for missing locked repos", () => { + const groups = [makeGroup("org/a", 3)]; + const result = ensureLockedRepoGroups(groups, ["org/a", "org/b", "org/c"], emptyFactory); + expect(result).toHaveLength(3); + expect(result[0].repoFullName).toBe("org/a"); + expect(result[0].items).toHaveLength(3); + expect(result[1].repoFullName).toBe("org/b"); + expect(result[1].items).toHaveLength(0); + expect(result[2].repoFullName).toBe("org/c"); + expect(result[2].items).toHaveLength(0); + }); + + it("preserves existing groups in original order with stubs appended", () => { + const groups = [makeGroup("org/x", 1), makeGroup("org/y", 2)]; + const result = ensureLockedRepoGroups(groups, ["org/missing"], emptyFactory); + expect(result).toHaveLength(3); + expect(result[0].repoFullName).toBe("org/x"); + expect(result[1].repoFullName).toBe("org/y"); + expect(result[2].repoFullName).toBe("org/missing"); + }); + + it("no-op when lockedOrder is empty", () => { + const groups = [makeGroup("org/a", 3)]; + const result = ensureLockedRepoGroups(groups, [], emptyFactory); + expect(result).toBe(groups); + }); + + it("no-op when groups is empty and lockedOrder is empty", () => { + const result = ensureLockedRepoGroups([], [], emptyFactory); + expect(result).toEqual([]); + }); + + it("injects all locked repos when groups is empty", () => { + const result = ensureLockedRepoGroups([], ["org/a", "org/b"], emptyFactory); + expect(result).toHaveLength(2); + expect(result[0].repoFullName).toBe("org/a"); + expect(result[0].items).toHaveLength(0); + expect(result[1].repoFullName).toBe("org/b"); + expect(result[1].items).toHaveLength(0); + }); + + it("works with custom factory for different group shapes", () => { + interface WfGroup { repoFullName: string; workflows: string[] } + const wfFactory = (name: string): WfGroup => ({ repoFullName: name, workflows: [] }); + const groups: WfGroup[] = [{ repoFullName: "org/a", workflows: ["ci"] }]; + const result = ensureLockedRepoGroups(groups, ["org/a", "org/b"], wfFactory); + expect(result).toHaveLength(2); + expect(result[1].workflows).toEqual([]); + }); +}); From 20ac6a46c7fc7f8c1e43e3a27a25fba13a00ebc7 Mon Sep 17 00:00:00 2001 From: testvalue Date: Sun, 19 Apr 2026 15:59:08 -0400 Subject: [PATCH 02/11] fix(ui): minimal compact row for empty locked repos Empty locked repos now render as a single compact row with just the repo name and lock controls. Removes chevron, badges, icons, and summary text. Reduced vertical padding and muted styling. --- src/app/components/dashboard/ActionsTab.tsx | 23 +++++---- src/app/components/dashboard/IssuesTab.tsx | 51 ++++++++++--------- .../components/dashboard/PullRequestsTab.tsx | 23 +++++---- 3 files changed, 50 insertions(+), 47 deletions(-) diff --git a/src/app/components/dashboard/ActionsTab.tsx b/src/app/components/dashboard/ActionsTab.tsx index 1e997259..d69e0605 100644 --- a/src/app/components/dashboard/ActionsTab.tsx +++ b/src/app/components/dashboard/ActionsTab.tsx @@ -300,12 +300,21 @@ export default function ActionsTab(props: ActionsTabProps) { }); return ( -
+ + {repoGroup.repoFullName} + +
+ } + > +
{ if (!isEmpty()) toggleExpandedRepo("actions", repoGroup.repoFullName); }} + onToggle={() => toggleExpandedRepo("actions", repoGroup.repoFullName)} trailing={ <> @@ -313,14 +322,6 @@ export default function ActionsTab(props: ActionsTabProps) { } collapsedSummary={ - - No items match current filters - - } - > {workflowCounts().total} workflow{workflowCounts().total !== 1 ? "s" : ""} 0 || workflowCounts().failed > 0 || workflowCounts().running > 0}> @@ -342,7 +343,6 @@ export default function ActionsTab(props: ActionsTabProps) { - } /> @@ -382,6 +382,7 @@ export default function ActionsTab(props: ActionsTabProps) {
+ ); }} diff --git a/src/app/components/dashboard/IssuesTab.tsx b/src/app/components/dashboard/IssuesTab.tsx index 1ec93d5d..325ce48a 100644 --- a/src/app/components/dashboard/IssuesTab.tsx +++ b/src/app/components/dashboard/IssuesTab.tsx @@ -357,13 +357,22 @@ export default function IssuesTab(props: IssuesTabProps) { }); return ( -
+ + {repoGroup.repoFullName} + +
+ } + > +
{ if (!isEmpty()) toggleExpandedRepo("issues", repoGroup.repoFullName); }} + onToggle={() => toggleExpandedRepo("issues", repoGroup.repoFullName)} badges={ @@ -378,29 +387,20 @@ export default function IssuesTab(props: IssuesTabProps) { } collapsedSummary={ - - No items match current filters - - } - > - - {repoGroup.items.length} {repoGroup.items.length === 1 ? "issue" : "issues"} - - {([role, count]) => ( - - {role} ×{count} - - )} - - - + + {repoGroup.items.length} {repoGroup.items.length === 1 ? "issue" : "issues"} + + {([role, count]) => ( + + {role} ×{count} + + )} + + } /> @@ -444,6 +444,7 @@ export default function IssuesTab(props: IssuesTabProps) {
+ ); }}
diff --git a/src/app/components/dashboard/PullRequestsTab.tsx b/src/app/components/dashboard/PullRequestsTab.tsx index 6aff4c94..b6094a8a 100644 --- a/src/app/components/dashboard/PullRequestsTab.tsx +++ b/src/app/components/dashboard/PullRequestsTab.tsx @@ -464,13 +464,22 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { }); return ( -
+ + {repoGroup.repoFullName} + +
+ } + > +
{ if (!isEmpty()) toggleExpandedRepo("pullRequests", repoGroup.repoFullName); }} + onToggle={() => toggleExpandedRepo("pullRequests", repoGroup.repoFullName)} badges={ @@ -485,14 +494,6 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { } collapsedSummary={ - - No items match current filters - - } - > {repoGroup.items.length} {repoGroup.items.length === 1 ? "PR" : "PRs"} 0}> @@ -549,7 +550,6 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { )} - } /> @@ -667,6 +667,7 @@ export default function PullRequestsTab(props: PullRequestsTabProps) {
+
); }} From 83fa4c2b985e230c1dc0cca070c65bef33c5f0f6 Mon Sep 17 00:00:00 2001 From: testvalue Date: Sun, 19 Apr 2026 16:11:07 -0400 Subject: [PATCH 03/11] fix(ui): reserve chevron space in empty locked repo rows Adds invisible spacer matching ChevronIcon dimensions (h-3.5 w-3.5) so empty rows align with populated repo group headers. --- src/app/components/dashboard/ActionsTab.tsx | 3 ++- src/app/components/dashboard/IssuesTab.tsx | 3 ++- src/app/components/dashboard/PullRequestsTab.tsx | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/app/components/dashboard/ActionsTab.tsx b/src/app/components/dashboard/ActionsTab.tsx index d69e0605..4226b83a 100644 --- a/src/app/components/dashboard/ActionsTab.tsx +++ b/src/app/components/dashboard/ActionsTab.tsx @@ -303,7 +303,8 @@ export default function ActionsTab(props: ActionsTabProps) { +
+ {repoGroup.repoFullName}
diff --git a/src/app/components/dashboard/IssuesTab.tsx b/src/app/components/dashboard/IssuesTab.tsx index 325ce48a..6dc22c23 100644 --- a/src/app/components/dashboard/IssuesTab.tsx +++ b/src/app/components/dashboard/IssuesTab.tsx @@ -360,7 +360,8 @@ export default function IssuesTab(props: IssuesTabProps) { +
+ {repoGroup.repoFullName}
diff --git a/src/app/components/dashboard/PullRequestsTab.tsx b/src/app/components/dashboard/PullRequestsTab.tsx index b6094a8a..5e72868e 100644 --- a/src/app/components/dashboard/PullRequestsTab.tsx +++ b/src/app/components/dashboard/PullRequestsTab.tsx @@ -467,7 +467,8 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { +
+ {repoGroup.repoFullName}
From 44bfb97d26ae996750135d9f0dee5eaafefed7c4 Mon Sep 17 00:00:00 2001 From: testvalue Date: Sun, 19 Apr 2026 16:12:11 -0400 Subject: [PATCH 04/11] fix(ui): adds GitHub link to empty locked rows for trailing alignment --- src/app/components/dashboard/ActionsTab.tsx | 1 + src/app/components/dashboard/IssuesTab.tsx | 1 + src/app/components/dashboard/PullRequestsTab.tsx | 1 + 3 files changed, 3 insertions(+) diff --git a/src/app/components/dashboard/ActionsTab.tsx b/src/app/components/dashboard/ActionsTab.tsx index 4226b83a..d1033313 100644 --- a/src/app/components/dashboard/ActionsTab.tsx +++ b/src/app/components/dashboard/ActionsTab.tsx @@ -306,6 +306,7 @@ export default function ActionsTab(props: ActionsTabProps) {
{repoGroup.repoFullName} +
} diff --git a/src/app/components/dashboard/IssuesTab.tsx b/src/app/components/dashboard/IssuesTab.tsx index 6dc22c23..b88b618f 100644 --- a/src/app/components/dashboard/IssuesTab.tsx +++ b/src/app/components/dashboard/IssuesTab.tsx @@ -363,6 +363,7 @@ export default function IssuesTab(props: IssuesTabProps) {
{repoGroup.repoFullName} +
} diff --git a/src/app/components/dashboard/PullRequestsTab.tsx b/src/app/components/dashboard/PullRequestsTab.tsx index 5e72868e..07b725df 100644 --- a/src/app/components/dashboard/PullRequestsTab.tsx +++ b/src/app/components/dashboard/PullRequestsTab.tsx @@ -470,6 +470,7 @@ export default function PullRequestsTab(props: PullRequestsTabProps) {
{repoGroup.repoFullName} +
} From 1e46d7edfa1ab44b71ca5d71df413b0783791254 Mon Sep 17 00:00:00 2001 From: testvalue Date: Sun, 19 Apr 2026 16:15:28 -0400 Subject: [PATCH 05/11] fix(ui): adds group/repo-header class to empty rows for hover reveals --- src/app/components/dashboard/ActionsTab.tsx | 2 +- src/app/components/dashboard/IssuesTab.tsx | 2 +- src/app/components/dashboard/PullRequestsTab.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/components/dashboard/ActionsTab.tsx b/src/app/components/dashboard/ActionsTab.tsx index d1033313..dcb48ec1 100644 --- a/src/app/components/dashboard/ActionsTab.tsx +++ b/src/app/components/dashboard/ActionsTab.tsx @@ -303,7 +303,7 @@ export default function ActionsTab(props: ActionsTabProps) { +
{repoGroup.repoFullName} diff --git a/src/app/components/dashboard/IssuesTab.tsx b/src/app/components/dashboard/IssuesTab.tsx index b88b618f..32aeec0a 100644 --- a/src/app/components/dashboard/IssuesTab.tsx +++ b/src/app/components/dashboard/IssuesTab.tsx @@ -360,7 +360,7 @@ export default function IssuesTab(props: IssuesTabProps) { +
{repoGroup.repoFullName} diff --git a/src/app/components/dashboard/PullRequestsTab.tsx b/src/app/components/dashboard/PullRequestsTab.tsx index 07b725df..f1cb384c 100644 --- a/src/app/components/dashboard/PullRequestsTab.tsx +++ b/src/app/components/dashboard/PullRequestsTab.tsx @@ -467,7 +467,7 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { +
{repoGroup.repoFullName} From e225c16ce22a770606a45a33d346e8139c334586 Mon Sep 17 00:00:00 2001 From: testvalue Date: Sun, 19 Apr 2026 16:18:02 -0400 Subject: [PATCH 06/11] fix(ui): matches RepoGroupHeader layout structure for trailing alignment Moves px-4 from outer container to inner flex-1 span, mirroring how RepoGroupHeader separates button padding from trailing content. --- src/app/components/dashboard/ActionsTab.tsx | 8 +++++--- src/app/components/dashboard/IssuesTab.tsx | 8 +++++--- src/app/components/dashboard/PullRequestsTab.tsx | 8 +++++--- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/app/components/dashboard/ActionsTab.tsx b/src/app/components/dashboard/ActionsTab.tsx index dcb48ec1..50cdde2c 100644 --- a/src/app/components/dashboard/ActionsTab.tsx +++ b/src/app/components/dashboard/ActionsTab.tsx @@ -303,9 +303,11 @@ export default function ActionsTab(props: ActionsTabProps) { - - {repoGroup.repoFullName} +
+ + + {repoGroup.repoFullName} +
diff --git a/src/app/components/dashboard/IssuesTab.tsx b/src/app/components/dashboard/IssuesTab.tsx index 32aeec0a..a8311d3a 100644 --- a/src/app/components/dashboard/IssuesTab.tsx +++ b/src/app/components/dashboard/IssuesTab.tsx @@ -360,9 +360,11 @@ export default function IssuesTab(props: IssuesTabProps) { - - {repoGroup.repoFullName} +
+ + + {repoGroup.repoFullName} +
diff --git a/src/app/components/dashboard/PullRequestsTab.tsx b/src/app/components/dashboard/PullRequestsTab.tsx index f1cb384c..dd07e987 100644 --- a/src/app/components/dashboard/PullRequestsTab.tsx +++ b/src/app/components/dashboard/PullRequestsTab.tsx @@ -467,9 +467,11 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { - - {repoGroup.repoFullName} +
+ + + {repoGroup.repoFullName} +
From 149cc092a29187858e2426c805866a392deb6168 Mon Sep 17 00:00:00 2001 From: testvalue Date: Sun, 19 Apr 2026 16:36:01 -0400 Subject: [PATCH 07/11] fix(ui): adapts empty locked row padding to layout density Uses py-1.5 for comfortable density and compact:py-0.5 for compact, matching the density-adaptive pattern in RepoGroupHeader. --- src/app/components/dashboard/ActionsTab.tsx | 2 +- src/app/components/dashboard/IssuesTab.tsx | 2 +- src/app/components/dashboard/PullRequestsTab.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/components/dashboard/ActionsTab.tsx b/src/app/components/dashboard/ActionsTab.tsx index 50cdde2c..484cc1ff 100644 --- a/src/app/components/dashboard/ActionsTab.tsx +++ b/src/app/components/dashboard/ActionsTab.tsx @@ -304,7 +304,7 @@ export default function ActionsTab(props: ActionsTabProps) { when={!isEmpty()} fallback={
- + {repoGroup.repoFullName} diff --git a/src/app/components/dashboard/IssuesTab.tsx b/src/app/components/dashboard/IssuesTab.tsx index a8311d3a..bdfa3d64 100644 --- a/src/app/components/dashboard/IssuesTab.tsx +++ b/src/app/components/dashboard/IssuesTab.tsx @@ -361,7 +361,7 @@ export default function IssuesTab(props: IssuesTabProps) { when={!isEmpty()} fallback={
- + {repoGroup.repoFullName} diff --git a/src/app/components/dashboard/PullRequestsTab.tsx b/src/app/components/dashboard/PullRequestsTab.tsx index dd07e987..8ef2c0ce 100644 --- a/src/app/components/dashboard/PullRequestsTab.tsx +++ b/src/app/components/dashboard/PullRequestsTab.tsx @@ -468,7 +468,7 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { when={!isEmpty()} fallback={
- + {repoGroup.repoFullName} From bc7854299da95c757311d525d0911337f61cfdbb Mon Sep 17 00:00:00 2001 From: testvalue Date: Sun, 19 Apr 2026 18:41:27 -0400 Subject: [PATCH 08/11] fix(view): pr-review fixes for locked repo stubs - Extract EmptyLockedRepoRow shared component from tripled inline markup - Fix empty-state Show conditions: use sibling Shows with .length guards for mutual exclusivity (empty message vs locked stub rows) - Add JSDoc to ensureLockedRepoGroups documenting pipeline coupling - Fix Show child indentation across all three tab components - Add compact stub row rendering tests for all three tabs - Add isExpanded guard tests for all three tabs - Add EmptyLockedRepoRow unit tests --- src/app/components/dashboard/ActionsTab.tsx | 152 +++++----- src/app/components/dashboard/IssuesTab.tsx | 234 ++++++++------- .../components/dashboard/PullRequestsTab.tsx | 272 +++++++++--------- .../components/shared/EmptyLockedRepoRow.tsx | 21 ++ src/app/lib/grouping.ts | 6 + .../components/dashboard/ActionsTab.test.tsx | 37 +++ tests/components/dashboard/IssuesTab.test.tsx | 39 +++ .../dashboard/PullRequestsTab.test.tsx | 39 +++ .../shared/EmptyLockedRepoRow.test.tsx | 35 +++ 9 files changed, 495 insertions(+), 340 deletions(-) create mode 100644 src/app/components/shared/EmptyLockedRepoRow.tsx create mode 100644 tests/components/shared/EmptyLockedRepoRow.test.tsx diff --git a/src/app/components/dashboard/ActionsTab.tsx b/src/app/components/dashboard/ActionsTab.tsx index 484cc1ff..1a45619d 100644 --- a/src/app/components/dashboard/ActionsTab.tsx +++ b/src/app/components/dashboard/ActionsTab.tsx @@ -11,6 +11,7 @@ import RepoGroupHeader from "../shared/RepoGroupHeader"; import ExpandCollapseButtons from "../shared/ExpandCollapseButtons"; import RepoLockControls from "../shared/RepoLockControls"; import RepoGitHubLink from "../shared/RepoGitHubLink"; +import EmptyLockedRepoRow from "../shared/EmptyLockedRepoRow"; import { orderRepoGroups, ensureLockedRepoGroups } from "../../lib/grouping"; import { createReorderHighlight } from "../../lib/reorderHighlight"; import { createFlashDetection } from "../../lib/flashDetection"; @@ -303,89 +304,82 @@ export default function ActionsTab(props: ActionsTabProps) { - - - {repoGroup.repoFullName} - - - -
+ } > -
- toggleExpandedRepo("actions", repoGroup.repoFullName)} - trailing={ - <> - - - - } - collapsedSummary={ - - {workflowCounts().total} workflow{workflowCounts().total !== 1 ? "s" : ""} - 0 || workflowCounts().failed > 0 || workflowCounts().running > 0}> - {": "} - 0}> - {workflowCounts().passed} passed +
+ toggleExpandedRepo("actions", repoGroup.repoFullName)} + trailing={ + <> + + + + } + collapsedSummary={ + + {workflowCounts().total} workflow{workflowCounts().total !== 1 ? "s" : ""} + 0 || workflowCounts().failed > 0 || workflowCounts().running > 0}> + {": "} + 0}> + {workflowCounts().passed} passed + + 0 && (workflowCounts().failed > 0 || workflowCounts().running > 0)}> + {", "} + + 0}> + {workflowCounts().failed} failed + + 0 && workflowCounts().running > 0}> + {", "} + + 0}> + {workflowCounts().running} running + - 0 && (workflowCounts().failed > 0 || workflowCounts().running > 0)}> - {", "} - - 0}> - {workflowCounts().failed} failed - - 0 && workflowCounts().running > 0}> - {", "} - - 0}> - {workflowCounts().running} running - - - - } - /> - - {(peek) => ( -
- - {peek().itemLabel} - {peek().newStatus} + + } + /> + + {(peek) => ( +
+ + {peek().itemLabel} + {peek().newStatus} +
+ )} +
+ + {/* Workflow cards grid */} + +
+ + {(wfGroup) => { + const wfKey = `${repoGroup.repoFullName}:${wfGroup.workflowId}`; + const isWfExpanded = () => !!expandedWorkflows[wfKey]; + + return ( +
+ toggleWorkflow(wfKey)} + onIgnoreRun={handleIgnore} + refreshTick={props.refreshTick} + hotPollingRunIds={props.hotPollingRunIds} + flashingRunIds={flashingRunIds()} + /> +
+ ); + }} +
- )} -
- - {/* Workflow cards grid */} - -
- - {(wfGroup) => { - const wfKey = `${repoGroup.repoFullName}:${wfGroup.workflowId}`; - const isWfExpanded = () => !!expandedWorkflows[wfKey]; - - return ( -
- toggleWorkflow(wfKey)} - onIgnoreRun={handleIgnore} - refreshTick={props.refreshTick} - hotPollingRunIds={props.hotPollingRunIds} - flashingRunIds={flashingRunIds()} - /> -
- ); - }} -
-
-
-
+
+
); }} diff --git a/src/app/components/dashboard/IssuesTab.tsx b/src/app/components/dashboard/IssuesTab.tsx index bdfa3d64..3eaeaf02 100644 --- a/src/app/components/dashboard/IssuesTab.tsx +++ b/src/app/components/dashboard/IssuesTab.tsx @@ -17,6 +17,7 @@ import { groupByRepo, computePageLayout, slicePageGroups, orderRepoGroups, ensur import { createReorderHighlight } from "../../lib/reorderHighlight"; import RepoLockControls from "../shared/RepoLockControls"; import RepoGitHubLink from "../shared/RepoGitHubLink"; +import EmptyLockedRepoRow from "../shared/EmptyLockedRepoRow"; import { Tooltip } from "../shared/Tooltip"; export interface IssuesTabProps { @@ -306,40 +307,39 @@ export default function IssuesTab(props: IssuesTabProps) {
+ {/* Empty — only when no groups exist at all (locked stubs are handled by EmptyLockedRepoRow) */} + 0) && pageGroups().length === 0}> +
+ +

+ {viewState.tabFilters.issues.scope === "all" ? "No open issues found" : "No open issues involving you"} +

+

+ {viewState.tabFilters.issues.scope === "all" + ? "No issues match your current filters." + : "Issues where you are the author, assignee, or mentioned will appear here."} +

+
+
+ {/* Issue rows */} - 0}> - 0} - fallback={ -
- -

- {viewState.tabFilters.issues.scope === "all" ? "No open issues found" : "No open issues involving you"} -

-

- {viewState.tabFilters.issues.scope === "all" - ? "No issues match your current filters." - : "Issues where you are the author, assignee, or mentioned will appear here."} -

-
- } - > -
- - {(repoGroup) => { + 0) && pageGroups().length > 0}> +
+ + {(repoGroup) => { const isEmpty = () => repoGroup.items.length === 0; const isExpanded = () => !isEmpty() && !!viewState.expandedRepos.issues[repoGroup.repoFullName]; @@ -360,100 +360,92 @@ export default function IssuesTab(props: IssuesTabProps) { - - - {repoGroup.repoFullName} - - - -
+ } > -
- toggleExpandedRepo("issues", repoGroup.repoFullName)} - badges={ - - - Monitoring all - - - } - trailing={ - <> - - - - } - collapsedSummary={ - - {repoGroup.items.length} {repoGroup.items.length === 1 ? "issue" : "issues"} - - {([role, count]) => ( - - {role} ×{count} - +
+ toggleExpandedRepo("issues", repoGroup.repoFullName)} + badges={ + + + Monitoring all + + + } + trailing={ + <> + + + + } + collapsedSummary={ + + {repoGroup.items.length} {repoGroup.items.length === 1 ? "issue" : "issues"} + + {([role, count]) => ( + + {role} ×{count} + + )} + + + } + /> + +
+ + {(issue) => ( +
+ handleIgnore(issue)} + onTrack={config.enableTracking ? () => handleTrack(issue) : undefined} + isTracked={config.enableTracking ? trackedIssueIds().has(issue.id) : undefined} + commentCount={issue.comments} + surfacedByBadge={ + props.trackedUsers && props.trackedUsers.length > 0 + ? + : undefined + } + > + + +
)}
- - } - /> - -
- - {(issue) => ( -
- handleIgnore(issue)} - onTrack={config.enableTracking ? () => handleTrack(issue) : undefined} - isTracked={config.enableTracking ? trackedIssueIds().has(issue.id) : undefined} - commentCount={issue.comments} - surfacedByBadge={ - props.trackedUsers && props.trackedUsers.length > 0 - ? - : undefined - } - > - - -
- )} -
-
-
-
+
+ +
); - }} -
-
-
+ }} + +
0}> diff --git a/src/app/components/dashboard/PullRequestsTab.tsx b/src/app/components/dashboard/PullRequestsTab.tsx index 8ef2c0ce..9bb56f7f 100644 --- a/src/app/components/dashboard/PullRequestsTab.tsx +++ b/src/app/components/dashboard/PullRequestsTab.tsx @@ -22,6 +22,7 @@ import { createReorderHighlight } from "../../lib/reorderHighlight"; import { createFlashDetection } from "../../lib/flashDetection"; import RepoLockControls from "../shared/RepoLockControls"; import RepoGitHubLink from "../shared/RepoGitHubLink"; +import EmptyLockedRepoRow from "../shared/EmptyLockedRepoRow"; import { Tooltip } from "../shared/Tooltip"; export interface PullRequestsTabProps { @@ -400,40 +401,39 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { + {/* Empty — only when no groups exist at all (locked stubs are handled by EmptyLockedRepoRow) */} + 0) && pageGroups().length === 0}> +
+ +

+ {viewState.tabFilters.pullRequests.scope === "all" ? "No open pull requests found" : "No open pull requests involving you"} +

+

+ {viewState.tabFilters.pullRequests.scope === "all" + ? "No pull requests match your current filters." + : "PRs where you are the author, assignee, or reviewer will appear here."} +

+
+
+ {/* PR rows */} - 0}> - 0} - fallback={ -
- -

- {viewState.tabFilters.pullRequests.scope === "all" ? "No open pull requests found" : "No open pull requests involving you"} -

-

- {viewState.tabFilters.pullRequests.scope === "all" - ? "No pull requests match your current filters." - : "PRs where you are the author, assignee, or reviewer will appear here."} -

-
- } - > -
- - {(repoGroup) => { + 0) && pageGroups().length > 0}> +
+ + {(repoGroup) => { const isEmpty = () => repoGroup.items.length === 0; const isExpanded = () => !isEmpty() && !!viewState.expandedRepos.pullRequests[repoGroup.repoFullName]; @@ -467,108 +467,101 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { - - - {repoGroup.repoFullName} - - - -
+ } > -
- toggleExpandedRepo("pullRequests", repoGroup.repoFullName)} - badges={ - - - Monitoring all - - - } - trailing={ - <> - - - - } - collapsedSummary={ - - {repoGroup.items.length} {repoGroup.items.length === 1 ? "PR" : "PRs"} - 0}> - - - {summaryMeta().checks.success} - +
+ toggleExpandedRepo("pullRequests", repoGroup.repoFullName)} + badges={ + + + Monitoring all + - 0}> - - - {summaryMeta().checks.failure} - - - 0}> - - - {summaryMeta().checks.pending} - - - 0}> - - - {summaryMeta().checks.conflict === 1 ? "Conflict" : `Conflicts ×${summaryMeta().checks.conflict}`} - - - 0}> - - {`Approved ×${summaryMeta().reviews.APPROVED}`} - - - 0}> - - {`Changes ×${summaryMeta().reviews.CHANGES_REQUESTED}`} - - - 0}> - - {`Needs review ×${summaryMeta().reviews.REVIEW_REQUIRED}`} - - - - {([role, count]) => ( - - {`${role} ×${count}`} + } + trailing={ + <> + + + + } + collapsedSummary={ + + {repoGroup.items.length} {repoGroup.items.length === 1 ? "PR" : "PRs"} + 0}> + + + {summaryMeta().checks.success} - )} - - - } - /> - - {(peek) => ( -
- - {peek().itemLabel} - {peek().newStatus} -
- )} -
- -
- - {(pr) => ( + + 0}> + + + {summaryMeta().checks.failure} + + + 0}> + + + {summaryMeta().checks.pending} + + + 0}> + + + {summaryMeta().checks.conflict === 1 ? "Conflict" : `Conflicts ×${summaryMeta().checks.conflict}`} + + + 0}> + + {`Approved ×${summaryMeta().reviews.APPROVED}`} + + + 0}> + + {`Changes ×${summaryMeta().reviews.CHANGES_REQUESTED}`} + + + 0}> + + {`Needs review ×${summaryMeta().reviews.REVIEW_REQUIRED}`} + + + + {([role, count]) => ( + + {`${role} ×${count}`} + + )} + + + } + /> + + {(peek) => ( +
+ + {peek().itemLabel} + {peek().newStatus} +
+ )} +
+ +
+ + {(pr) => (
- )} -
-
-
-
+ )} + +
+
+
); - }} -
-
-
+ }} + +
0}> diff --git a/src/app/components/shared/EmptyLockedRepoRow.tsx b/src/app/components/shared/EmptyLockedRepoRow.tsx new file mode 100644 index 00000000..74b1974e --- /dev/null +++ b/src/app/components/shared/EmptyLockedRepoRow.tsx @@ -0,0 +1,21 @@ +import RepoGitHubLink from "./RepoGitHubLink"; +import RepoLockControls from "./RepoLockControls"; + +export default function EmptyLockedRepoRow(props: { + repoFullName: string; + section: "issues" | "pulls" | "actions"; +}) { + return ( +
+ + + {props.repoFullName} + + + +
+ ); +} diff --git a/src/app/lib/grouping.ts b/src/app/lib/grouping.ts index 5801210d..3cfc0d82 100644 --- a/src/app/lib/grouping.ts +++ b/src/app/lib/grouping.ts @@ -50,6 +50,12 @@ export function slicePageGroups( return groups.slice(start, end); } +/** + * Ensures every locked repo has a group entry, appending empty stubs for any + * that are absent from the current data. The stubs land at the tail on purpose: + * the caller must pipe the result through orderRepoGroups, which promotes all + * locked groups to the front in the correct order. + */ export function ensureLockedRepoGroups( groups: G[], lockedOrder: readonly string[], diff --git a/tests/components/dashboard/ActionsTab.test.tsx b/tests/components/dashboard/ActionsTab.test.tsx index 88768042..b76a8ff7 100644 --- a/tests/components/dashboard/ActionsTab.test.tsx +++ b/tests/components/dashboard/ActionsTab.test.tsx @@ -104,6 +104,43 @@ describe("ActionsTab — empty-repo state preservation", () => { // With empty items and no configRepoNames, guard returns early — no pruning expect(viewState.lockedRepos).toEqual([]); }); + + it("renders compact stub row for a locked repo with no workflow runs", () => { + setViewState(produce((s) => { + s.lockedRepos = ["owner/locked-empty"]; + })); + + const { container } = render(() => ( + + )); + + const stub = container.querySelector('[data-repo-group="owner/locked-empty"]'); + expect(stub).not.toBeNull(); + expect(stub?.textContent).toContain("owner/locked-empty"); + const headerBtn = stub?.querySelector('[aria-expanded]'); + expect(headerBtn).toBeNull(); + }); + + it("does not expand a locked repo with no workflow runs even when expandedRepos is set", () => { + setViewState(produce((s) => { + s.lockedRepos = ["owner/locked-empty"]; + s.expandedRepos.actions["owner/locked-empty"] = true; + })); + + const { container } = render(() => ( + + )); + + const stub = container.querySelector('[data-repo-group="owner/locked-empty"]'); + expect(stub).not.toBeNull(); + expect(stub?.querySelector('[aria-expanded]')).toBeNull(); + }); }); // ── ActionsTab — RepoGroupHeader integration ────────────────────────────────── diff --git a/tests/components/dashboard/IssuesTab.test.tsx b/tests/components/dashboard/IssuesTab.test.tsx index c4d168e8..ff837b9c 100644 --- a/tests/components/dashboard/IssuesTab.test.tsx +++ b/tests/components/dashboard/IssuesTab.test.tsx @@ -740,4 +740,43 @@ describe("IssuesTab — empty-repo state preservation", () => { // With empty items and no configRepoNames, guard returns early — no pruning expect(viewState.lockedRepos).toEqual([]); }); + + it("renders compact stub row for a locked repo with no issues", () => { + setViewState(produce((s) => { + s.lockedRepos = ["owner/locked-empty"]; + })); + + const { container } = render(() => ( + + )); + + const stub = container.querySelector('[data-repo-group="owner/locked-empty"]'); + expect(stub).not.toBeNull(); + expect(stub?.textContent).toContain("owner/locked-empty"); + const headerBtn = stub?.querySelector('[aria-expanded]'); + expect(headerBtn).toBeNull(); + }); + + it("does not expand a locked repo with no issues even when expandedRepos is set", () => { + setViewState(produce((s) => { + s.lockedRepos = ["owner/locked-empty"]; + s.expandedRepos.issues["owner/locked-empty"] = true; + })); + + const { container } = render(() => ( + + )); + + const stub = container.querySelector('[data-repo-group="owner/locked-empty"]'); + expect(stub).not.toBeNull(); + expect(stub?.querySelector('[aria-expanded]')).toBeNull(); + }); }); diff --git a/tests/components/dashboard/PullRequestsTab.test.tsx b/tests/components/dashboard/PullRequestsTab.test.tsx index 373e94c3..bca916e2 100644 --- a/tests/components/dashboard/PullRequestsTab.test.tsx +++ b/tests/components/dashboard/PullRequestsTab.test.tsx @@ -711,4 +711,43 @@ describe("PullRequestsTab — empty-repo state preservation", () => { // With empty items and no configRepoNames, guard returns early — no pruning expect(viewState.lockedRepos).toEqual([]); }); + + it("renders compact stub row for a locked repo with no pull requests", () => { + setViewState(produce((s) => { + s.lockedRepos = ["owner/locked-empty"]; + })); + + const { container } = render(() => ( + + )); + + const stub = container.querySelector('[data-repo-group="owner/locked-empty"]'); + expect(stub).not.toBeNull(); + expect(stub?.textContent).toContain("owner/locked-empty"); + const headerBtn = stub?.querySelector('[aria-expanded]'); + expect(headerBtn).toBeNull(); + }); + + it("does not expand a locked repo with no pull requests even when expandedRepos is set", () => { + setViewState(produce((s) => { + s.lockedRepos = ["owner/locked-empty"]; + s.expandedRepos.pullRequests["owner/locked-empty"] = true; + })); + + const { container } = render(() => ( + + )); + + const stub = container.querySelector('[data-repo-group="owner/locked-empty"]'); + expect(stub).not.toBeNull(); + expect(stub?.querySelector('[aria-expanded]')).toBeNull(); + }); }); diff --git a/tests/components/shared/EmptyLockedRepoRow.test.tsx b/tests/components/shared/EmptyLockedRepoRow.test.tsx new file mode 100644 index 00000000..df01da0a --- /dev/null +++ b/tests/components/shared/EmptyLockedRepoRow.test.tsx @@ -0,0 +1,35 @@ +import { describe, it, expect } from "vitest"; +import { render } from "@solidjs/testing-library"; +import EmptyLockedRepoRow from "../../../src/app/components/shared/EmptyLockedRepoRow"; + +describe("EmptyLockedRepoRow", () => { + it("renders the repo name", () => { + const { getByText } = render(() => ( + + )); + expect(getByText("owner/repo")).toBeTruthy(); + }); + + it("sets data-repo-group attribute", () => { + const { container } = render(() => ( + + )); + const row = container.querySelector('[data-repo-group="owner/repo"]'); + expect(row).not.toBeNull(); + }); + + it("applies de-emphasis styling", () => { + const { container } = render(() => ( + + )); + const row = container.querySelector('[data-repo-group="owner/repo"]'); + expect(row?.className).toContain("opacity-40"); + }); + + it("has no aria-expanded attribute (non-interactive)", () => { + const { container } = render(() => ( + + )); + expect(container.querySelector("[aria-expanded]")).toBeNull(); + }); +}); From ae28cbaf930877ee06ca7cb93860c1e2ba5c2404 Mon Sep 17 00:00:00 2001 From: testvalue Date: Sun, 19 Apr 2026 18:50:08 -0400 Subject: [PATCH 09/11] test(view): adds double-render regression tests Verify that the empty-state message and locked stub rows are mutually exclusive: when only locked stubs exist (no real items), the empty message must not render alongside the stubs. --- .../components/dashboard/ActionsTab.test.tsx | 19 +++++++++++++++++++ tests/components/dashboard/IssuesTab.test.tsx | 16 ++++++++++++++++ .../dashboard/PullRequestsTab.test.tsx | 16 ++++++++++++++++ 3 files changed, 51 insertions(+) diff --git a/tests/components/dashboard/ActionsTab.test.tsx b/tests/components/dashboard/ActionsTab.test.tsx index b76a8ff7..6b1f92c9 100644 --- a/tests/components/dashboard/ActionsTab.test.tsx +++ b/tests/components/dashboard/ActionsTab.test.tsx @@ -141,6 +141,25 @@ describe("ActionsTab — empty-repo state preservation", () => { expect(stub).not.toBeNull(); expect(stub?.querySelector('[aria-expanded]')).toBeNull(); }); + + it("hides empty-state message when only locked stubs exist (no double render)", () => { + setViewState(produce((s) => { + s.lockedRepos = ["owner/locked-empty"]; + })); + + const { container } = render(() => ( + + )); + + // Locked stub renders + const stub = container.querySelector('[data-repo-group="owner/locked-empty"]'); + expect(stub).not.toBeNull(); + // Empty-state message does NOT render alongside the stub + expect(screen.queryByText("No workflow runs found.")).toBeNull(); + }); }); // ── ActionsTab — RepoGroupHeader integration ────────────────────────────────── diff --git a/tests/components/dashboard/IssuesTab.test.tsx b/tests/components/dashboard/IssuesTab.test.tsx index ff837b9c..9563c0cd 100644 --- a/tests/components/dashboard/IssuesTab.test.tsx +++ b/tests/components/dashboard/IssuesTab.test.tsx @@ -779,4 +779,20 @@ describe("IssuesTab — empty-repo state preservation", () => { expect(stub).not.toBeNull(); expect(stub?.querySelector('[aria-expanded]')).toBeNull(); }); + + it("hides empty-state message when only locked stubs exist (no double render)", () => { + setViewState(produce((s) => { + s.lockedRepos = ["owner/locked-empty"]; + })); + + render(() => ( + + )); + + expect(screen.queryByText(/No open issues/i)).toBeNull(); + }); }); diff --git a/tests/components/dashboard/PullRequestsTab.test.tsx b/tests/components/dashboard/PullRequestsTab.test.tsx index bca916e2..31b5ef66 100644 --- a/tests/components/dashboard/PullRequestsTab.test.tsx +++ b/tests/components/dashboard/PullRequestsTab.test.tsx @@ -750,4 +750,20 @@ describe("PullRequestsTab — empty-repo state preservation", () => { expect(stub).not.toBeNull(); expect(stub?.querySelector('[aria-expanded]')).toBeNull(); }); + + it("hides empty-state message when only locked stubs exist (no double render)", () => { + setViewState(produce((s) => { + s.lockedRepos = ["owner/locked-empty"]; + })); + + render(() => ( + + )); + + expect(screen.queryByText(/No open pull requests/i)).toBeNull(); + }); }); From 6a7dfb55f91f5d052dee5bcf9b44ccc76ad8a47b Mon Sep 17 00:00:00 2001 From: testvalue Date: Mon, 20 Apr 2026 08:07:39 -0400 Subject: [PATCH 10/11] fix(actions): adds loading guard for skeleton+stub render ActionsTab groups Show lacked the loading guard that IssuesTab and PullRequestsTab have. During initial load with locked repos, ensureLockedRepoGroups injects stubs that made repoGroups().length > 0, rendering stubs alongside the loading skeleton. --- src/app/components/dashboard/ActionsTab.tsx | 6 +++--- .../components/dashboard/ActionsTab.test.tsx | 20 +++++++++++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/app/components/dashboard/ActionsTab.tsx b/src/app/components/dashboard/ActionsTab.tsx index 1a45619d..e6d09434 100644 --- a/src/app/components/dashboard/ActionsTab.tsx +++ b/src/app/components/dashboard/ActionsTab.tsx @@ -263,10 +263,10 @@ export default function ActionsTab(props: ActionsTabProps) {
- {/* Empty */} + {/* Empty — only when no groups exist at all (locked stubs are handled by EmptyLockedRepoRow) */} 0) && repoGroups().length === 0 } >
@@ -275,7 +275,7 @@ export default function ActionsTab(props: ActionsTabProps) { {/* Repo groups */} - 0}> + 0) && repoGroups().length > 0}> {(repoGroup) => { const isEmpty = () => repoGroup.workflows.length === 0; diff --git a/tests/components/dashboard/ActionsTab.test.tsx b/tests/components/dashboard/ActionsTab.test.tsx index 6b1f92c9..31c9bd90 100644 --- a/tests/components/dashboard/ActionsTab.test.tsx +++ b/tests/components/dashboard/ActionsTab.test.tsx @@ -160,6 +160,26 @@ describe("ActionsTab — empty-repo state preservation", () => { // Empty-state message does NOT render alongside the stub expect(screen.queryByText("No workflow runs found.")).toBeNull(); }); + + it("hides locked stubs during initial load (no skeleton + stub double render)", () => { + setViewState(produce((s) => { + s.lockedRepos = ["owner/locked-empty"]; + })); + + const { container } = render(() => ( + + )); + + // Loading skeleton shows (label is aria-label, not visible text) + screen.getByRole("status", { name: "Loading workflow runs" }); + // Locked stub does NOT render alongside the skeleton + const stub = container.querySelector('[data-repo-group="owner/locked-empty"]'); + expect(stub).toBeNull(); + }); }); // ── ActionsTab — RepoGroupHeader integration ────────────────────────────────── From 3e793cd11f584c733befad7385293a1a7a54641e Mon Sep 17 00:00:00 2001 From: testvalue Date: Mon, 20 Apr 2026 08:17:29 -0400 Subject: [PATCH 11/11] docs(user-guide): documents pinned repo empty-state behavior Pinned repos now stay visible as de-emphasized rows when filters exclude all their items. Updates the Repo Pinning section to describe this behavior. --- docs/USER_GUIDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index c48aee6d..4af99436 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -350,7 +350,7 @@ The Tracked tab lets you pin issues and PRs into a personal TODO list that you c ## Repo Pinning -Each repo group header has a pin (lock) control, visible on hover on desktop and always visible on mobile. Pinning a repo keeps it at the top of the list on all tabs regardless of sort order or how recently it was updated. +Each repo group header has a pin (lock) control, visible on hover on desktop and always visible on mobile. Pinning a repo keeps it at the top of the list on all tabs regardless of sort order or how recently it was updated. Pinned repos remain visible even when filters exclude all their items — they appear as compact, de-emphasized rows with the repo name and pin controls still accessible. - Click the pin icon to pin a repo to the top. - Click it again to unpin.