From c8aceb6a43ad568023dd3245bb84ac7e19b052c1 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+romgenie@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:11:48 -0400 Subject: [PATCH 01/15] fix(board): choose project before familiar --- src/app/api/familiars/route.test.ts | 12 +- src/app/api/familiars/route.ts | 18 ++- src/components/board-inspector.tsx | 118 ++++++++++++-------- src/components/board-project-picker.test.ts | 43 +++++-- src/components/new-card-modal.test.ts | 23 ++-- src/components/new-card-modal.tsx | 73 +++++++----- src/lib/project-permissions.test.ts | 6 + src/lib/project-permissions.ts | 19 ++++ src/lib/use-project-familiars.ts | 61 ++++++++++ 9 files changed, 276 insertions(+), 97 deletions(-) create mode 100644 src/lib/use-project-familiars.ts diff --git a/src/app/api/familiars/route.test.ts b/src/app/api/familiars/route.test.ts index 83d47a933..c1eca5944 100644 --- a/src/app/api/familiars/route.test.ts +++ b/src/app/api/familiars/route.test.ts @@ -55,7 +55,17 @@ assert.match( ); assert.match( source, - /rosterResult\.roster\.map\(/, + /const roster = projectId[\s\S]*?filterFamiliarsForProject\([\s\S]*?"session-launch"[\s\S]*?: rosterResult\.roster/, + "a project-scoped familiar request filters the roster with session-launch access", +); +assert.match( + source, + /const projectId = new URL\(req\.url\)\.searchParams\.get\("projectId"\)/, + "Familiars API accepts a projectId scope for dependent task pickers", +); +assert.match( + source, + /roster\.map\(/, "daemon roster and declared-only familiars still flow through the same enrichment path", ); assert.match( diff --git a/src/app/api/familiars/route.ts b/src/app/api/familiars/route.ts index db6835c99..3ab50235a 100644 --- a/src/app/api/familiars/route.ts +++ b/src/app/api/familiars/route.ts @@ -6,6 +6,7 @@ import { bindingFor, saveConfig } from "@/lib/cave-config"; import { covenHome } from "@/lib/coven-paths"; import { resolveFamiliarAvatar } from "@/lib/server/familiar-avatar"; import { loadVisibleFamiliarRoster } from "@/lib/server/familiar-roster"; +import { filterFamiliarsForProject, loadProjectPermissions } from "@/lib/project-permissions"; import { buildFamiliarsToml, familiarsTomlContainsId, @@ -30,7 +31,7 @@ export type DaemonFamiliar = { memory_freshness?: string; }; -export async function GET() { +export async function GET(req: Request) { const rosterResult = await loadVisibleFamiliarRoster(); if (!rosterResult.ok) { // Auth failures (401/403) mean the hub/daemon rejected our access token @@ -56,6 +57,19 @@ export async function GET() { ); } const { config } = rosterResult; + const projectId = new URL(req.url).searchParams.get("projectId")?.trim(); + // Board task creation selects the project first. Restrict its familiar + // picker using the same read-level session-launch rule enforced by + // /api/board/:id/chat; the route's final assertProjectAccess remains the + // authority for a launch request. + const roster = projectId + ? filterFamiliarsForProject( + await loadProjectPermissions(), + rosterResult.roster, + projectId, + "session-launch", + ) + : rosterResult.roster; // Pass `emoji` through — it's the daemon-provided default glyph the // glyph picker uses as the starting value. The Cave-local override store // (`cave-glyph-overrides.ts`) wins on render when the user picks something. @@ -65,7 +79,7 @@ export async function GET() { // content changes and server-side encoding changes refetch in desktop // WebViews. Familiars with no on-disk avatar omit it and render the glyph. const familiars = await Promise.all( - rosterResult.roster.map(async (f) => { + roster.map(async (f) => { const configEntry = config.familiars[f.id] ?? {}; const binding = bindingFor(config, f.id); const avatar = await resolveFamiliarAvatar(f.id); diff --git a/src/components/board-inspector.tsx b/src/components/board-inspector.tsx index 935c5d7a4..67c487c8c 100644 --- a/src/components/board-inspector.tsx +++ b/src/components/board-inspector.tsx @@ -42,6 +42,7 @@ import { attachmentIcon, fileToAttachment, hasDraggedFiles } from "@/lib/chat-at import type { CardPatch } from "@/lib/board-card-ops"; import { sessionStatusTone, sessionStatusWord } from "@/lib/session-status"; import { BoardInspectorDebug } from "@/components/board-inspector-debug"; +import { useProjectFamiliars } from "@/lib/use-project-familiars"; const DEFAULT_TIMEOUT_MS = 2 * 60 * 60 * 1000; @@ -1122,6 +1123,32 @@ export function BoardInspector({ card, familiars, sessions, projects, onClose, o const currentFamiliar = familiars.find((f) => f.id === card.familiarId) ?? null; const resolvedFamiliarList = useResolvedFamiliars(currentFamiliar ? [currentFamiliar] : [], { includeArchived: true }); const resolvedFamiliar = resolvedFamiliarList[0] ?? null; + const { + familiars: eligibleFamiliars, + loading: eligibleFamiliarsLoading, + loadedSuccessfully: eligibleFamiliarsLoaded, + } = useProjectFamiliars({ projectId: card.projectId ?? null }); + + // Preserve an assignment that remains authorized, but fail closed if a + // project edit makes the card's familiar ineligible for its task launch. + useEffect(() => { + if (!card.projectId || !card.familiarId || !eligibleFamiliarsLoaded) return; + if (!eligibleFamiliars.some((familiar) => familiar.id === card.familiarId)) { + onPatch(card.id, { familiarId: null }); + } + }, [card.familiarId, card.id, card.projectId, eligibleFamiliars, eligibleFamiliarsLoaded, onPatch]); + + const familiarPickerReady = Boolean(card.projectId) && eligibleFamiliarsLoaded && !eligibleFamiliarsLoading; + const familiarOptions = !card.projectId + ? [{ value: "", label: "Choose a project first", disabled: true }] + : eligibleFamiliarsLoading + ? [{ value: "", label: "Loading authorized familiars…", disabled: true }] + : !eligibleFamiliarsLoaded + ? [{ value: "", label: "Could not load authorized familiars", disabled: true }] + : [ + { value: "", label: "Unassigned" }, + ...eligibleFamiliars.map((familiar) => ({ value: familiar.id, label: familiar.display_name })), + ]; const close = () => { setClosing(true); setTimeout(onClose, 180); }; @@ -1226,52 +1253,6 @@ export function BoardInspector({ card, familiars, sessions, projects, onClose, o -
-
Familiar
-
- - {resolvedFamiliar ? ( - - ) : ( - - )} - - onPatch(card.id, { familiarId: next || null })} - options={[ - { value: "", label: "Unassigned" }, - ...familiars.map((f) => ({ value: f.id, label: f.display_name })), - ]} - showCaret={false} - /> - -
-
- -
-
-
Start date
- onPatch(card.id, { startDate: e.target.value || null })} - /> -
-
-
End date
- onPatch(card.id, { endDate: e.target.value || null })} - /> -
-
-
Project @@ -1317,6 +1298,51 @@ export function BoardInspector({ card, familiars, sessions, projects, onClose, o

) : null}
+ +
+
Familiar
+
+ + {resolvedFamiliar ? ( + + ) : ( + + )} + + onPatch(card.id, { familiarId: next || null })} + options={familiarOptions} + disabled={!familiarPickerReady} + showCaret={false} + /> + +
+
+ +
+
+
Start date
+ onPatch(card.id, { startDate: e.target.value || null })} + /> +
+
+
End date
+ onPatch(card.id, { endDate: e.target.value || null })} + /> +
+
+
diff --git a/src/components/board-project-picker.test.ts b/src/components/board-project-picker.test.ts index 0ef70cfe3..b7fee10bf 100644 --- a/src/components/board-project-picker.test.ts +++ b/src/components/board-project-picker.test.ts @@ -34,27 +34,52 @@ assert.match( /Open Projects/, "inspector offers a direct route to the project creation surface", ); +assert.ok( + inspector.indexOf('label="Project"') < inspector.indexOf('label="Familiar"'), + "inspector presents Project before Familiar", +); +assert.match( + inspector, + /useProjectFamiliars\(\{ projectId: card\.projectId \?\? null \}\)/, + "inspector requests only project-authorized familiars", +); +assert.match( + inspector, + /value=\{familiarPickerReady \? card\.familiarId \?\? "" : ""\}[\s\S]{0,200}disabled=\{!familiarPickerReady\}/, + "inspector disables and hides the familiar choice until its project authorization result is ready", +); +assert.match( + inspector, + /!eligibleFamiliars\.some\([\s\S]{0,180}onPatch\(card\.id, \{ familiarId: null \}\)/, + "inspector clears an existing familiar that is ineligible for the selected project", +); // ── New-card modal can set a project at creation time ────────────────────── assert.match(newCard, /projectId: string \| null;/, "NewCardDraft carries projectId"); -// The modal sources its own familiar-scoped project list (rather than taking an -// unscoped `projects` prop) so the Project picker only offers projects the -// assigned familiar has been granted — matching the server-side grant filter. assert.match( newCard, - /useProjects\(\{ familiarId, enabled: open \}\)/, - "new-card modal scopes its project list to the selected familiar", + /useProjects\(\{ enabled: open \}\)/, + "new-card modal loads the project list before choosing a familiar", ); assert.match(newCard, /setProjectId\(null\)/, "new-card modal resets projectId when reopened"); assert.match( newCard, - /setFamiliarId\(v \|\| null\);[\s\S]{0,400}setProjectId\(null\);/, - "switching the familiar clears the selected project so an ungranted project can't ride along the re-scope", + /useProjectFamiliars\(\{ projectId, enabled: open \}\)/, + "new-card modal fetches familiar options scoped to its selected project", +); +assert.match( + newCard, + /!eligibleFamiliars\.some\([\s\S]{0,180}setFamiliarId\(null\);[\s\S]{0,100}setSessionId\(null\);/, + "new-card modal clears an incompatible familiar and linked session after a project change", +); +assert.ok( + newCard.indexOf('') < newCard.indexOf(''), + "new-card modal presents Project before Familiar", ); assert.match( newCard, - /[\s\S]{0,600}label: projectsLoading \? "Loading projects…" : "No project" \}/, - "new-card modal renders a Project field whose default reads 'Loading projects…' while the scoped list is in flight, else 'No project'", + /value=\{familiarPickerReady \? familiarId \?\? "" : ""\}[\s\S]{0,200}disabled=\{!familiarPickerReady\}/, + "new-card modal requires a selected and authorized project before Familiar is available", ); assert.match( newCard, diff --git a/src/components/new-card-modal.test.ts b/src/components/new-card-modal.test.ts index 0be9e78d6..3b39186f1 100644 --- a/src/components/new-card-modal.test.ts +++ b/src/components/new-card-modal.test.ts @@ -10,24 +10,27 @@ assert.doesNotMatch(modal, /(null); const coarse = useIsCoarsePointer(); - // Only offer projects the assigned familiar can actually reach — the board - // POST starts the card's chat under that familiar, which the server rejects - // (403) for an ungranted project. Re-scopes when the Familiar picker below - // changes; a null familiar ("Default familiar") loads the operator-wide list. - const { projects, loading: projectsLoading } = useProjects({ familiarId, enabled: open }); + // The project comes first. Familiar choices are then fetched server-side + // with the session-launch access requirement, matching the final launch + // authorization boundary. + const { projects, loading: projectsLoading } = useProjects({ enabled: open }); + const { + familiars: eligibleFamiliars, + loading: eligibleFamiliarsLoading, + loadedSuccessfully: eligibleFamiliarsLoaded, + } = useProjectFamiliars({ projectId, enabled: open }); useEffect(() => { if (!open) return; @@ -95,6 +100,14 @@ export function NewCardModal({ setError(null); }, [open, defaultStatus, defaultFamiliarId, defaultTitle, defaultLinks, defaultNotes, defaultLabels]); + useEffect(() => { + if (!projectId || !familiarId || !eligibleFamiliarsLoaded) return; + if (!eligibleFamiliars.some((familiar) => familiar.id === familiarId)) { + setFamiliarId(null); + setSessionId(null); + } + }, [eligibleFamiliars, eligibleFamiliarsLoaded, familiarId, projectId]); + const eligibleSessions = familiarId ? sessions.filter((s) => s.familiarId === familiarId) : sessions; @@ -102,6 +115,20 @@ export function NewCardModal({ // The selected project drives the card's working directory — there is no // free-form cwd field, so drafts never carry machine-specific paths. const selectedProject = projects.find((p) => p.id === projectId) ?? null; + const familiarPickerReady = Boolean(projectId) && eligibleFamiliarsLoaded && !eligibleFamiliarsLoading; + const familiarOptions = !projectId + ? [{ value: "", label: "Choose a project first", disabled: true }] + : eligibleFamiliarsLoading + ? [{ value: "", label: "Loading authorized familiars…", disabled: true }] + : !eligibleFamiliarsLoaded + ? [{ value: "", label: "Could not load authorized familiars", disabled: true }] + : [ + { value: "", label: "Unassigned" }, + ...eligibleFamiliars.map((familiar) => ({ + value: familiar.id, + label: `${familiar.display_name} · ${familiar.harness ?? "?"}`, + })), + ]; const create = async () => { if (!title.trim() || busy) return; @@ -224,40 +251,28 @@ export function NewCardModal({ /> - - setProjectId(v || null)} options={[ - // While the familiar-scoped list is in flight, suppress the - // options entirely: the retained list belongs to the *previous* - // familiar, so offering it lets the user pick a project this - // familiar can't reach (the board chat-launch then 403s). { value: "", label: projectsLoading ? "Loading projects…" : "No project" }, ...(projectsLoading ? [] : projects.map((p) => ({ value: p.id, label: p.name }))), ]} /> + + + setProjectId(v || null)} + onChange={(v) => { + // A project change invalidates the prior familiar scope until + // the project-authorized roster has loaded. Do not let a quick + // submit carry the modal's default/previous familiar into an + // unrelated project. + setProjectId(v || null); + setFamiliarId(null); + setSessionId(null); + }} options={[ { value: "", label: projectsLoading ? "Loading projects…" : "No project" }, ...(projectsLoading ? [] : projects.map((p) => ({ value: p.id, label: p.name }))), @@ -358,10 +366,12 @@ function Select({ value, onChange, options, + disabled = false, }: { value: string; onChange: (v: string) => void; - options: { value: string; label: string }[]; + options: { value: string; label: string; disabled?: boolean }[]; + disabled?: boolean; }) { return ( ); From 16e00b1678f10548ad42080d0a99fdd8783074ff Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+romgenie@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:22:47 -0400 Subject: [PATCH 03/15] test(board): cover project familiar reset --- src/components/task-chat-cwd.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/components/task-chat-cwd.test.ts b/src/components/task-chat-cwd.test.ts index 8e34a4bb1..e847263b5 100644 --- a/src/components/task-chat-cwd.test.ts +++ b/src/components/task-chat-cwd.test.ts @@ -129,17 +129,17 @@ assert.match( ); assert.match( boardInspector, - /
[\s\S]{0,120}Project<\/span>[\s\S]{0,1100}onPatch\(card\.id, \{ projectId: selectedProject\?\.id \?\? null, cwd: selectedProject\?\.root \?\? null \}\)/, - "The task Project field should set the runtime root for chat starts", + /
[\s\S]{0,120}Project<\/span>[\s\S]{0,1700}onPatch\(card\.id, \{[\s\S]{0,180}projectId: selectedProject\?\.id \?\? null,[\s\S]{0,180}cwd: selectedProject\?\.root \?\? null,[\s\S]{0,180}familiarId: null,[\s\S]{0,180}\}\)/, + "The task Project field should set the runtime root and clear the prior familiar", ); assert.match( boardInspector, - /onPatch\(card\.id, \{ projectId: selectedProject\?\.id \?\? null, cwd: selectedProject\?\.root \?\? null \}\)/, - "Changing the task project should persist both projectId and cwd", + /onPatch\(card\.id, \{[\s\S]{0,180}projectId: selectedProject\?\.id \?\? null,[\s\S]{0,180}cwd: selectedProject\?\.root \?\? null,[\s\S]{0,180}familiarId: null,[\s\S]{0,180}\}\)/, + "Changing the task project should persist projectId and cwd, then clear the familiar", ); assert.match( boardInspector, - / \(\{ value: project\.id, label: project\.name \}\)\)/, + / \(\{ value: project\.id, label: project\.name \}\)\)/, "The task project picker should render the persisted project registry through the shared select", ); assert.doesNotMatch( From 79fbd77c29c642aa068ad052a4439d979ce4b506 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+romgenie@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:26:51 -0400 Subject: [PATCH 04/15] test(board): cover familiar reset on project change --- src/lib/board-search.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/lib/board-search.test.ts b/src/lib/board-search.test.ts index 5f7fc898c..0db5c9153 100644 --- a/src/lib/board-search.test.ts +++ b/src/lib/board-search.test.ts @@ -103,5 +103,9 @@ const boardInspector = await readFile(new URL("../components/board-inspector.tsx assert.match(boardInspector, /link-item-anchor/, "Task inspector should render task links"); assert.match(boardInspector, /card\.sessionId/, "Task inspector should render task session context"); assert.match(boardInspector, /
[\s\S]{0,120}Project<\/span>/, "Task inspector should expose the task project selector"); -assert.match(boardInspector, /onPatch\(card\.id, \{ projectId: selectedProject\?\.id \?\? null, cwd: selectedProject\?\.root \?\? null \}\)/, "Task project changes should set the persisted cwd"); +assert.match( + boardInspector, + /onPatch\(card\.id, \{[\s\S]{0,160}projectId: selectedProject\?\.id \?\? null,[\s\S]{0,160}cwd: selectedProject\?\.root \?\? null,[\s\S]{0,160}familiarId: null,[\s\S]{0,160}\}\)/, + "Task project changes should set the persisted cwd and clear the prior familiar", +); assert.doesNotMatch(boardInspector, /function openCwdInExplorer|aria-label="Open CWD in directory explorer"/, "Task inspector should not expose a separate CWD open action"); From fe546332ecf61a88a757f4fcfe8a182cb3329352 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+romgenie@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:32:41 -0400 Subject: [PATCH 05/15] fix(board): prevent stale project familiar choices --- src/components/new-card-modal.test.ts | 16 ++++++++++++++++ src/lib/use-project-familiars.ts | 20 +++++++++++++++----- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/src/components/new-card-modal.test.ts b/src/components/new-card-modal.test.ts index 49ed4a8b4..18e1f6291 100644 --- a/src/components/new-card-modal.test.ts +++ b/src/components/new-card-modal.test.ts @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; const modal = readFileSync(new URL("./new-card-modal.tsx", import.meta.url), "utf8"); +const projectFamiliars = readFileSync(new URL("../lib/use-project-familiars.ts", import.meta.url), "utf8"); assert.ok(modal.includes('import { Button } from "@/components/ui/button"'), "new-card modal action buttons use the shared Button primitive"); assert.ok(modal.includes('import { StandardSelect } from "@/components/ui/select"'), "new-card modal dropdowns use StandardSelect"); @@ -37,5 +38,20 @@ assert.match( /function Select\(\{[\s\S]{0,180}disabled = false,[\s\S]{0,500}\(null\)/, + "project-scoped familiar results retain the project that produced them", +); +assert.match( + projectFamiliars, + /loadedSuccessfully: enabled && Boolean\(projectId\) && loadedProjectId === projectId/, + "a familiar roster from the previous project never enables the picker during a project change", +); +assert.match( + projectFamiliars, + /catch \{[\s\S]{0,220}finally/, + "a failed familiar request leaves the dependent picker in its load-failure state without an unhandled rejection", +); console.log("new-card-modal.test.ts: ok"); diff --git a/src/lib/use-project-familiars.ts b/src/lib/use-project-familiars.ts index ffd4b4739..7ba695843 100644 --- a/src/lib/use-project-familiars.ts +++ b/src/lib/use-project-familiars.ts @@ -23,7 +23,10 @@ export function useProjectFamiliars({ }): ProjectFamiliarsState { const [familiars, setFamiliars] = useState([]); const [loading, setLoading] = useState(false); - const [loadedSuccessfully, setLoadedSuccessfully] = useState(false); + // Keep the result tied to the project that produced it. Effects run after + // render, so clearing state inside the effect alone would briefly expose + // the previous project's roster after projectId changes. + const [loadedProjectId, setLoadedProjectId] = useState(null); const generationRef = useRef(0); useEffect(() => { @@ -33,13 +36,13 @@ export function useProjectFamiliars({ if (!enabled || !projectId) { setFamiliars([]); setLoading(false); - setLoadedSuccessfully(false); + setLoadedProjectId(null); return; } setFamiliars([]); setLoading(true); - setLoadedSuccessfully(false); + setLoadedProjectId(null); void (async () => { try { const response = await fetch(`/api/familiars?projectId=${encodeURIComponent(projectId)}`, { @@ -49,13 +52,20 @@ export function useProjectFamiliars({ if (generationRef.current !== generation) return; if (response.ok && payload?.ok) { setFamiliars(Array.isArray(payload.familiars) ? payload.familiars : []); - setLoadedSuccessfully(true); + setLoadedProjectId(projectId); } + } catch { + // Keep the picker disabled and show its existing load-failure state. + // Fetch can reject when a locally hosted or remote Cave is restarting. } finally { if (generationRef.current === generation) setLoading(false); } })(); }, [enabled, projectId]); - return { familiars, loading, loadedSuccessfully }; + return { + familiars, + loading, + loadedSuccessfully: enabled && Boolean(projectId) && loadedProjectId === projectId, + }; } From bd3ee9015125f9170701badf60acfb762c60e862 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+romgenie@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:40:34 -0400 Subject: [PATCH 06/15] fix(board): reset stale task sessions on reassignment --- src/app/api/board/[id]/chat/route.ts | 19 ++++++++++++++++++- src/components/board-chat-link.test.ts | 5 +++++ src/components/board-inspector.tsx | 6 +++++- src/components/board-project-picker.test.ts | 8 ++++---- 4 files changed, 32 insertions(+), 6 deletions(-) diff --git a/src/app/api/board/[id]/chat/route.ts b/src/app/api/board/[id]/chat/route.ts index e9f9ab2de..5a60c4e38 100644 --- a/src/app/api/board/[id]/chat/route.ts +++ b/src/app/api/board/[id]/chat/route.ts @@ -50,7 +50,10 @@ export async function POST( ); } - if (card.sessionId) { + // Unscoped legacy tasks have no project authorization boundary to recheck. + // Project-backed cards are handled below, after their current familiar has + // been authorized for the assigned project. + if (card.sessionId && !card.projectId) { await recordSessionFamiliar(card.sessionId, familiarId); return NextResponse.json({ ok: true, @@ -106,6 +109,20 @@ export async function POST( } throw error; } + + // A persisted card/session link can outlive a permission change or a + // reassignment made by another Cave client. Never let that legacy link + // bypass the same authorization required to start a new session. + if (card.sessionId) { + await recordSessionFamiliar(card.sessionId, familiarId); + return NextResponse.json({ + ok: true, + reused: true, + card, + sessionId: card.sessionId, + familiarId, + }); + } } else { const rawProjectRoot = body.projectRoot ?? card.cwd; if (!rawProjectRoot) { diff --git a/src/components/board-chat-link.test.ts b/src/components/board-chat-link.test.ts index dec90abad..1cf12141a 100644 --- a/src/components/board-chat-link.test.ts +++ b/src/components/board-chat-link.test.ts @@ -92,6 +92,11 @@ assert.match( /projectById\(card\.projectId, await loadProjects\(\)\)[\s\S]{0,900}assertProjectAccess\(\{ familiarId \}, assignedProject\.id, "session-launch"\)/, "Board chat endpoint should resolve assigned project roots server-side and authorize the familiar", ); +assert.match( + route, + /assertProjectAccess\(\{ familiarId \}, assignedProject\.id, "session-launch"\)[\s\S]{0,700}if \(card\.sessionId\) \{[\s\S]{0,300}reused: true/, + "a project-linked session is reused only after the current familiar passes project authorization", +); assert.match( route, /project root does not match assigned task project/, diff --git a/src/components/board-inspector.tsx b/src/components/board-inspector.tsx index 90a4b5523..c2f39a073 100644 --- a/src/components/board-inspector.tsx +++ b/src/components/board-inspector.tsx @@ -1134,7 +1134,10 @@ export function BoardInspector({ card, familiars, sessions, projects, onClose, o useEffect(() => { if (!card.projectId || !card.familiarId || !eligibleFamiliarsLoaded) return; if (!eligibleFamiliars.some((familiar) => familiar.id === card.familiarId)) { - onPatch(card.id, { familiarId: null }); + // A linked session belongs to the prior familiar/project context. Keep + // it from being reopened after the task is reassigned to an authorized + // familiar, which can use a different runtime or harness. + onPatch(card.id, { familiarId: null, sessionId: null }); } }, [card.familiarId, card.id, card.projectId, eligibleFamiliars, eligibleFamiliarsLoaded, onPatch]); @@ -1284,6 +1287,7 @@ export function BoardInspector({ card, familiars, sessions, projects, onClose, o projectId: selectedProject?.id ?? null, cwd: selectedProject?.root ?? null, familiarId: null, + sessionId: null, }); }} options={[ diff --git a/src/components/board-project-picker.test.ts b/src/components/board-project-picker.test.ts index 5b07690de..1e94bb00c 100644 --- a/src/components/board-project-picker.test.ts +++ b/src/components/board-project-picker.test.ts @@ -20,8 +20,8 @@ assert.match( ); assert.match( inspector, - /onPatch\(card\.id, \{[\s\S]{0,180}projectId: selectedProject\?\.id \?\? null,[\s\S]{0,180}cwd: selectedProject\?\.root \?\? null,[\s\S]{0,180}familiarId: null,[\s\S]{0,180}\}\)/, - "changing the inspector project picker clears the old familiar before persisting the new project root", + /onPatch\(card\.id, \{[\s\S]{0,180}projectId: selectedProject\?\.id \?\? null,[\s\S]{0,180}cwd: selectedProject\?\.root \?\? null,[\s\S]{0,180}familiarId: null,[\s\S]{0,180}sessionId: null,[\s\S]{0,180}\}\)/, + "changing the inspector project picker clears the old familiar and session before persisting the new project root", ); assert.match(inspector, /\{ value: "", label: "No project" \}/, "inspector offers a No-project option"); assert.match( @@ -50,8 +50,8 @@ assert.match( ); assert.match( inspector, - /!eligibleFamiliars\.some\([\s\S]{0,180}onPatch\(card\.id, \{ familiarId: null \}\)/, - "inspector clears an existing familiar that is ineligible for the selected project", + /!eligibleFamiliars\.some\([\s\S]{0,500}onPatch\(card\.id, \{ familiarId: null, sessionId: null \}\)/, + "inspector clears an existing familiar and its stale session when it is ineligible for the selected project", ); // ── New-card modal can set a project at creation time ────────────────────── From a24ca4f565e04dff29f9f6e5727af30e3e822120 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+romgenie@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:45:25 -0400 Subject: [PATCH 07/15] fix(board): reset sessions on familiar reassignment --- src/components/board-inspector.tsx | 6 ++++-- src/components/board-project-picker.test.ts | 5 +++++ src/components/board-table-familiar-select.test.ts | 4 ++-- src/components/board-table.tsx | 5 ++++- src/components/board-ux-polish.test.ts | 5 +++++ src/components/board-view.tsx | 7 +++++-- 6 files changed, 25 insertions(+), 7 deletions(-) diff --git a/src/components/board-inspector.tsx b/src/components/board-inspector.tsx index c2f39a073..896d6c019 100644 --- a/src/components/board-inspector.tsx +++ b/src/components/board-inspector.tsx @@ -277,7 +277,9 @@ function GitHubAttachSection({ const fam = familiars.find( (f) => f.display_name?.toLowerCase() === item.repo?.toLowerCase() ); - if (fam) onPatch(card.id, { familiarId: fam.id }); + // A session carries the prior familiar's harness/runtime context. Do not + // relabel it as this familiar merely because a GitHub assignment matched. + if (fam) onPatch(card.id, { familiarId: fam.id, sessionId: null }); } const iconName = (k: string) => (KIND_ICON[k] ?? "ph:link") as IconName; @@ -1325,7 +1327,7 @@ export function BoardInspector({ card, familiars, sessions, projects, onClose, o label="Familiar" className="board-drawer-field-select board-drawer-field-select--styled" value={familiarPickerReady ? card.familiarId ?? "" : ""} - onChange={(next) => onPatch(card.id, { familiarId: next || null })} + onChange={(next) => onPatch(card.id, { familiarId: next || null, sessionId: null })} options={familiarOptions} disabled={!familiarPickerReady} showCaret={false} diff --git a/src/components/board-project-picker.test.ts b/src/components/board-project-picker.test.ts index 1e94bb00c..08e54d65d 100644 --- a/src/components/board-project-picker.test.ts +++ b/src/components/board-project-picker.test.ts @@ -53,6 +53,11 @@ assert.match( /!eligibleFamiliars\.some\([\s\S]{0,500}onPatch\(card\.id, \{ familiarId: null, sessionId: null \}\)/, "inspector clears an existing familiar and its stale session when it is ineligible for the selected project", ); +assert.match( + inspector, + /label="Familiar"[\s\S]{0,500}onChange=\{\(next\) => onPatch\(card\.id, \{ familiarId: next \|\| null, sessionId: null \}\)\}/, + "changing an authorized familiar also unlinks its prior runtime session", +); // ── New-card modal can set a project at creation time ────────────────────── assert.match(newCard, /projectId: string \| null;/, "NewCardDraft carries projectId"); diff --git a/src/components/board-table-familiar-select.test.ts b/src/components/board-table-familiar-select.test.ts index 32ac2f497..0ef83e5f1 100644 --- a/src/components/board-table-familiar-select.test.ts +++ b/src/components/board-table-familiar-select.test.ts @@ -12,8 +12,8 @@ assert.match( ); assert.match( boardTable, - / onPatch\(card\.id, \{ familiarId: next \|\| null \}\)\}[\s\S]*options=\{familiarOptions\}/, - "Familiar column should render an inline StandardSelect that patches card.familiarId", + / onPatch\(card\.id, \{ familiarId: next \|\| null, sessionId: null \}\)\}[\s\S]*options=\{familiarOptions\}/, + "Familiar column should unlink the prior runtime session when it patches card.familiarId", ); assert.match( boardTable, diff --git a/src/components/board-table.tsx b/src/components/board-table.tsx index b24289544..660e2d2a3 100644 --- a/src/components/board-table.tsx +++ b/src/components/board-table.tsx @@ -507,7 +507,10 @@ export function BoardTable({ cards, familiars, projects, groupBy, sortKey, sortD label={`Assign familiar for ${card.title}`} className="board-table-familiar-select" value={card.familiarId ?? ""} - onChange={(next) => onPatch(card.id, { familiarId: next || null })} + // A linked session was created with the prior familiar's + // harness/runtime. Reassignment must start fresh instead + // of relabelling that session under a different runtime. + onChange={(next) => onPatch(card.id, { familiarId: next || null, sessionId: null })} options={familiarOptions} /> diff --git a/src/components/board-ux-polish.test.ts b/src/components/board-ux-polish.test.ts index 0a49e6930..e200637f7 100644 --- a/src/components/board-ux-polish.test.ts +++ b/src/components/board-ux-polish.test.ts @@ -153,6 +153,11 @@ assert.match( "quick-add inherits the swimlane's familiar so the card lands in the right lane", ); assert.match(view, /onQuickAdd=\{quickAdd\}/, "BoardView wires the quick-add create path"); +assert.match( + view, + /familiarId: lane\.projectId \? null : \(lane\.familiarId !== undefined \? lane\.familiarId : \(activeFamiliarId \?\? null\)\)/, + "quick-add leaves project-lane tasks unassigned until their authorized familiar is selected", +); // ── WIP limits per column (#3) ── assert.match(kanban, /wipLimits\?:\s*WipLimits/, "BoardKanban accepts WIP limits"); diff --git a/src/components/board-view.tsx b/src/components/board-view.tsx index 03b66e8c5..912051ae7 100644 --- a/src/components/board-view.tsx +++ b/src/components/board-view.tsx @@ -516,7 +516,10 @@ export function BoardView({ // Inline quick-add from a kanban column: title-only card in that column's // status, scoped to the swimlane it was dropped under (familiar/project) or - // the active familiar when ungrouped. + // the active familiar when ungrouped. Project lanes deliberately begin + // unassigned: the active familiar may use a different harness/runtime and + // lack that project's session-launch grant, so the dependent picker must + // choose an authorized familiar first. const quickAdd = async ( status: CardStatus, title: string, @@ -527,7 +530,7 @@ export function BoardView({ notes: "", status, priority: "medium", - familiarId: lane.familiarId !== undefined ? lane.familiarId : (activeFamiliarId ?? null), + familiarId: lane.projectId ? null : (lane.familiarId !== undefined ? lane.familiarId : (activeFamiliarId ?? null)), sessionId: null, projectId: lane.projectId !== undefined ? lane.projectId : null, cwd: null, From 9c789d856f30f5d9eaac28029382ff87142cc898 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+romgenie@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:49:39 -0400 Subject: [PATCH 08/15] fix(board): preserve portable familiar task selection --- src/components/board-chat-link.test.ts | 5 +++++ src/components/board-inspector.tsx | 7 +++++-- src/components/board-project-picker.test.ts | 14 ++++++++++++-- src/components/board-view.tsx | 6 +++++- src/components/new-card-modal.test.ts | 9 +++++++-- src/components/new-card-modal.tsx | 17 ++++++++++++----- 6 files changed, 46 insertions(+), 12 deletions(-) diff --git a/src/components/board-chat-link.test.ts b/src/components/board-chat-link.test.ts index 1cf12141a..9f0e98c7e 100644 --- a/src/components/board-chat-link.test.ts +++ b/src/components/board-chat-link.test.ts @@ -37,6 +37,11 @@ assert.match( /const openTaskWork = async \(id: string\) =>/, "BoardView should expose one task-scoped work entry path", ); +assert.match( + boardView, + /if \(card\.sessionId && !card\.projectId\)/, + "project-backed task sessions must revisit the board endpoint for current authorization before opening", +); assert.match( boardView, /if \(isMobile\)[\s\S]*onJumpToSession\?\.\(/, diff --git a/src/components/board-inspector.tsx b/src/components/board-inspector.tsx index 896d6c019..b83a3067c 100644 --- a/src/components/board-inspector.tsx +++ b/src/components/board-inspector.tsx @@ -1143,9 +1143,12 @@ export function BoardInspector({ card, familiars, sessions, projects, onClose, o } }, [card.familiarId, card.id, card.projectId, eligibleFamiliars, eligibleFamiliarsLoaded, onPatch]); - const familiarPickerReady = Boolean(card.projectId) && eligibleFamiliarsLoaded && !eligibleFamiliarsLoading; + const familiarPickerReady = !card.projectId || (eligibleFamiliarsLoaded && !eligibleFamiliarsLoading); const familiarOptions = !card.projectId - ? [{ value: "", label: "Choose a project first", disabled: true }] + ? [ + { value: "", label: "Unassigned" }, + ...familiars.map((familiar) => ({ value: familiar.id, label: familiar.display_name })), + ] : eligibleFamiliarsLoading ? [{ value: "", label: "Loading authorized familiars…", disabled: true }] : !eligibleFamiliarsLoaded diff --git a/src/components/board-project-picker.test.ts b/src/components/board-project-picker.test.ts index 08e54d65d..6bb6980bd 100644 --- a/src/components/board-project-picker.test.ts +++ b/src/components/board-project-picker.test.ts @@ -46,7 +46,17 @@ assert.match( assert.match( inspector, /value=\{familiarPickerReady \? card\.familiarId \?\? "" : ""\}[\s\S]{0,200}disabled=\{!familiarPickerReady\}/, - "inspector disables and hides the familiar choice until its project authorization result is ready", + "inspector disables the familiar choice until its project authorization result is ready", +); +assert.match( + inspector, + /const familiarPickerReady = !card\.projectId \|\| \(eligibleFamiliarsLoaded && !eligibleFamiliarsLoading\)/, + "inspector keeps the familiar picker available for unscoped cards", +); +assert.match( + inspector, + /const familiarOptions = !card\.projectId[\s\S]{0,360}\.{3}familiars\.map\(\(familiar\)/, + "inspector preserves the complete familiar roster for unscoped cards", ); assert.match( inspector, @@ -84,7 +94,7 @@ assert.ok( assert.match( newCard, /value=\{familiarPickerReady \? familiarId \?\? "" : ""\}[\s\S]{0,200}disabled=\{!familiarPickerReady\}/, - "new-card modal requires a selected and authorized project before Familiar is available", + "new-card modal gates project-backed familiar choices on authorization", ); assert.match( newCard, diff --git a/src/components/board-view.tsx b/src/components/board-view.tsx index 912051ae7..b1d4ecb3e 100644 --- a/src/components/board-view.tsx +++ b/src/components/board-view.tsx @@ -796,7 +796,11 @@ export function BoardView({ const openTaskWork = async (id: string) => { const card = cards.find((candidate) => candidate.id === id); if (!card) return; - if (card.sessionId) { + // Project-backed sessions must still traverse the board-chat endpoint so + // a grant revoked after the session was created cannot be reopened from + // the Board without a fresh authorization check. Unscoped legacy cards + // have no project boundary to revalidate and can keep the direct path. + if (card.sessionId && !card.projectId) { if (isMobile) { const linkedSession = sessions.find((session) => session.id === card.sessionId); onJumpToSession?.(card.sessionId, linkedSession?.familiarId ?? card.familiarId ?? null); diff --git a/src/components/new-card-modal.test.ts b/src/components/new-card-modal.test.ts index 18e1f6291..0686b459c 100644 --- a/src/components/new-card-modal.test.ts +++ b/src/components/new-card-modal.test.ts @@ -25,14 +25,19 @@ assert.match( ); assert.match( modal, - /const familiarPickerReady = Boolean\(projectId\) && eligibleFamiliarsLoaded && !eligibleFamiliarsLoading/, - "new-card modal enables Familiar only after a project-scoped response succeeds", + /const familiarPickerReady = !projectId \|\| \(eligibleFamiliarsLoaded && !eligibleFamiliarsLoading\)/, + "new-card modal enables the complete roster for unscoped work and waits for authorization otherwise", ); assert.match( modal, /eligibleFamiliars\.map\(\(familiar\)/, "new-card modal renders only the server-authorized familiar list", ); +assert.match( + modal, + /const familiarOptions = !projectId[\s\S]{0,500}\.{3}familiars\.map\(\(familiar\)/, + "new-card modal preserves the complete familiar roster for unscoped cards", +); assert.match( modal, /function Select\(\{[\s\S]{0,180}disabled = false,[\s\S]{0,500}(null); const coarse = useIsCoarsePointer(); - // The project comes first. Familiar choices are then fetched server-side - // with the session-launch access requirement, matching the final launch - // authorization boundary. + // Project-backed familiar choices are fetched server-side with the + // session-launch access requirement, matching the final launch + // authorization boundary. Unscoped cards retain the complete roster so + // users can deliberately choose the installed harness/runtime they need. const { projects, loading: projectsLoading } = useProjects({ enabled: open }); const { familiars: eligibleFamiliars, @@ -115,9 +116,15 @@ export function NewCardModal({ // The selected project drives the card's working directory — there is no // free-form cwd field, so drafts never carry machine-specific paths. const selectedProject = projects.find((p) => p.id === projectId) ?? null; - const familiarPickerReady = Boolean(projectId) && eligibleFamiliarsLoaded && !eligibleFamiliarsLoading; + const familiarPickerReady = !projectId || (eligibleFamiliarsLoaded && !eligibleFamiliarsLoading); const familiarOptions = !projectId - ? [{ value: "", label: "Choose a project first", disabled: true }] + ? [ + { value: "", label: "Unassigned" }, + ...familiars.map((familiar) => ({ + value: familiar.id, + label: `${familiar.display_name} · ${familiar.harness ?? "?"}`, + })), + ] : eligibleFamiliarsLoading ? [{ value: "", label: "Loading authorized familiars…", disabled: true }] : !eligibleFamiliarsLoaded From a6770c64a087cafa898027bafa47a588df448f27 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+romgenie@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:57:31 -0400 Subject: [PATCH 09/15] fix(board): scope inline familiar picker by project --- .../board-table-familiar-select.test.ts | 14 +++- src/components/board-table.tsx | 37 ++++++++++- src/lib/use-project-familiars.ts | 66 +++++++++++++++++++ 3 files changed, 113 insertions(+), 4 deletions(-) diff --git a/src/components/board-table-familiar-select.test.ts b/src/components/board-table-familiar-select.test.ts index 0ef83e5f1..9ed50ea30 100644 --- a/src/components/board-table-familiar-select.test.ts +++ b/src/components/board-table-familiar-select.test.ts @@ -12,8 +12,18 @@ assert.match( ); assert.match( boardTable, - / onPatch\(card\.id, \{ familiarId: next \|\| null, sessionId: null \}\)\}[\s\S]*options=\{familiarOptions\}/, - "Familiar column should unlink the prior runtime session when it patches card.familiarId", + /useProjectFamiliarsByProject\(\{ projectIds \}\)/, + "Table mode fetches authorized familiar rosters for every project it displays", +); +assert.match( + boardTable, + /const rowFamiliarOptions = !projectId[\s\S]*?familiarOptions[\s\S]*?Loading authorized familiars…[\s\S]*?scopedFamiliars\.map/, + "project-backed table rows only offer their authorized roster while unscoped rows preserve every familiar", +); +assert.match( + boardTable, + / onPatch\(card\.id, \{ familiarId: next \|\| null, sessionId: null \}\)\}[\s\S]*options=\{rowFamiliarOptions\}[\s\S]*disabled=\{!familiarPickerReady\}/, + "Familiar column gates project-backed choices until authorization has loaded and unlinks the prior runtime session", ); assert.match( boardTable, diff --git a/src/components/board-table.tsx b/src/components/board-table.tsx index 660e2d2a3..81c176997 100644 --- a/src/components/board-table.tsx +++ b/src/components/board-table.tsx @@ -11,6 +11,7 @@ import { useResolvedFamiliars } from "@/lib/familiar-resolve"; import { useRovingTabIndex } from "@/lib/use-roving-tabindex"; import { RelativeTime } from "@/components/ui/relative-time"; import { StandardSelect } from "@/components/ui/select"; +import { useProjectFamiliarsByProject } from "@/lib/use-project-familiars"; export type GroupBy = "status" | "familiar" | "project"; export type SortKey = "title" | "status" | "priority" | "familiar" | "lifecycle" | "startDate" | "endDate" | "updatedAt"; @@ -204,6 +205,19 @@ export function BoardTable({ cards, familiars, projects, groupBy, sortKey, sortD ], [familiars], ); + // Table mode can assign a familiar inline, bypassing the inspector. Scope + // each project-backed row to the same server-authorized roster as the other + // Board pickers; cards with no project intentionally retain every installed + // runtime/harness choice. + const projectIds = useMemo( + () => cards.flatMap((card) => card.projectId ? [card.projectId] : []), + [cards], + ); + const { + familiarsByProject, + loadingProjectIds, + loadedProjectIds, + } = useProjectFamiliarsByProject({ projectIds }); // Columns rendered in the user's saved order; any unknown/new key is dropped // and any column missing from the saved order is appended so the table is @@ -497,6 +511,23 @@ export function BoardTable({ cards, familiars, projects, groupBy, sortKey, sortD ); break; case "familiar": + { + const projectId = card.projectId; + const scopedFamiliars = projectId ? familiarsByProject.get(projectId) ?? [] : familiars; + const familiarPickerReady = !projectId || ( + loadedProjectIds.has(projectId) && !loadingProjectIds.has(projectId) + ); + const rowFamiliarOptions = !projectId + ? familiarOptions + : !familiarPickerReady + ? [{ value: "", label: "Loading authorized familiars…", disabled: true }] + : [ + { value: "", label: "Unassigned" }, + ...scopedFamiliars.map((familiar) => ({ + value: familiar.id, + label: familiar.display_name, + })), + ]; content = ( @@ -506,16 +537,18 @@ export function BoardTable({ cards, familiars, projects, groupBy, sortKey, sortD onPatch(card.id, { familiarId: next || null, sessionId: null })} - options={familiarOptions} + options={rowFamiliarOptions} + disabled={!familiarPickerReady} /> ); + } break; case "lifecycle": content = ; diff --git a/src/lib/use-project-familiars.ts b/src/lib/use-project-familiars.ts index 7ba695843..62ea48334 100644 --- a/src/lib/use-project-familiars.ts +++ b/src/lib/use-project-familiars.ts @@ -9,6 +9,12 @@ export type ProjectFamiliarsState = { loadedSuccessfully: boolean; }; +export type ProjectFamiliarsByProjectState = { + familiarsByProject: ReadonlyMap; + loadingProjectIds: ReadonlySet; + loadedProjectIds: ReadonlySet; +}; + /** * Loads only familiars that may launch a session in the selected project. * Clearing the prior result before each fetch prevents a picker from briefly @@ -69,3 +75,63 @@ export function useProjectFamiliars({ loadedSuccessfully: enabled && Boolean(projectId) && loadedProjectId === projectId, }; } + +/** + * Fetches the authorized roster once per distinct project. Table mode exposes + * an inline familiar picker for many cards at once, so sharing this lookup + * keeps project-backed cards constrained without hiding the complete roster + * for intentionally unscoped tasks. + */ +export function useProjectFamiliarsByProject({ + projectIds, + enabled = true, +}: { + projectIds: readonly string[]; + enabled?: boolean; +}): ProjectFamiliarsByProjectState { + const [familiarsByProject, setFamiliarsByProject] = useState>(() => new Map()); + const [loadingProjectIds, setLoadingProjectIds] = useState>(() => new Set()); + const [loadedProjectIds, setLoadedProjectIds] = useState>(() => new Set()); + const generationRef = useRef(0); + const projectIdsKey = [...new Set(projectIds.map((projectId) => projectId.trim()).filter(Boolean))] + .sort() + .join("\u0000"); + + useEffect(() => { + generationRef.current += 1; + const generation = generationRef.current; + const ids = projectIdsKey ? projectIdsKey.split("\u0000") : []; + + if (!enabled || ids.length === 0) { + setFamiliarsByProject(new Map()); + setLoadingProjectIds(new Set()); + setLoadedProjectIds(new Set()); + return; + } + + setFamiliarsByProject(new Map()); + setLoadingProjectIds(new Set(ids)); + setLoadedProjectIds(new Set()); + void Promise.all(ids.map(async (projectId) => { + try { + const response = await fetch(`/api/familiars?projectId=${encodeURIComponent(projectId)}`, { + cache: "no-store", + }); + const payload = await response.json().catch(() => null) as { ok?: boolean; familiars?: Familiar[] } | null; + return response.ok && payload?.ok + ? [projectId, Array.isArray(payload.familiars) ? payload.familiars : []] as const + : null; + } catch { + return null; + } + })).then((results) => { + if (generationRef.current !== generation) return; + const loaded = results.filter((result): result is readonly [string, Familiar[]] => result !== null); + setFamiliarsByProject(new Map(loaded)); + setLoadedProjectIds(new Set(loaded.map(([projectId]) => projectId))); + setLoadingProjectIds(new Set()); + }); + }, [enabled, projectIdsKey]); + + return { familiarsByProject, loadingProjectIds, loadedProjectIds }; +} From 1b09bc3a5dd8b6918f875f8042d800ae92f63e52 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+romgenie@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:04:49 -0400 Subject: [PATCH 10/15] fix(board): show authorized roster load failures --- src/components/board-table-familiar-select.test.ts | 5 +++++ src/components/board-table.tsx | 8 +++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/components/board-table-familiar-select.test.ts b/src/components/board-table-familiar-select.test.ts index 9ed50ea30..eb4c1853a 100644 --- a/src/components/board-table-familiar-select.test.ts +++ b/src/components/board-table-familiar-select.test.ts @@ -20,6 +20,11 @@ assert.match( /const rowFamiliarOptions = !projectId[\s\S]*?familiarOptions[\s\S]*?Loading authorized familiars…[\s\S]*?scopedFamiliars\.map/, "project-backed table rows only offer their authorized roster while unscoped rows preserve every familiar", ); +assert.match( + boardTable, + /loadingProjectIds\.has\(projectId\)\s*\?\s*"Loading authorized familiars…"\s*:\s*"Could not load authorized familiars"/, + "a failed project roster load is distinguished from an in-flight request instead of displaying Loading forever", +); assert.match( boardTable, / onPatch\(card\.id, \{ familiarId: next \|\| null, sessionId: null \}\)\}[\s\S]*options=\{rowFamiliarOptions\}[\s\S]*disabled=\{!familiarPickerReady\}/, diff --git a/src/components/board-table.tsx b/src/components/board-table.tsx index 81c176997..e67738a43 100644 --- a/src/components/board-table.tsx +++ b/src/components/board-table.tsx @@ -520,7 +520,13 @@ export function BoardTable({ cards, familiars, projects, groupBy, sortKey, sortD const rowFamiliarOptions = !projectId ? familiarOptions : !familiarPickerReady - ? [{ value: "", label: "Loading authorized familiars…", disabled: true }] + ? [{ + value: "", + label: loadingProjectIds.has(projectId) + ? "Loading authorized familiars…" + : "Could not load authorized familiars", + disabled: true, + }] : [ { value: "", label: "Unassigned" }, ...scopedFamiliars.map((familiar) => ({ From addc0402f230184ac8330b94a99148482e83fcbc Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+romgenie@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:11:12 -0400 Subject: [PATCH 11/15] fix(board): batch project familiar roster lookups --- src/app/api/familiars/route.test.ts | 13 ++- src/app/api/familiars/route.ts | 116 ++++++++++++++++---------- src/components/new-card-modal.test.ts | 5 ++ src/lib/use-project-familiars.ts | 39 +++++---- 4 files changed, 110 insertions(+), 63 deletions(-) diff --git a/src/app/api/familiars/route.test.ts b/src/app/api/familiars/route.test.ts index c1eca5944..d4d886990 100644 --- a/src/app/api/familiars/route.test.ts +++ b/src/app/api/familiars/route.test.ts @@ -55,13 +55,18 @@ assert.match( ); assert.match( source, - /const roster = projectId[\s\S]*?filterFamiliarsForProject\([\s\S]*?"session-launch"[\s\S]*?: rosterResult\.roster/, - "a project-scoped familiar request filters the roster with session-launch access", + /const rostersByProject[\s\S]*?filterFamiliarsForProject\(permissions!, rosterResult\.roster, projectId, "session-launch"\)/, + "every project-scoped familiar request filters the roster with session-launch access", ); assert.match( source, - /const projectId = new URL\(req\.url\)\.searchParams\.get\("projectId"\)/, - "Familiars API accepts a projectId scope for dependent task pickers", + /searchParams\s*\.getAll\("projectId"\)/, + "Familiars API accepts repeated projectId scopes for dependent task pickers", +); +assert.match( + source, + /familiarsByProject: Object\.fromEntries/, + "the table can receive every project-scoped roster from one daemon/config lookup", ); assert.match( source, diff --git a/src/app/api/familiars/route.ts b/src/app/api/familiars/route.ts index 3ab50235a..3614087a8 100644 --- a/src/app/api/familiars/route.ts +++ b/src/app/api/familiars/route.ts @@ -57,19 +57,30 @@ export async function GET(req: Request) { ); } const { config } = rosterResult; - const projectId = new URL(req.url).searchParams.get("projectId")?.trim(); + const projectIds = [...new Set( + new URL(req.url).searchParams + .getAll("projectId") + .map((projectId) => projectId.trim()) + .filter(Boolean), + )]; // Board task creation selects the project first. Restrict its familiar // picker using the same read-level session-launch rule enforced by // /api/board/:id/chat; the route's final assertProjectAccess remains the // authority for a launch request. - const roster = projectId - ? filterFamiliarsForProject( - await loadProjectPermissions(), - rosterResult.roster, - projectId, - "session-launch", - ) - : rosterResult.roster; + const permissions = projectIds.length > 0 ? await loadProjectPermissions() : null; + const rostersByProject = new Map( + projectIds.map((projectId) => [ + projectId, + filterFamiliarsForProject(permissions!, rosterResult.roster, projectId, "session-launch"), + ]), + ); + const roster = projectIds.length === 0 + ? rosterResult.roster + : projectIds.length === 1 + ? rostersByProject.get(projectIds[0]) ?? [] + : [...new Map( + [...rostersByProject.values()].flat().map((familiar) => [familiar.id, familiar]), + ).values()]; // Pass `emoji` through — it's the daemon-provided default glyph the // glyph picker uses as the starting value. The Cave-local override store // (`cave-glyph-overrides.ts`) wins on render when the user picks something. @@ -78,40 +89,59 @@ export async function GET(req: Request) { // when one exists, cache-busted by file mtime plus renderer format so both // content changes and server-side encoding changes refetch in desktop // WebViews. Familiars with no on-disk avatar omit it and render the glyph. - const familiars = await Promise.all( - roster.map(async (f) => { - const configEntry = config.familiars[f.id] ?? {}; - const binding = bindingFor(config, f.id); - const avatar = await resolveFamiliarAvatar(f.id); - return { - ...f, - display_name: binding.display_name ?? f.display_name, - role: binding.role ?? f.role, - pronouns: binding.pronouns ?? f.pronouns, - description: binding.description ?? f.description, - color: binding.color, - harness: binding.harness, - defaultHarness: config.defaults.harness, - harnessOverride: configEntry.harness ?? null, - model: binding.model, - note: binding.note, - voiceProvider: binding.voiceProvider, - voiceModel: binding.voiceModel, - voiceName: binding.voiceName, - imageProvider: binding.imageProvider, - imageModel: binding.imageModel, - imageSize: binding.imageSize, - imageQuality: binding.imageQuality, - autoSelfReport: configEntry.autoSelfReport ?? false, - asanaEnabled: configEntry.asanaEnabled, - asanaWorkspaceGid: configEntry.asanaWorkspaceGid, - ...(binding.omnigent ? { omnigent: binding.omnigent } : {}), - avatarUrl: avatar - ? `/api/familiars/${encodeURIComponent(f.id)}/avatar?v=${Math.round(avatar.mtimeMs)}&format=png` - : undefined, - }; - }), - ); + const enrichFamiliar = async (f: (typeof rosterResult.roster)[number]) => { + const configEntry = config.familiars[f.id] ?? {}; + const binding = bindingFor(config, f.id); + const avatar = await resolveFamiliarAvatar(f.id); + return { + ...f, + display_name: binding.display_name ?? f.display_name, + role: binding.role ?? f.role, + pronouns: binding.pronouns ?? f.pronouns, + description: binding.description ?? f.description, + color: binding.color, + harness: binding.harness, + defaultHarness: config.defaults.harness, + harnessOverride: configEntry.harness ?? null, + model: binding.model, + note: binding.note, + voiceProvider: binding.voiceProvider, + voiceModel: binding.voiceModel, + voiceName: binding.voiceName, + imageProvider: binding.imageProvider, + imageModel: binding.imageModel, + imageSize: binding.imageSize, + imageQuality: binding.imageQuality, + autoSelfReport: configEntry.autoSelfReport ?? false, + asanaEnabled: configEntry.asanaEnabled, + asanaWorkspaceGid: configEntry.asanaWorkspaceGid, + ...(binding.omnigent ? { omnigent: binding.omnigent } : {}), + avatarUrl: avatar + ? `/api/familiars/${encodeURIComponent(f.id)}/avatar?v=${Math.round(avatar.mtimeMs)}&format=png` + : undefined, + }; + }; + const familiars = await Promise.all(roster.map(enrichFamiliar)); + + // The table can show cards from many projects at once. Fetching the daemon + // roster once per project is especially expensive for hub and remote-host + // installs, so repeated projectId parameters return each filtered roster + // from one config/permissions/daemon snapshot. + if (projectIds.length > 1) { + const familiarById = new Map(familiars.map((familiar) => [familiar.id, familiar])); + return NextResponse.json({ + ok: true, + familiarsByProject: Object.fromEntries( + projectIds.map((projectId) => [ + projectId, + (rostersByProject.get(projectId) ?? []).flatMap((familiar) => { + const enriched = familiarById.get(familiar.id); + return enriched ? [enriched] : []; + }), + ]), + ), + }); + } return NextResponse.json({ ok: true, familiars }); } diff --git a/src/components/new-card-modal.test.ts b/src/components/new-card-modal.test.ts index 0686b459c..eb7d5a0bc 100644 --- a/src/components/new-card-modal.test.ts +++ b/src/components/new-card-modal.test.ts @@ -58,5 +58,10 @@ assert.match( /catch \{[\s\S]{0,220}finally/, "a failed familiar request leaves the dependent picker in its load-failure state without an unhandled rejection", ); +assert.match( + projectFamiliars, + /for \(const projectId of ids\) search\.append\("projectId", projectId\)/, + "table project rosters are requested together so remote/hub installs do not repeat daemon lookups per project", +); console.log("new-card-modal.test.ts: ok"); diff --git a/src/lib/use-project-familiars.ts b/src/lib/use-project-familiars.ts index 62ea48334..1d691aac2 100644 --- a/src/lib/use-project-familiars.ts +++ b/src/lib/use-project-familiars.ts @@ -112,25 +112,32 @@ export function useProjectFamiliarsByProject({ setFamiliarsByProject(new Map()); setLoadingProjectIds(new Set(ids)); setLoadedProjectIds(new Set()); - void Promise.all(ids.map(async (projectId) => { + const search = new URLSearchParams(); + for (const projectId of ids) search.append("projectId", projectId); + void (async () => { try { - const response = await fetch(`/api/familiars?projectId=${encodeURIComponent(projectId)}`, { - cache: "no-store", - }); - const payload = await response.json().catch(() => null) as { ok?: boolean; familiars?: Familiar[] } | null; - return response.ok && payload?.ok - ? [projectId, Array.isArray(payload.familiars) ? payload.familiars : []] as const - : null; + const response = await fetch(`/api/familiars?${search}`, { cache: "no-store" }); + const payload = await response.json().catch(() => null) as { + ok?: boolean; + familiarsByProject?: Record; + } | null; + if (!response.ok || !payload?.ok || !payload.familiarsByProject) return; + if (generationRef.current !== generation) return; + const familiarsByProject = payload.familiarsByProject; + const loaded = ids.map((projectId) => [ + projectId, + Array.isArray(familiarsByProject[projectId]) + ? familiarsByProject[projectId] + : [], + ] as const); + setFamiliarsByProject(new Map(loaded)); + setLoadedProjectIds(new Set(ids)); } catch { - return null; + // Keep every affected picker disabled and show its existing failure state. + } finally { + if (generationRef.current === generation) setLoadingProjectIds(new Set()); } - })).then((results) => { - if (generationRef.current !== generation) return; - const loaded = results.filter((result): result is readonly [string, Familiar[]] => result !== null); - setFamiliarsByProject(new Map(loaded)); - setLoadedProjectIds(new Set(loaded.map(([projectId]) => projectId))); - setLoadingProjectIds(new Set()); - }); + })(); }, [enabled, projectIdsKey]); return { familiarsByProject, loadingProjectIds, loadedProjectIds }; From 5a8a218a9a2b57b5ab99b33b58aa2f9eddd9a61b Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+romgenie@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:18:26 -0400 Subject: [PATCH 12/15] fix(board): support single-project familiar rosters --- scripts/run-tests.mjs | 1 + src/lib/use-project-familiars.test.ts | 13 +++++++++++++ src/lib/use-project-familiars.ts | 15 +++++++++++++-- 3 files changed, 27 insertions(+), 2 deletions(-) create mode 100644 src/lib/use-project-familiars.test.ts diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs index 9ff4b5707..36ae982e4 100644 --- a/scripts/run-tests.mjs +++ b/scripts/run-tests.mjs @@ -429,6 +429,7 @@ export const SUITES = { "src/components/board-link-browser.test.ts", "src/components/new-card-modal.test.ts", "src/lib/active-familiar.test.ts", + "src/lib/use-project-familiars.test.ts", "src/lib/use-projects.test.ts", "src/lib/use-projects-race.test.ts", "src/components/calendar-view-polish.test.ts", diff --git a/src/lib/use-project-familiars.test.ts b/src/lib/use-project-familiars.test.ts new file mode 100644 index 000000000..81358ed5d --- /dev/null +++ b/src/lib/use-project-familiars.test.ts @@ -0,0 +1,13 @@ +// @ts-nocheck +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; + +const source = readFileSync(new URL("./use-project-familiars.ts", import.meta.url), "utf8"); + +assert.match( + source, + /ids\.length === 1 && Array\.isArray\(payload\.familiars\)[\s\S]*?\[ids\[0\]\]: payload\.familiars/, + "the batch project-familiar hook accepts the API's single-project response shape", +); + +console.log("project familiar hook response compatibility passed"); diff --git a/src/lib/use-project-familiars.ts b/src/lib/use-project-familiars.ts index 1d691aac2..49f08b0c0 100644 --- a/src/lib/use-project-familiars.ts +++ b/src/lib/use-project-familiars.ts @@ -119,11 +119,22 @@ export function useProjectFamiliarsByProject({ const response = await fetch(`/api/familiars?${search}`, { cache: "no-store" }); const payload = await response.json().catch(() => null) as { ok?: boolean; + familiars?: Familiar[]; familiarsByProject?: Record; } | null; - if (!response.ok || !payload?.ok || !payload.familiarsByProject) return; + if (!response.ok || !payload?.ok) return; if (generationRef.current !== generation) return; - const familiarsByProject = payload.familiarsByProject; + // `/api/familiars?projectId=…` deliberately retains its established + // single-project `{ familiars }` response for inspector/modal callers. + // A board can currently contain only one distinct project, though, in + // which case this batch hook makes that same one-id request. Accept + // both response forms so the inline picker works for single-project + // boards and during client/server version-skewed desktop updates. + const familiarsByProject = payload.familiarsByProject + ?? (ids.length === 1 && Array.isArray(payload.familiars) + ? { [ids[0]]: payload.familiars } + : null); + if (!familiarsByProject) return; const loaded = ids.map((projectId) => [ projectId, Array.isArray(familiarsByProject[projectId]) From 5b88a4d5a08f49777f738a71c6194bcbaf50447d Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+romgenie@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:40:35 -0400 Subject: [PATCH 13/15] test(board): accommodate familiar picker handler --- src/components/board-project-picker.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/board-project-picker.test.ts b/src/components/board-project-picker.test.ts index 6bb6980bd..28b763db8 100644 --- a/src/components/board-project-picker.test.ts +++ b/src/components/board-project-picker.test.ts @@ -45,7 +45,7 @@ assert.match( ); assert.match( inspector, - /value=\{familiarPickerReady \? card\.familiarId \?\? "" : ""\}[\s\S]{0,200}disabled=\{!familiarPickerReady\}/, + /value=\{familiarPickerReady \? card\.familiarId \?\? "" : ""\}[\s\S]{0,500}disabled=\{!familiarPickerReady\}/, "inspector disables the familiar choice until its project authorization result is ready", ); assert.match( From da1b6c86f2eca1baac7e7ee2a809cc8a1ee59623 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+romgenie@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:48:51 -0400 Subject: [PATCH 14/15] test(board): preserve runtime-safe familiar reassignment --- src/components/board-project-picker.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/board-project-picker.test.ts b/src/components/board-project-picker.test.ts index 28b763db8..c4bb55648 100644 --- a/src/components/board-project-picker.test.ts +++ b/src/components/board-project-picker.test.ts @@ -45,7 +45,7 @@ assert.match( ); assert.match( inspector, - /value=\{familiarPickerReady \? card\.familiarId \?\? "" : ""\}[\s\S]{0,500}disabled=\{!familiarPickerReady\}/, + /value=\{familiarPickerReady \? card\.familiarId \?\? "" : ""\}[\s\S]{0,700}disabled=\{!familiarPickerReady\}/, "inspector disables the familiar choice until its project authorization result is ready", ); assert.match( @@ -65,8 +65,8 @@ assert.match( ); assert.match( inspector, - /label="Familiar"[\s\S]{0,500}onChange=\{\(next\) => onPatch\(card\.id, \{ familiarId: next \|\| null, sessionId: null \}\)\}/, - "changing an authorized familiar also unlinks its prior runtime session", + /label="Familiar"[\s\S]{0,700}onChange=\{\(next\) => \{[\s\S]{0,500}familiarId: next \|\| null,[\s\S]{0,150}sessionId: null,[\s\S]{0,150}\.\.\.taskModelPatch\(null\)/, + "changing an authorized familiar unlinks its prior runtime session and model", ); // ── New-card modal can set a project at creation time ────────────────────── From df03c2e1d3819e0adfc3ba1436de55ce574ef8c5 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+romgenie@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:56:19 -0400 Subject: [PATCH 15/15] test(board): cover session reset on reassignment --- src/components/board-task-model.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/board-task-model.test.ts b/src/components/board-task-model.test.ts index 57d5a4def..dcc6df812 100644 --- a/src/components/board-task-model.test.ts +++ b/src/components/board-task-model.test.ts @@ -28,7 +28,7 @@ assert.match( "the inspector renders every runtime-provided model option, including authenticated catalogs", ); assert.match(inspector, /const taskModelPatch = \(modelOverride: string \| null\): CardPatch => \(\{[\s\S]{0,120}modelOverrideHarness: modelOverride \? modelHarness \|\| null : null/, "the inspector tags each task model with its selected runtime"); -assert.match(inspector, /persistTaskModelPatch\(\{ familiarId: next \|\| null, \.\.\.taskModelPatch\(null\) \}\)/, "changing familiar clears the prior task model and runtime"); +assert.match(inspector, /persistTaskModelPatch\(\{[\s\S]{0,120}familiarId: next \|\| null,[\s\S]{0,120}sessionId: null,[\s\S]{0,120}\.\.\.taskModelPatch\(null\)/, "changing familiar clears the linked session, prior task model, and runtime"); assert.match(inspector, /label="Model"/, "inspector exposes a Model control"); assert.match( inspector,