feat(KB-063): add realtime GitHub badge updates

- Add a focused GitHub badge poller with shared rate-limit, freshness, and cleanup handling
- Wire a dedicated badge websocket server and routes to stream PR and issue badge snapshots
- Add a shared useBadgeWebSocket hook and update TaskCard to subscribe only when visible while preserving partial badge state
- Cover polling, websocket, and task card flows with tests and document the realtime badge channel
This commit is contained in:
gsxdsm
2026-03-30 03:19:51 -07:00
parent 93e0c12549
commit 4e23716b7d
21 changed files with 3176 additions and 199 deletions

View File

@@ -21,8 +21,8 @@ function renderHeader(props = {}) {
describe("Header", () => {
it("renders the logo and brand", () => {
renderHeader();
expect(screen.getByText("kb")).toBeDefined();
expect(screen.getByText("board")).toBeDefined();
expect(screen.getByText("Fusion")).toBeDefined();
expect(screen.getByText("tasks")).toBeDefined();
});
it("renders action buttons", () => {

View File

@@ -1,8 +1,9 @@
import { useCallback, useState, useRef, useEffect } from "react";
import { Link, Clock, Layers, Pencil, ChevronDown } from "lucide-react";
import type { Task, TaskDetail, Column } from "@kb/core";
import type { Task, TaskDetail, Column, PrInfo, IssueInfo } from "@kb/core";
import { fetchTaskDetail, uploadAttachment } from "../api";
import { GitHubBadge } from "./GitHubBadge";
import { useBadgeWebSocket } from "../hooks/useBadgeWebSocket";
import type { ToastType } from "../hooks/useToast";
const COLUMN_COLOR_MAP: Record<Column, string> = {
@@ -27,6 +28,23 @@ const EDITABLE_COLUMNS: Set<Column> = new Set(["triage", "todo"]);
const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finalizing", "merging", "specifying"]);
function pickPreferredBadge<T extends { lastCheckedAt?: string }>(
liveValue: T | null | undefined,
liveTimestamp: string | undefined,
taskValue: T | undefined,
taskTimestamp: string | undefined,
): T | undefined {
if (liveValue === undefined || !liveTimestamp) {
return taskValue;
}
if (!taskTimestamp || liveTimestamp >= taskTimestamp) {
return liveValue ?? undefined;
}
return taskValue;
}
interface TaskCardProps {
task: Task;
queued?: boolean;
@@ -64,6 +82,9 @@ export function TaskCard({
const titleInputRef = useRef<HTMLInputElement>(null);
const descTextareaRef = useRef<HTMLTextAreaElement>(null);
const touchOpenHandledRef = useRef(false);
const cardRef = useRef<HTMLDivElement>(null);
const [isInViewport, setIsInViewport] = useState(false);
const { badgeUpdates, subscribeToBadge, unsubscribeFromBadge } = useBadgeWebSocket();
const isInteractiveTarget = useCallback((target: EventTarget | null): boolean => {
if (!(target instanceof HTMLElement)) return false;
@@ -84,6 +105,26 @@ export function TaskCard({
}
}, [isEditing]);
useEffect(() => {
if (typeof IntersectionObserver === "undefined") {
setIsInViewport(true);
return;
}
const element = cardRef.current;
if (!element) return;
const observer = new IntersectionObserver(
([entry]) => {
setIsInViewport(entry?.isIntersecting ?? true);
},
{ rootMargin: "200px" },
);
observer.observe(element);
return () => observer.disconnect();
}, [isEditing, task.id]);
const handleDragStart = useCallback((e: React.DragEvent) => {
e.dataTransfer.setData("text/plain", task.id);
e.dataTransfer.effectAllowed = "move";
@@ -173,6 +214,33 @@ export function TaskCard({
// Check if this card can be edited inline
const canEdit = EDITABLE_COLUMNS.has(task.column) && !isAgentActive && !isPaused && !queued && onUpdateTask;
const hasGitHubBadge = Boolean(task.prInfo || task.issueInfo);
useEffect(() => {
if (!hasGitHubBadge || !isInViewport) {
unsubscribeFromBadge(task.id);
return;
}
subscribeToBadge(task.id);
return () => {
unsubscribeFromBadge(task.id);
};
}, [hasGitHubBadge, isInViewport, subscribeToBadge, task.id, unsubscribeFromBadge]);
const liveBadgeData = badgeUpdates.get(task.id);
const livePrInfo = pickPreferredBadge<PrInfo>(
liveBadgeData?.prInfo,
liveBadgeData?.timestamp,
task.prInfo,
task.prInfo?.lastCheckedAt ?? task.updatedAt,
);
const liveIssueInfo = pickPreferredBadge<IssueInfo>(
liveBadgeData?.issueInfo,
liveBadgeData?.timestamp,
task.issueInfo,
task.issueInfo?.lastCheckedAt ?? task.updatedAt,
);
const enterEditMode = useCallback((e?: React.MouseEvent) => {
e?.stopPropagation();
@@ -281,6 +349,7 @@ export function TaskCard({
if (isEditing) {
return (
<div
ref={cardRef}
className={cardClass}
data-id={task.id}
onDoubleClick={handleDoubleClick}
@@ -321,6 +390,7 @@ export function TaskCard({
return (
<div
ref={cardRef}
className={cardClass}
data-id={task.id}
draggable={isDraggable}
@@ -361,8 +431,8 @@ export function TaskCard({
</span>
)}
{/* GitHub badges only for tasks explicitly linked to an issue or PR */}
{(task.prInfo || task.issueInfo) && (
<GitHubBadge prInfo={task.prInfo} issueInfo={task.issueInfo} />
{(livePrInfo || liveIssueInfo) && (
<GitHubBadge prInfo={livePrInfo} issueInfo={liveIssueInfo} />
)}
{/* Edit button - visible on hover for editable cards */}
{canEdit && (

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { Column, Task, TaskDetail } from "@kb/core";
import { TaskCard } from "../TaskCard";
@@ -9,6 +9,27 @@ vi.mock("../../api", () => ({
uploadAttachment: vi.fn(),
}));
const mockUseBadgeWebSocket = vi.fn(() => ({
badgeUpdates: new Map(),
isConnected: false,
subscribeToBadge: vi.fn(),
unsubscribeFromBadge: vi.fn(),
}));
vi.mock("../../hooks/useBadgeWebSocket", () => ({
useBadgeWebSocket: () => mockUseBadgeWebSocket(),
}));
beforeEach(() => {
mockUseBadgeWebSocket.mockReset();
mockUseBadgeWebSocket.mockReturnValue({
badgeUpdates: new Map(),
isConnected: false,
subscribeToBadge: vi.fn(),
unsubscribeFromBadge: vi.fn(),
});
});
/**
* Tests for the agent-active class logic in TaskCard.
*
@@ -1527,4 +1548,263 @@ describe("TaskCard GitHub badges", () => {
const badge = container.querySelector(".card-github-badge--merged");
expect(badge).toBeDefined();
});
it("prefers newer live badge data over stale task badge data", () => {
mockUseBadgeWebSocket.mockReturnValue({
badgeUpdates: new Map([
[
"KB-099",
{
prInfo: {
url: "https://github.com/owner/repo/pull/42",
number: 42,
status: "merged",
title: "Merged PR",
headBranch: "feature/merged",
baseBranch: "main",
commentCount: 5,
},
issueInfo: null,
timestamp: "2026-03-30T12:00:00.000Z",
},
],
]),
isConnected: true,
subscribeToBadge: vi.fn(),
unsubscribeFromBadge: vi.fn(),
});
const task = makeTask({
prInfo: {
url: "https://github.com/owner/repo/pull/42",
number: 42,
status: "open",
title: "Open PR",
headBranch: "feature/bugfix",
baseBranch: "main",
commentCount: 0,
lastCheckedAt: "2026-03-30T11:00:00.000Z",
},
updatedAt: "2026-03-30T11:00:00.000Z",
});
render(
<TaskCard
task={task}
onOpenDetail={vi.fn()}
addToast={noopToast}
/>
);
expect(screen.getByTitle("PR #42: Merged PR")).toBeDefined();
});
it("falls back to newer task badge data when cached live data is older", () => {
mockUseBadgeWebSocket.mockReturnValue({
badgeUpdates: new Map([
[
"KB-099",
{
prInfo: {
url: "https://github.com/owner/repo/pull/42",
number: 42,
status: "open",
title: "Older Live PR",
headBranch: "feature/bugfix",
baseBranch: "main",
commentCount: 0,
},
timestamp: "2026-03-30T11:00:00.000Z",
},
],
]),
isConnected: false,
subscribeToBadge: vi.fn(),
unsubscribeFromBadge: vi.fn(),
});
const task = makeTask({
prInfo: {
url: "https://github.com/owner/repo/pull/42",
number: 42,
status: "merged",
title: "Fresh Task PR",
headBranch: "feature/merged",
baseBranch: "main",
commentCount: 2,
lastCheckedAt: "2026-03-30T12:00:00.000Z",
},
updatedAt: "2026-03-30T12:00:00.000Z",
});
render(
<TaskCard
task={task}
onOpenDetail={vi.fn()}
addToast={noopToast}
/>
);
expect(screen.getByTitle("PR #42: Fresh Task PR")).toBeDefined();
});
it("keeps task-provided badges when the first live update only includes one badge field", () => {
mockUseBadgeWebSocket.mockReturnValue({
badgeUpdates: new Map([
[
"KB-099",
{
issueInfo: {
url: "https://github.com/owner/repo/issues/123",
number: 123,
state: "closed",
title: "Updated issue",
stateReason: "completed",
},
timestamp: "2026-03-30T12:00:00.000Z",
},
],
]),
isConnected: true,
subscribeToBadge: vi.fn(),
unsubscribeFromBadge: vi.fn(),
});
const task = makeTask({
prInfo: {
url: "https://github.com/owner/repo/pull/42",
number: 42,
status: "open",
title: "Tracked PR",
headBranch: "feature/bugfix",
baseBranch: "main",
commentCount: 1,
lastCheckedAt: "2026-03-30T11:00:00.000Z",
},
issueInfo: {
url: "https://github.com/owner/repo/issues/123",
number: 123,
state: "open",
title: "Tracked issue",
lastCheckedAt: "2026-03-30T11:00:00.000Z",
},
updatedAt: "2026-03-30T11:00:00.000Z",
});
render(
<TaskCard
task={task}
onOpenDetail={vi.fn()}
addToast={noopToast}
/>
);
expect(screen.getByTitle("PR #42: Tracked PR")).toBeDefined();
expect(screen.getByTitle("Issue #123: Updated issue")).toBeDefined();
});
it("subscribes on mount and unsubscribes on unmount for linked GitHub tasks", () => {
const subscribeToBadge = vi.fn();
const unsubscribeFromBadge = vi.fn();
mockUseBadgeWebSocket.mockReturnValue({
badgeUpdates: new Map(),
isConnected: true,
subscribeToBadge,
unsubscribeFromBadge,
});
const task = makeTask({
prInfo: {
url: "https://github.com/owner/repo/pull/42",
number: 42,
status: "open",
title: "Tracked PR",
headBranch: "feature/bugfix",
baseBranch: "main",
commentCount: 1,
},
});
const { unmount } = render(
<TaskCard
task={task}
onOpenDetail={vi.fn()}
addToast={noopToast}
/>
);
expect(subscribeToBadge).toHaveBeenCalledWith("KB-099");
unmount();
expect(unsubscribeFromBadge).toHaveBeenCalledWith("KB-099");
});
it("waits for viewport intersection before subscribing and unsubscribes when leaving", () => {
const subscribeToBadge = vi.fn();
const unsubscribeFromBadge = vi.fn();
mockUseBadgeWebSocket.mockReturnValue({
badgeUpdates: new Map(),
isConnected: true,
subscribeToBadge,
unsubscribeFromBadge,
});
const originalIntersectionObserver = globalThis.IntersectionObserver;
const observers: Array<{ callback: IntersectionObserverCallback }> = [];
class MockIntersectionObserver {
observe = vi.fn();
disconnect = vi.fn();
unobserve = vi.fn();
root = null;
rootMargin = "200px";
thresholds = [0];
readonly takeRecords = vi.fn(() => []);
constructor(callback: IntersectionObserverCallback) {
observers.push({ callback });
}
}
(globalThis as unknown as { IntersectionObserver: typeof IntersectionObserver }).IntersectionObserver = MockIntersectionObserver as unknown as typeof IntersectionObserver;
const task = makeTask({
prInfo: {
url: "https://github.com/owner/repo/pull/42",
number: 42,
status: "open",
title: "Tracked PR",
headBranch: "feature/bugfix",
baseBranch: "main",
commentCount: 1,
},
});
try {
render(
<TaskCard
task={task}
onOpenDetail={vi.fn()}
addToast={noopToast}
/>
);
expect(subscribeToBadge).not.toHaveBeenCalled();
act(() => {
observers[0].callback([{ isIntersecting: true } as IntersectionObserverEntry], {} as IntersectionObserver);
});
expect(subscribeToBadge).toHaveBeenCalledWith("KB-099");
act(() => {
observers[0].callback([{ isIntersecting: false } as IntersectionObserverEntry], {} as IntersectionObserver);
});
expect(unsubscribeFromBadge).toHaveBeenCalledWith("KB-099");
} finally {
(globalThis as unknown as { IntersectionObserver: typeof IntersectionObserver | undefined }).IntersectionObserver = originalIntersectionObserver;
}
});
});