Skip to content
49 changes: 49 additions & 0 deletions src/components/chat-new-dashboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { readFile } from "node:fs/promises";
const dash = await readFile(new URL("./chat-new-dashboard.tsx", import.meta.url), "utf8");
const chatView = await readFile(new URL("./chat-view.tsx", import.meta.url), "utf8");
const homeComposer = await readFile(new URL("./home-composer.tsx", import.meta.url), "utf8");
const boardHook = await readFile(new URL("./home/use-dashboard-board.ts", import.meta.url), "utf8");
const css = await readFile(new URL("../styles/home-dashboard.css", import.meta.url), "utf8");

// ── (1) chat-view's empty-state split ────────────────────────────────────
Expand Down Expand Up @@ -118,6 +119,54 @@ assert.match(
assert.match(dash, /const boardCards = useDashboardBoard\(\)/, "the board reads the live Tasks board");
assert.match(dash, /fetch\("\/api\/inbox", \{ cache: "no-store"/, "the needs-you tier reads the live inbox");
assert.match(dash, /groupInboxFeed\(items\)\.needsYou/, "needs-you uses the same attention tier as the bell");
assert.match(
boardHook,
/familiarId: string \| null/,
"the lean dashboard-card projection must retain familiar ownership",
);
assert.match(
boardHook,
/familiarId: c\.familiarId \?\? null/,
"the Board response must carry familiar ownership into the dashboard model",
);
assert.match(
dash,
/filterFamiliarOwned\(boardCards,\s*familiar\.id\)/,
"Board open work must scope to the selected familiar before row derivation",
);
assert.match(
dash,
/filterFamiliarOwned\(needsYou,\s*familiar\.id\)/,
"Needs-you inbox work must scope to the selected familiar before row derivation",
);
assert.match(
dash,
/openWorkRows\(scopedBoardCards\)/,
"open-work counts, caps, and empty states must derive from scoped Board cards",
);
assert.match(
dash,
/scopedNeedsYou\.map/,
"open-work counts, caps, and empty states must derive from scoped Needs-you items",
);
assert.match(
dash,
/workFilter === "inbox" && scopedNeedsYou\.length > 0/,
"the Needs-you section link must use the familiar-scoped count",
);
assert.match(
dash,
/filterVisibleChatSessions\(sessions,\s*familiar\.id\)[\s\S]{0,300}?\.slice\(0,\s*RECENT_THREADS_CAP\)/,
"Recent threads must apply shared familiar visibility before the three-row cap",
);
assert.doesNotMatch(
dash.slice(
dash.indexOf("const recentThreads = useMemo"),
dash.indexOf("return (", dash.indexOf("const recentThreads = useMemo")),
),
/\.sort\(/,
"Recent threads preserve the shared helper's canonical newest-first ordering",
);
assert.match(dash, /OPEN_WORK_FILTERS\.map/, "the board renders the trimmed filter tabs");
assert.match(
dash,
Expand Down
28 changes: 19 additions & 9 deletions src/components/chat-new-dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,12 @@ import { Icon } from "@/lib/icon";
import { groupInboxFeed } from "@/lib/inbox-feed";
import { greetingForHour } from "@/lib/home-greeting";
import { relativeAge } from "@/lib/rss";
import { filterVisibleChatSessions } from "@/lib/chat-projects";
import { useDashboardBoard } from "@/components/home/use-dashboard-board";
import {
OPEN_WORK_FILTERS,
OPEN_WORK_FILTER_LABEL,
filterFamiliarOwned,
filterOpenWork,
openWorkCounts,
openWorkPriorityLabel,
Expand Down Expand Up @@ -126,12 +128,20 @@ export function ChatNewDashboard({
// that item's session (or land on Schedules), read-stamped like the bell.
const boardCards = useDashboardBoard();
const needsYou = useNeedsYou();
const scopedBoardCards = useMemo(
() => filterFamiliarOwned(boardCards, familiar.id),
[boardCards, familiar.id],
);
const scopedNeedsYou = useMemo(
() => filterFamiliarOwned(needsYou, familiar.id),
[needsYou, familiar.id],
);
const openWork = useMemo(() => {
const board = openWorkRows(boardCards).map((r) => ({
const board = openWorkRows(scopedBoardCards).map((r) => ({
...r,
onOpen: () => navigateMode("board"),
}));
const needs = needsYou.map((item) => ({
const needs = scopedNeedsYou.map((item) => ({
id: `needs-${item.id}`,
title: item.title,
kind: "inbox" as const,
Expand All @@ -148,7 +158,7 @@ export function ChatNewDashboard({
},
}));
return [...board, ...needs];
}, [boardCards, needsYou]);
}, [scopedBoardCards, scopedNeedsYou]);
const [workFilter, setWorkFilter] = useState<OpenWorkFilter>("all");
const workCounts = useMemo(() => openWorkCounts(openWork), [openWork]);
// Capped so the no-scroll board fits the pane; "View all in Tasks →" carries
Expand All @@ -157,13 +167,13 @@ export function ChatNewDashboard({
() => filterOpenWork(openWork, workFilter).slice(0, OPEN_WORK_ROWS_CAP),
[openWork, workFilter],
);
// Recent threads: the freshest titled sessions, newest-first, capped.
// Recent threads: this familiar's freshest titled sessions, newest-first,
// capped after applying the shared chat visibility contract.
const recentThreads = useMemo(() => {
return sessions
.filter((s) => !s.archived_at && !s.generated && Boolean(s.title?.trim()))
.sort((a, b) => (b.updated_at ?? "").localeCompare(a.updated_at ?? ""))
return filterVisibleChatSessions(sessions, familiar.id)
.filter((s) => Boolean(s.title?.trim()))
.slice(0, RECENT_THREADS_CAP);
Comment thread
BunsDev marked this conversation as resolved.
}, [sessions]);
}, [sessions, familiar.id]);

return (
<div className="home-dash__body home-dash--embed select-none" data-testid="chat-new-dashboard">
Expand Down Expand Up @@ -213,7 +223,7 @@ export function ChatNewDashboard({
<section className="home-dash__section" aria-label="Open work">
<div className="home-dash__section-head">
<div className="home-dash__section-label">Open work</div>
{workFilter === "inbox" && needsYou.length > 0 ? (
{workFilter === "inbox" && scopedNeedsYou.length > 0 ? (
<button
type="button"
className="home-dash__section-link"
Expand Down
17 changes: 17 additions & 0 deletions src/components/home/dashboard-open-work.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Pure derivations for the Home dashboard's Open-work board (launcher 3a).
import assert from "node:assert/strict";
import {
filterFamiliarOwned,
filterOpenWork,
openWorkCounts,
openWorkPriorityLabel,
Expand All @@ -20,6 +21,22 @@ const card = (over = {}) => ({
...over,
});

// ── familiar ownership: exact match; foreign + unassigned work stay out ──
const owned = filterFamiliarOwned(
[
{ id: "sage", familiarId: "sage" },
{ id: "nova", familiarId: "nova" },
{ id: "unassigned", familiarId: null },
{ id: "missing" },
],
"sage",
);
assert.deepEqual(
owned.map((item) => item.id),
["sage"],
"only exact familiar ownership should survive",
);

// ── openWorkRows: maps columns, drops done + untitled, orders by kind/priority
{
const rows = openWorkRows([
Expand Down
9 changes: 9 additions & 0 deletions src/components/home/dashboard-open-work.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,15 @@ export type OpenWorkRow = {
timeoutMs?: number;
};

/** Keep only work explicitly owned by one familiar. Unassigned work is not
* part of a selected familiar's New Chat dashboard. */
export function filterFamiliarOwned<T extends { familiarId?: string | null }>(
items: readonly T[],
familiarId: string,
): T[] {
return items.filter((item) => item.familiarId === familiarId);
}

/** Board columns that count as open work, in the order they should read. */
const KIND_RANK: Record<OpenWorkKind, number> = {
running: 0,
Expand Down
9 changes: 6 additions & 3 deletions src/components/home/use-dashboard-board.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@

// Board snapshot for the Home dashboard's "Open work" board (launcher 3a).
// One /api/board GET, mapped to the lean fields the board rows and filter tabs
// need: title, column status (→ row kind + chip), priority, and — for running
// cards — the timeout badge inputs (runningSince/timeoutMs). A failed fetch
// leaves the empty list so the board degrades to an empty state, never errors.
// need: title, familiar ownership, column status (→ row kind + chip), priority,
// and — for running cards — the timeout badge inputs (runningSince/timeoutMs).
// A failed fetch leaves the empty list so the board degrades to an empty state,
// never errors.
//
// This is a dedicated dashboard fetch for suggestion and pending-task
// helper) because the dashboard needs the richer per-card lifecycle fields
Expand All @@ -16,6 +17,7 @@ import type { CardLifecycle, CardPriority, CardStatus } from "@/lib/cave-board-t
export type DashboardCard = {
id: string;
title: string;
familiarId: string | null;
status: CardStatus;
priority: CardPriority;
lifecycle: CardLifecycle;
Expand All @@ -38,6 +40,7 @@ export function useDashboardBoard(): DashboardCard[] {
(c: DashboardCard): DashboardCard => ({
id: c.id,
title: c.title,
familiarId: c.familiarId ?? null,
status: c.status,
priority: c.priority,
lifecycle: c.lifecycle,
Expand Down
4 changes: 2 additions & 2 deletions tests/chat-boot-landing.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ const SESSION_S1 = {
updated_at: iso(2),
};

// Unassigned inbox card — fair game for this familiar's resume pills.
// Familiar-owned inbox card — this landing must only surface Nova's work.
const BOARD = {
ok: true,
cards: [
Expand All @@ -45,7 +45,7 @@ const BOARD = {
title: "Fix login flow",
status: "inbox",
priority: "medium",
familiarId: null,
familiarId: "nova",
projectId: null,
cwd: null,
createdAt: iso(6),
Expand Down
Loading