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:
@@ -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", () => {
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user