Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,5 @@ dist/
hack/
.serena/
.playwright-mcp/
playwright-report/
test-results/
82 changes: 73 additions & 9 deletions src/app/components/dashboard/DashboardPage.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createSignal, createMemo, Show, Switch, Match, onMount, onCleanup } from "solid-js";
import { createStore } from "solid-js/store";
import { createStore, produce } from "solid-js/store";
import Header from "../layout/Header";
import TabBar, { TabId } from "../layout/TabBar";
import FilterBar from "../layout/FilterBar";
Expand Down Expand Up @@ -82,17 +82,81 @@ async function pollFetch(): Promise<DashboardData> {
setDashboardData("loading", true);
}
try {
const data = await fetchAllData();
// Two-phase rendering: phase 1 callback fires with light issues + PRs
// so the UI renders immediately. Phase 2 (enrichment + workflow runs)
// arrives when fetchAllData resolves.
let phaseOneFired = false;
const data = await fetchAllData((lightData) => {
// Phase 1: render light issues + PRs immediately — but only on initial
// load (no cached data). On reload with cached data, the cache already
// has enriched PRs; replacing them with light PRs would cause a visible
// flicker (badges disappear then reappear when phase 2 arrives).
if (dashboardData.pullRequests.length === 0) {
phaseOneFired = true;
setDashboardData({
issues: lightData.issues,
pullRequests: lightData.pullRequests,
loading: false,
lastRefreshedAt: new Date(),
});
}
});
// When notifications gate says nothing changed, keep existing data
if (!data.skipped) {
const now = new Date();
setDashboardData({
issues: data.issues,
pullRequests: data.pullRequests,
workflowRuns: data.workflowRuns,
loading: false,
lastRefreshedAt: now,
});

if (phaseOneFired) {
// Phase 1 fired — use fine-grained merge for the light→enriched
// transition. Only update heavy fields to avoid re-rendering the
// entire list (light fields haven't changed within this poll cycle).
const enrichedMap = new Map<number, PullRequest>();
for (const pr of data.pullRequests) enrichedMap.set(pr.id, pr);

setDashboardData(produce((state) => {
state.issues = data.issues;
state.workflowRuns = data.workflowRuns;
state.loading = false;
state.lastRefreshedAt = now;

let canMerge = state.pullRequests.length === enrichedMap.size;
if (canMerge) {
for (let i = 0; i < state.pullRequests.length; i++) {
if (!enrichedMap.has(state.pullRequests[i].id)) { canMerge = false; break; }
}
}

if (canMerge) {
for (let i = 0; i < state.pullRequests.length; i++) {
const e = enrichedMap.get(state.pullRequests[i].id)!;
const pr = state.pullRequests[i];
pr.headSha = e.headSha;
pr.assigneeLogins = e.assigneeLogins;
pr.reviewerLogins = e.reviewerLogins;
pr.checkStatus = e.checkStatus;
pr.additions = e.additions;
pr.deletions = e.deletions;
pr.changedFiles = e.changedFiles;
pr.comments = e.comments;
pr.reviewThreads = e.reviewThreads;
pr.totalReviewCount = e.totalReviewCount;
pr.enriched = e.enriched;
}
} else {
state.pullRequests = data.pullRequests;
}
}));
} else {
// Phase 1 did NOT fire (cached data existed or subsequent poll).
// Full atomic replacement — all fields (light + heavy) may have
// changed since the last cycle.
setDashboardData({
issues: data.issues,
pullRequests: data.pullRequests,
workflowRuns: data.workflowRuns,
loading: false,
lastRefreshedAt: now,
});
}
// Persist for stale-while-revalidate on full page reload.
// Errors are transient and not persisted. Deferred to avoid blocking paint.
const cachePayload = {
Expand Down
40 changes: 24 additions & 16 deletions src/app/components/dashboard/PullRequestsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -149,9 +149,13 @@ export default function PullRequestsTab(props: PullRequestsTabProps) {
const roles = deriveInvolvementRoles(props.userLogin, pr.userLogin, pr.assigneeLogins, pr.reviewerLogins);
const sizeCategory = prSizeCategory(pr.additions, pr.deletions);

// Tab filters
// Tab filters — light-field filters always apply; heavy-field filters
// only apply to enriched PRs so unenriched phase-1 PRs aren't incorrectly hidden
const isEnriched = pr.enriched !== false;
if (tabFilters.role !== "all") {
if (!roles.includes(tabFilters.role as "author" | "reviewer" | "assignee")) return false;
// Role depends on assigneeLogins/reviewerLogins (heavy), but "author" is light
if (isEnriched && !roles.includes(tabFilters.role as "author" | "reviewer" | "assignee")) return false;
if (!isEnriched && tabFilters.role === "author" && !roles.includes("author")) return false;
}
if (tabFilters.reviewDecision !== "all") {
if (pr.reviewDecision !== tabFilters.reviewDecision) return false;
Expand All @@ -160,14 +164,14 @@ export default function PullRequestsTab(props: PullRequestsTabProps) {
if (tabFilters.draft === "draft" && !pr.draft) return false;
if (tabFilters.draft === "ready" && pr.draft) return false;
}
if (tabFilters.checkStatus !== "all") {
if (tabFilters.checkStatus !== "all" && isEnriched) {
if (tabFilters.checkStatus === "none") {
if (pr.checkStatus !== null) return false;
} else {
if (pr.checkStatus !== tabFilters.checkStatus) return false;
}
}
if (tabFilters.sizeCategory !== "all") {
if (tabFilters.sizeCategory !== "all" && isEnriched) {
if (sizeCategory !== tabFilters.sizeCategory) return false;
}

Expand Down Expand Up @@ -429,29 +433,33 @@ export default function PullRequestsTab(props: PullRequestsTabProps) {
createdAt={pr.createdAt}
url={pr.htmlUrl}
labels={pr.labels}
commentCount={pr.comments + pr.reviewThreads}
commentCount={pr.enriched !== false ? pr.comments + pr.reviewThreads : undefined}
onIgnore={() => handleIgnore(pr)}
density={config.viewDensity}
>
<div class="flex items-center gap-2 flex-wrap">
<RoleBadge roles={prMeta().get(pr.id)?.roles ?? []} />
<Show when={pr.enriched !== false}>
<RoleBadge roles={prMeta().get(pr.id)?.roles ?? []} />
</Show>
<ReviewBadge decision={pr.reviewDecision} />
<SizeBadge additions={pr.additions} deletions={pr.deletions} changedFiles={pr.changedFiles} category={prMeta().get(pr.id)?.sizeCategory} filesUrl={`${pr.htmlUrl}/files`} />
<StatusDot status={pr.checkStatus} href={`${pr.htmlUrl}/checks`} />
<Show when={pr.checkStatus === "conflict"}>
<span class="badge badge-warning badge-sm gap-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
Merge conflict
</span>
<Show when={pr.enriched !== false}>
<SizeBadge additions={pr.additions} deletions={pr.deletions} changedFiles={pr.changedFiles} category={prMeta().get(pr.id)?.sizeCategory} filesUrl={`${pr.htmlUrl}/files`} />
<StatusDot status={pr.checkStatus} href={`${pr.htmlUrl}/checks`} />
<Show when={pr.checkStatus === "conflict"}>
<span class="badge badge-warning badge-sm gap-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
Merge conflict
</span>
</Show>
</Show>
<Show when={pr.draft}>
<span class="badge badge-ghost badge-sm italic text-base-content/50">
Draft
</span>
</Show>
<Show when={pr.reviewerLogins.length > 0}>
<Show when={pr.enriched !== false && pr.reviewerLogins.length > 0}>
<span class="text-xs text-base-content/60" title={pr.reviewerLogins.join(", ")}>
Reviewers: {pr.reviewerLogins.slice(0, 5).join(", ")}
{pr.reviewerLogins.length > 5 && ` +${pr.reviewerLogins.length - 5} more`}
Expand Down
Loading
Loading