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
105 changes: 73 additions & 32 deletions src/app/components/dashboard/ActionsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,24 @@ import { createEffect, createMemo, For, Show } from "solid-js";
import { createStore } from "solid-js/store";
import type { WorkflowRun } from "../../services/api";
import { config } from "../../stores/config";
import { viewState, setViewState, setTabFilter, resetTabFilter, resetAllTabFilters, ignoreItem, unignoreItem, toggleExpandedRepo, setAllExpanded, pruneExpandedRepos, type ActionsFilterField } from "../../stores/view";
import { viewState, setViewState, setTabFilter, resetTabFilter, resetAllTabFilters, ignoreItem, unignoreItem, toggleExpandedRepo, setAllExpanded, pruneExpandedRepos, pruneLockedRepos, type ActionsFilterField } from "../../stores/view";
import WorkflowSummaryCard from "./WorkflowSummaryCard";
import IgnoreBadge from "./IgnoreBadge";
import SkeletonRows from "../shared/SkeletonRows";
import FilterChips from "../shared/FilterChips";
import type { FilterChipGroupDef } from "../shared/FilterChips";
import ChevronIcon from "../shared/ChevronIcon";
import ExpandCollapseButtons from "../shared/ExpandCollapseButtons";
import RepoLockControls from "../shared/RepoLockControls";
import { orderRepoGroups } from "../../lib/grouping";
import { createReorderHighlight } from "../../lib/reorderHighlight";
import { createFlashDetection } from "../../lib/flashDetection";

interface ActionsTabProps {
workflowRuns: WorkflowRun[];
loading?: boolean;
hasUpstreamRepos?: boolean;
hotPollingRunIds?: ReadonlySet<number>;
}

interface WorkflowGroup {
Expand Down Expand Up @@ -134,6 +139,15 @@ export default function ActionsTab(props: ActionsTabProps) {
pruneExpandedRepos("actions", names);
});

const { flashingIds: flashingRunIds, peekUpdates } = createFlashDetection({
getItems: () => props.workflowRuns,
getHotIds: () => props.hotPollingRunIds,
getExpandedRepos: () => viewState.expandedRepos.actions,
trackKey: (run) => `${run.status}|${run.conclusion}`,
itemLabel: (run) => run.name,
itemStatus: (run) => run.conclusion ?? run.status,
});

function handleIgnore(run: WorkflowRun) {
ignoreItem({
id: String(run.id),
Expand Down Expand Up @@ -182,7 +196,20 @@ export default function ActionsTab(props: ActionsTabProps) {
});
});

const repoGroups = createMemo(() => groupRuns(filteredRuns()));
const repoGroups = createMemo(() =>
orderRepoGroups(groupRuns(filteredRuns()), viewState.lockedRepos.actions)
);

createEffect(() => {
const names = activeRepoNames();
if (names.length === 0) return;
pruneLockedRepos("actions", names);
});

const highlightedReposActions = createReorderHighlight(
() => repoGroups().map(g => g.repoFullName),
() => viewState.lockedRepos.actions,
);

return (
<div class="divide-y divide-base-300">
Expand Down Expand Up @@ -259,37 +286,49 @@ export default function ActionsTab(props: ActionsTabProps) {
return (
<div class="bg-base-100">
{/* Repo header */}
<button
onClick={() => toggleExpandedRepo("actions", repoGroup.repoFullName)}
aria-expanded={isExpanded()}
class="w-full flex items-center gap-2 px-4 py-2.5 text-left text-sm font-semibold text-base-content bg-base-200/60 border-y border-base-300 hover:bg-base-200 transition-colors"
>
<ChevronIcon size="md" rotated={!isExpanded()} />
{repoGroup.repoFullName}
<Show when={!isExpanded()}>
<span class="ml-auto text-xs font-normal text-base-content/60">
{collapsedSummary().total} workflow{collapsedSummary().total !== 1 ? "s" : ""}
<Show when={collapsedSummary().passed > 0 || collapsedSummary().failed > 0 || collapsedSummary().running > 0}>
{": "}
<Show when={collapsedSummary().passed > 0}>
<span>{collapsedSummary().passed} passed</span>
</Show>
<Show when={collapsedSummary().passed > 0 && (collapsedSummary().failed > 0 || collapsedSummary().running > 0)}>
{", "}
</Show>
<Show when={collapsedSummary().failed > 0}>
<span class="text-error font-medium">{collapsedSummary().failed} failed</span>
<div class={`group/repo-header flex items-center bg-base-200/60 border-y border-base-300 hover:bg-base-200 transition-colors duration-300 ${highlightedReposActions().has(repoGroup.repoFullName) ? "animate-reorder-highlight" : ""}`}>
<button
onClick={() => toggleExpandedRepo("actions", repoGroup.repoFullName)}
aria-expanded={isExpanded()}
class="flex-1 flex items-center gap-2 px-4 py-2.5 text-left text-sm font-semibold text-base-content"
>
<ChevronIcon size="md" rotated={!isExpanded()} />
{repoGroup.repoFullName}
<Show when={!isExpanded()}>
<span class="ml-auto text-xs font-normal text-base-content/60">
{collapsedSummary().total} workflow{collapsedSummary().total !== 1 ? "s" : ""}
<Show when={collapsedSummary().passed > 0 || collapsedSummary().failed > 0 || collapsedSummary().running > 0}>
{": "}
<Show when={collapsedSummary().passed > 0}>
<span>{collapsedSummary().passed} passed</span>
</Show>
<Show when={collapsedSummary().passed > 0 && (collapsedSummary().failed > 0 || collapsedSummary().running > 0)}>
{", "}
</Show>
<Show when={collapsedSummary().failed > 0}>
<span class="text-error font-medium">{collapsedSummary().failed} failed</span>
</Show>
<Show when={collapsedSummary().failed > 0 && collapsedSummary().running > 0}>
{", "}
</Show>
<Show when={collapsedSummary().running > 0}>
<span>{collapsedSummary().running} running</span>
</Show>
</Show>
<Show when={collapsedSummary().failed > 0 && collapsedSummary().running > 0}>
{", "}
</Show>
<Show when={collapsedSummary().running > 0}>
<span>{collapsedSummary().running} running</span>
</Show>
</Show>
</span>
</Show>
</button>
</span>
</Show>
</button>
<RepoLockControls tab="actions" repoFullName={repoGroup.repoFullName} />
</div>
<Show when={!isExpanded() && peekUpdates().get(repoGroup.repoFullName)}>
{(peek) => (
<div class="animate-flash flex items-center gap-2 text-xs text-base-content/70 px-4 py-1.5 border-b border-base-300 bg-base-100">
<span class="loading loading-spinner loading-xs text-primary/60" />
<span class="truncate flex-1">{peek().itemLabel}</span>
<span class="badge badge-xs badge-primary">{peek().newStatus}</span>
</div>
)}
</Show>

{/* Workflow cards grid */}
<Show when={isExpanded()}>
Expand All @@ -308,6 +347,8 @@ export default function ActionsTab(props: ActionsTabProps) {
onToggle={() => toggleWorkflow(wfKey)}
onIgnoreRun={handleIgnore}
density={config.viewDensity}
hotPollingRunIds={props.hotPollingRunIds}
flashingRunIds={flashingRunIds()}
/>
</div>
);
Expand Down
15 changes: 15 additions & 0 deletions src/app/components/dashboard/DashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
import { clearAuth, user, onAuthCleared, DASHBOARD_STORAGE_KEY } from "../../stores/auth";
import { getClient, getGraphqlRateLimit } from "../../services/github";
import { formatCount } from "../../lib/format";
import { setsEqual } from "../../lib/collections";

// ── Shared dashboard store (module-level to survive navigation) ─────────────

Expand Down Expand Up @@ -221,6 +222,8 @@ const [_coordinator, _setCoordinator] = createSignal<ReturnType<typeof createPol
const [_hotCoordinator, _setHotCoordinator] = createSignal<{ destroy: () => void } | null>(null);

export default function DashboardPage() {
const [hotPollingPRIds, setHotPollingPRIds] = createSignal<ReadonlySet<number>>(new Set());
const [hotPollingRunIds, setHotPollingRunIds] = createSignal<ReadonlySet<number>>(new Set());

const initialTab = createMemo<TabId>(() => {
if (config.rememberLastTab) {
Expand Down Expand Up @@ -269,6 +272,16 @@ export default function DashboardPage() {
run.completedAt = update.completedAt;
}
}));
},
{
onStart: (prDbIds, runIds) => {
if (!setsEqual(hotPollingPRIds(), prDbIds)) setHotPollingPRIds(prDbIds);
if (!setsEqual(hotPollingRunIds(), runIds)) setHotPollingRunIds(runIds);
},
onEnd: () => {
if (hotPollingPRIds().size > 0) setHotPollingPRIds(new Set<number>());
if (hotPollingRunIds().size > 0) setHotPollingRunIds(new Set<number>());
},
}
));
}
Expand Down Expand Up @@ -355,13 +368,15 @@ export default function DashboardPage() {
userLogin={userLogin()}
allUsers={allUsers()}
trackedUsers={config.trackedUsers}
hotPollingPRIds={hotPollingPRIds()}
/>
</Match>
<Match when={activeTab() === "actions"}>
<ActionsTab
workflowRuns={dashboardData.workflowRuns}
loading={dashboardData.loading}
hasUpstreamRepos={config.upstreamRepos.length > 0}
hotPollingRunIds={hotPollingRunIds()}
/>
</Match>
</Switch>
Expand Down
72 changes: 45 additions & 27 deletions src/app/components/dashboard/IssuesTab.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createEffect, createMemo, createSignal, For, Show } from "solid-js";
import { config, type TrackedUser } from "../../stores/config";
import { viewState, setSortPreference, setTabFilter, resetTabFilter, resetAllTabFilters, ignoreItem, unignoreItem, toggleExpandedRepo, setAllExpanded, pruneExpandedRepos, type IssueFilterField } from "../../stores/view";
import { viewState, setSortPreference, setTabFilter, resetTabFilter, resetAllTabFilters, ignoreItem, unignoreItem, toggleExpandedRepo, setAllExpanded, pruneExpandedRepos, pruneLockedRepos, type IssueFilterField } from "../../stores/view";
import type { Issue } from "../../services/api";
import ItemRow from "./ItemRow";
import UserAvatarBadge, { buildSurfacedByUsers } from "../shared/UserAvatarBadge";
Expand All @@ -15,7 +15,9 @@ import SkeletonRows from "../shared/SkeletonRows";
import ChevronIcon from "../shared/ChevronIcon";
import ExpandCollapseButtons from "../shared/ExpandCollapseButtons";
import { deriveInvolvementRoles } from "../../lib/format";
import { groupByRepo, computePageLayout, slicePageGroups } from "../../lib/grouping";
import { groupByRepo, computePageLayout, slicePageGroups, orderRepoGroups } from "../../lib/grouping";
import { createReorderHighlight } from "../../lib/reorderHighlight";
import RepoLockControls from "../shared/RepoLockControls";

export interface IssuesTabProps {
issues: Issue[];
Expand Down Expand Up @@ -156,7 +158,9 @@ export default function IssuesTab(props: IssuesTabProps) {
const filteredSorted = createMemo(() => filteredSortedWithMeta().items);
const issueMeta = createMemo(() => filteredSortedWithMeta().meta);

const repoGroups = createMemo(() => groupByRepo(filteredSorted()));
const repoGroups = createMemo(() =>
orderRepoGroups(groupByRepo(filteredSorted()), viewState.lockedRepos.issues)
);
const pageLayout = createMemo(() => computePageLayout(repoGroups(), config.itemsPerPage));
const pageCount = createMemo(() => pageLayout().pageCount);
const pageGroups = createMemo(() =>
Expand All @@ -178,6 +182,17 @@ export default function IssuesTab(props: IssuesTabProps) {
pruneExpandedRepos("issues", names);
});

createEffect(() => {
const names = activeRepoNames();
if (names.length === 0) return;
pruneLockedRepos("issues", names);
});

const highlightedReposIssues = createReorderHighlight(
() => repoGroups().map(g => g.repoFullName),
() => viewState.lockedRepos.issues,
);

function handleSort(field: string, direction: "asc" | "desc") {
setSortPreference("issues", field, direction);
setPage(0);
Expand Down Expand Up @@ -282,30 +297,33 @@ export default function IssuesTab(props: IssuesTabProps) {

return (
<div class="bg-base-100">
<button
onClick={() => toggleExpandedRepo("issues", repoGroup.repoFullName)}
aria-expanded={isExpanded()}
class="w-full flex items-center gap-2 px-4 py-2.5 text-left text-sm font-semibold text-base-content bg-base-200/60 border-y border-base-300 hover:bg-base-200 transition-colors"
>
<ChevronIcon size="md" rotated={!isExpanded()} />
{repoGroup.repoFullName}
<Show when={!isExpanded()}>
<span class="ml-auto flex items-center gap-2 text-xs font-normal text-base-content/60">
<span>{repoGroup.items.length} {repoGroup.items.length === 1 ? "issue" : "issues"}</span>
<For each={roleSummary()}>
{([role, count]) => (
<span class={`inline-flex items-center rounded-full px-1.5 py-0.5 text-xs font-medium ${
role === "author" ? "bg-primary/10 text-primary" :
role === "assignee" ? "bg-secondary/10 text-secondary" :
"bg-base-300 text-base-content/70"
}`}>
{role} ×{count}
</span>
)}
</For>
</span>
</Show>
</button>
<div class={`group/repo-header flex items-center bg-base-200/60 border-y border-base-300 hover:bg-base-200 transition-colors duration-300 ${highlightedReposIssues().has(repoGroup.repoFullName) ? "animate-reorder-highlight" : ""}`}>
<button
onClick={() => toggleExpandedRepo("issues", repoGroup.repoFullName)}
aria-expanded={isExpanded()}
class="flex-1 flex items-center gap-2 px-4 py-2.5 text-left text-sm font-semibold text-base-content"
>
<ChevronIcon size="md" rotated={!isExpanded()} />
{repoGroup.repoFullName}
<Show when={!isExpanded()}>
<span class="ml-auto flex items-center gap-2 text-xs font-normal text-base-content/60">
<span>{repoGroup.items.length} {repoGroup.items.length === 1 ? "issue" : "issues"}</span>
<For each={roleSummary()}>
{([role, count]) => (
<span class={`inline-flex items-center rounded-full px-1.5 py-0.5 text-xs font-medium ${
role === "author" ? "bg-primary/10 text-primary" :
role === "assignee" ? "bg-secondary/10 text-secondary" :
"bg-base-300 text-base-content/70"
}`}>
{role} ×{count}
</span>
)}
</For>
</span>
</Show>
</button>
<RepoLockControls tab="issues" repoFullName={repoGroup.repoFullName} />
</div>
<Show when={isExpanded()}>
<div role="list" class="divide-y divide-base-300">
<For each={repoGroup.items}>
Expand Down
10 changes: 8 additions & 2 deletions src/app/components/dashboard/ItemRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ export interface ItemRowProps {
commentCount?: number;
hideRepo?: boolean;
surfacedByBadge?: JSX.Element;
isPolling?: boolean;
isFlashing?: boolean;
}

export default function ItemRow(props: ItemRowProps) {
Expand All @@ -28,7 +30,8 @@ export default function ItemRow(props: ItemRowProps) {
class={`group relative flex items-start gap-3
hover:bg-base-200
transition-colors
${isCompact() ? "px-4 py-2" : "px-4 py-3"}`}
${isCompact() ? "px-4 py-2" : "px-4 py-3"}
${props.isFlashing ? "animate-flash" : props.isPolling ? "animate-shimmer" : ""}`}
>
{/* Overlay link — covers entire row; interactive children use relative z-10 */}
<Show when={safeUrl()}>
Expand Down Expand Up @@ -57,7 +60,7 @@ export default function ItemRow(props: ItemRowProps) {

{/* Main content */}
<div class="flex-1 min-w-0">
<div class={`flex flex-wrap items-baseline gap-x-2 gap-y-0.5 ${isCompact() ? "text-sm" : "text-sm"}`}>
<div class="flex flex-wrap items-baseline gap-x-2 gap-y-0.5 text-sm">
<span class="text-base-content/60 shrink-0">
#{props.number}
</span>
Expand Down Expand Up @@ -100,6 +103,9 @@ export default function ItemRow(props: ItemRowProps) {
<div class="relative z-10">{props.surfacedByBadge}</div>
</Show>
<span title={props.createdAt}>{relativeTime(props.createdAt)}</span>
<Show when={props.isPolling}>
<span class="loading loading-spinner loading-xs text-base-content/40" />
</Show>
<Show when={(props.commentCount ?? 0) > 0}>
<span class="flex items-center gap-0.5">
<svg
Expand Down
Loading
Loading