diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs index cdb5f298c..598276d13 100644 --- a/scripts/run-tests.mjs +++ b/scripts/run-tests.mjs @@ -434,6 +434,7 @@ export const SUITES = { "src/components/new-card-modal.test.ts", "src/lib/active-familiar.test.ts", "src/lib/use-project-familiars.test.ts", + "src/lib/project-scope.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/components/board-inspector.tsx b/src/components/board-inspector.tsx index 03a4afa59..8a460c81a 100644 --- a/src/components/board-inspector.tsx +++ b/src/components/board-inspector.tsx @@ -45,6 +45,7 @@ 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"; +import { useProjects } from "@/lib/use-projects"; const DEFAULT_TIMEOUT_MS = 2 * 60 * 60 * 1000; @@ -1179,6 +1180,28 @@ export function BoardInspector({ card, familiars, sessions, projects, onClose, o loading: eligibleFamiliarsLoading, loadedSuccessfully: eligibleFamiliarsLoaded, } = useProjectFamiliars({ projectId: card.projectId ?? null }); + // Cards can already have a familiar before their project is chosen (for + // example after an inline familiar assignment). In that direction, only + // expose projects the familiar can actually use for session launch. + const { + projects: accessibleProjects, + loading: accessibleProjectsLoading, + loadedSuccessfully: accessibleProjectsLoaded, + } = useProjects({ familiarId: card.familiarId, enabled: Boolean(card.familiarId) }); + const projectPickerReady = !card.familiarId || (accessibleProjectsLoaded && !accessibleProjectsLoading); + const projectOptions = !card.familiarId + ? [ + { value: "", label: "No project" }, + ...projects.map((project) => ({ value: project.id, label: project.name })), + ] + : accessibleProjectsLoading + ? [{ value: "", label: "Loading accessible projects…", disabled: true }] + : !accessibleProjectsLoaded + ? [{ value: "", label: "Could not load accessible projects", disabled: true }] + : [ + { value: "", label: "No project" }, + ...accessibleProjects.map((project) => ({ value: project.id, label: project.name })), + ]; // Preserve an assignment that remains authorized, but fail closed if a // project edit makes the card's familiar ineligible for its task launch. @@ -1330,24 +1353,21 @@ export function BoardInspector({ card, familiars, sessions, projects, onClose, o { - const selectedProject = projects.find((project) => project.id === next) ?? null; - // A Project → Familiar assignment is only valid after the - // target project's authorized roster resolves. Clear an - // existing familiar immediately so Start work cannot race - // that lookup with the old project's assignment. + const selectedProject = (card.familiarId ? accessibleProjects : projects) + .find((project) => project.id === next) ?? null; + // A familiar-first project list is server-scoped to that + // familiar's session-launch access, so keep the valid + // assignment. Its linked session may use another project. onPatch(card.id, { projectId: selectedProject?.id ?? null, cwd: selectedProject?.root ?? null, - familiarId: null, sessionId: null, }); }} - options={[ - { value: "", label: "No project" }, - ...projects.map((project) => ({ value: project.id, label: project.name })), - ]} + options={projectOptions} + disabled={!projectPickerReady} showCaret={false} /> diff --git a/src/components/board-project-picker.test.ts b/src/components/board-project-picker.test.ts index c4bb55648..99af62926 100644 --- a/src/components/board-project-picker.test.ts +++ b/src/components/board-project-picker.test.ts @@ -15,19 +15,29 @@ const view = readFileSync(new URL("./board-view.tsx", import.meta.url), "utf8"); assert.match(inspector, /projects: CaveProject\[\];/, "inspector Props declares a projects list"); assert.match( inspector, - /value=\{card\.projectId \?\? ""\}/, - "inspector project select is bound to the card's projectId (empty when unset)", + /value=\{projectPickerReady \? card\.projectId \?\? "" : ""\}/, + "inspector project select is bound to the card's projectId once its familiar-scoped result is ready", ); 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}sessionId: null,[\s\S]{0,180}\}\)/, - "changing the inspector project picker clears the old familiar and session 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}sessionId: null,[\s\S]{0,180}\}\)/, + "changing the inspector project picker retains the authorized familiar but unlinks its prior session", ); assert.match(inspector, /\{ value: "", label: "No project" \}/, "inspector offers a No-project option"); assert.match( inspector, - /projects\.map\(\(project\) => \(\{ value: project\.id, label: project\.name \}\)\)/, - "inspector lists every known project by id/name", + /useProjects\(\{ familiarId: card\.familiarId, enabled: Boolean\(card\.familiarId\) \}\)/, + "inspector fetches a familiar-scoped project list when a familiar was selected first", +); +assert.match( + inspector, + /accessibleProjects\.map\(\(project\) => \(\{ value: project\.id, label: project\.name \}\)\)/, + "inspector lists only projects the preselected familiar can access", +); +assert.match( + inspector, + /value=\{projectPickerReady \? card\.projectId \?\? "" : ""\}[\s\S]{0,1400}disabled=\{!projectPickerReady\}/, + "inspector suppresses stale global project options while the familiar-scoped lookup is loading", ); assert.match( inspector, @@ -73,8 +83,18 @@ assert.match( assert.match(newCard, /projectId: string \| null;/, "NewCardDraft carries projectId"); assert.match( newCard, - /useProjects\(\{ enabled: open \}\)/, - "new-card modal loads the project list before choosing a familiar", + /useProjects\(\{ familiarId, enabled: open \}\)/, + "new-card modal scopes projects when a familiar is already selected", +); +assert.match( + newCard, + /const projectPickerReady = isProjectPickerReady\(\{[\s\S]{0,200}opening,[\s\S]{0,200}loadedSuccessfully: projectsLoaded,[\s\S]{0,200}loading: projectsLoading/, + "new-card modal fails closed until the current familiar scope completes after opening", +); +assert.match( + newCard, + /value=\{projectPickerReady \? projectId \?\? "" : ""\}[\s\S]{0,1200}disabled=\{!projectPickerReady\}/, + "new-card modal disables the project picker instead of exposing a prior familiar's projects", ); assert.match(newCard, /setProjectId\(null\)/, "new-card modal resets projectId when reopened"); assert.match( @@ -98,8 +118,8 @@ assert.match( ); assert.match( newCard, - /onChange=\{\(v\) => \{[\s\S]{0,700}setProjectId\(v \|\| null\);[\s\S]{0,180}setFamiliarId\(null\);[\s\S]{0,180}setSessionId\(null\);/, - "changing a new task's project immediately clears the prior familiar while its authorized roster loads", + /onChange=\{\(v\) => \{[\s\S]{0,700}setProjectId\(v \|\| null\);[\s\S]{0,180}setSessionId\(null\);/, + "changing a new task's accessible project retains the familiar and unlinks its prior session", ); assert.match( newCard, diff --git a/src/components/new-card-modal.test.ts b/src/components/new-card-modal.test.ts index eb7d5a0bc..a9e18171e 100644 --- a/src/components/new-card-modal.test.ts +++ b/src/components/new-card-modal.test.ts @@ -11,8 +11,33 @@ assert.doesNotMatch(modal, / \{[\s\S]{0,240}setFamiliarId\(defaultFamiliarId\)/, + "new-card modal applies a reopened modal's default familiar before paint", +); +assert.match( + modal, + /const projectPickerReady = isProjectPickerReady\(\{[\s\S]{0,200}opening,[\s\S]{0,200}loadedSuccessfully: projectsLoaded,[\s\S]{0,200}loading: projectsLoading/, + "new-card modal does not enable a project picker until the current scope succeeds after opening", +); +assert.match( + modal, + /value=\{projectPickerReady \? projectId \?\? "" : ""\}[\s\S]{0,1200}disabled=\{!projectPickerReady\}/, + "new-card modal suppresses retained prior-scope project options during a familiar transition", +); assert.match( modal, /useProjectFamiliars\(\{ projectId, enabled: open \}\)/, diff --git a/src/components/new-card-modal.tsx b/src/components/new-card-modal.tsx index 9a7194011..960448bb3 100644 --- a/src/components/new-card-modal.tsx +++ b/src/components/new-card-modal.tsx @@ -1,9 +1,10 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useLayoutEffect, useRef, useState } from "react"; import type { Familiar, SessionRow } from "@/lib/types"; import { useProjects } from "@/lib/use-projects"; import { useProjectFamiliars } from "@/lib/use-project-familiars"; +import { isProjectPickerReady } from "@/lib/project-scope"; import { Modal } from "@/components/ui/modal"; import { Button } from "@/components/ui/button"; import { PropertyPill } from "@/components/ui/property-pill"; @@ -72,20 +73,32 @@ export function NewCardModal({ const [endDate, setEndDate] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); + const wasOpenRef = useRef(open); + const opening = open && !wasOpenRef.current; const coarse = useIsCoarsePointer(); - // 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 }); + // When the modal opens with a familiar already selected (such as from a + // familiar swimlane), only offer projects that familiar can launch work in. + // Project-first selection remains supported when no familiar is set. + const { + projects, + loading: projectsLoading, + loadedSuccessfully: projectsLoaded, + } = useProjects({ familiarId, enabled: open }); const { familiars: eligibleFamiliars, loading: eligibleFamiliarsLoading, loadedSuccessfully: eligibleFamiliarsLoaded, } = useProjectFamiliars({ projectId, enabled: open }); - useEffect(() => { + // This is deliberately a layout effect: on close/reopen the component stays + // mounted, so its prior familiar state exists for one render. Apply the new + // defaults before the browser can paint that stale familiar's projects. + useLayoutEffect(() => { + wasOpenRef.current = open; + }, [open]); + + useLayoutEffect(() => { if (!open) return; setTitle(defaultTitle ?? ""); setNotes(defaultNotes ?? ""); @@ -116,6 +129,26 @@ 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; + // A familiar-scoped project result must belong to the familiar currently + // selected in this modal. While a familiar changes, fail closed rather than + // briefly offering the prior familiar's retained project list. + const projectPickerReady = isProjectPickerReady({ + opening, + loadedSuccessfully: projectsLoaded, + loading: projectsLoading, + }); + const projectOptions = !projectPickerReady + ? [{ + value: "", + label: opening || projectsLoading + ? familiarId ? "Loading accessible projects…" : "Loading projects…" + : familiarId ? "Could not load accessible projects" : "Could not load projects", + disabled: true, + }] + : [ + { value: "", label: "No project" }, + ...projects.map((project) => ({ value: project.id, label: project.name })), + ]; const familiarPickerReady = !projectId || (eligibleFamiliarsLoaded && !eligibleFamiliarsLoading); const familiarOptions = !projectId ? [ @@ -138,7 +171,7 @@ export function NewCardModal({ ]; const create = async () => { - if (!title.trim() || busy) return; + if (!title.trim() || busy || (projectId && !projectPickerReady)) return; setBusy(true); setError(null); try { @@ -215,7 +248,7 @@ export function NewCardModal({ @@ -260,20 +293,16 @@ export function NewCardModal({