\(\{ 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