From 02b5bf3aa991588f8e220b6fadaeb366de174c12 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+romgenie@users.noreply.github.com> Date: Fri, 24 Jul 2026 22:05:17 -0400 Subject: [PATCH 1/7] fix(board): scope projects to selected familiar --- src/components/board-inspector.tsx | 42 +++++++++++++++------ src/components/board-project-picker.test.ts | 30 ++++++++++----- src/components/new-card-modal.test.ts | 9 ++++- src/components/new-card-modal.tsx | 17 ++++----- 4 files changed, 65 insertions(+), 33 deletions(-) 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..d001748b8 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,8 @@ 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, /setProjectId\(null\)/, "new-card modal resets projectId when reopened"); assert.match( @@ -98,8 +108,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..a90c82598 100644 --- a/src/components/new-card-modal.test.ts +++ b/src/components/new-card-modal.test.ts @@ -11,8 +11,13 @@ assert.doesNotMatch(modal, /(null); 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 } = useProjects({ familiarId, enabled: open }); const { familiars: eligibleFamiliars, loading: eligibleFamiliarsLoading, @@ -262,12 +261,10 @@ export function NewCardModal({ { // With a familiar already selected, this list is server-scoped // to its session-launch access, so the familiar remains valid. @@ -267,10 +288,8 @@ export function NewCardModal({ setProjectId(v || null); setSessionId(null); }} - options={[ - { value: "", label: projectsLoading ? "Loading projects…" : "No project" }, - ...(projectsLoading ? [] : projects.map((p) => ({ value: p.id, label: p.name }))), - ]} + options={projectOptions} + disabled={!projectPickerReady} /> diff --git a/src/lib/use-projects.test.ts b/src/lib/use-projects.test.ts index 111080741..0ca289731 100644 --- a/src/lib/use-projects.test.ts +++ b/src/lib/use-projects.test.ts @@ -123,13 +123,13 @@ assert.match( assert.match( source, - /const \[loadedSuccessfully, setLoadedSuccessfully\] = useState\(false\);/, - "useProjects tracks whether the current scope has ever completed a successful load", + /const scopeKey = familiarId \? `familiar:\$\{familiarId\}` : "unscoped";[\s\S]*const \[loadedScopeKey, setLoadedScopeKey\] = useState\(null\);[\s\S]*const loadedSuccessfully = enabled && loadedScopeKey === scopeKey;/, + "useProjects reports success only when the response belongs to the current familiar scope", ); assert.match( source, - /if \(data\.ok === false\) \{[\s\S]*setError\(data\.error \?\? "Failed to load projects"\);[\s\S]*\} else \{[\s\S]*setProjects\(sortProjectsAlphabetically\(Array\.isArray\(data\.projects\) \? data\.projects : \[\]\)\);[\s\S]*setLoadedSuccessfully\(true\);[\s\S]*\}/, - "loadedSuccessfully only flips true after a successful payload, not on error payloads", + /if \(data\.ok === false\) \{[\s\S]*setError\(data\.error \?\? "Failed to load projects"\);[\s\S]*\} else \{[\s\S]*setProjects\(sortProjectsAlphabetically\(Array\.isArray\(data\.projects\) \? data\.projects : \[\]\)\);[\s\S]*setLoadedScopeKey\(scopeKey\);[\s\S]*\}/, + "a scope becomes ready only after its own successful payload, not an error or another scope's payload", ); assert.match( source, diff --git a/src/lib/use-projects.ts b/src/lib/use-projects.ts index bea3df100..693160263 100644 --- a/src/lib/use-projects.ts +++ b/src/lib/use-projects.ts @@ -56,7 +56,12 @@ export function useProjects({ enabled = true, familiarId = null }: UseProjectsOp const [projects, setProjects] = useState([]); const [loading, setLoading] = useState(enabled); const [error, setError] = useState(null); - const [loadedSuccessfully, setLoadedSuccessfully] = useState(false); + // The effect below clears state after render. Keep the scope that produced + // the successful response so callers can fail closed during that render + // when familiarId has already changed but the previous list is still held. + const scopeKey = familiarId ? `familiar:${familiarId}` : "unscoped"; + const [loadedScopeKey, setLoadedScopeKey] = useState(null); + const loadedSuccessfully = enabled && loadedScopeKey === scopeKey; // Generation guard: bumped on every load() call, scope change, and disable, // so a stale response can't write into newer state. (Replaces the previous // per-instance AbortController — the shared, coalesced request can't be @@ -76,7 +81,7 @@ export function useProjects({ enabled = true, familiarId = null }: UseProjectsOp setError(data.error ?? "Failed to load projects"); } else { setProjects(sortProjectsAlphabetically(Array.isArray(data.projects) ? data.projects : [])); - setLoadedSuccessfully(true); + setLoadedScopeKey(scopeKey); } } catch (err) { if (generationRef.current === gen) { @@ -85,7 +90,7 @@ export function useProjects({ enabled = true, familiarId = null }: UseProjectsOp } finally { if (generationRef.current === gen) setLoading(false); } - }, [familiarId]); + }, [familiarId, scopeKey]); useEffect(() => { if (!enabled) { @@ -100,7 +105,7 @@ export function useProjects({ enabled = true, familiarId = null }: UseProjectsOp // so this effect only re-runs when the scope or `enabled` actually changes; // a manual reload() after a mutation calls load() directly and is // unaffected, so an in-place refresh never blanks the list. - setLoadedSuccessfully(false); + setLoadedScopeKey(null); setProjects([]); load(); return () => { From 11174898454f787b762bf6fbd7fe7220022f271e Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+romgenie@users.noreply.github.com> Date: Fri, 24 Jul 2026 22:27:58 -0400 Subject: [PATCH 3/7] test(projects): cover familiar scope transitions --- scripts/run-tests.mjs | 1 + src/lib/project-scope.test.ts | 19 +++++++++++++++++++ src/lib/project-scope.ts | 12 ++++++++++++ src/lib/use-projects.test.ts | 2 +- src/lib/use-projects.ts | 5 +++-- 5 files changed, 36 insertions(+), 3 deletions(-) create mode 100644 src/lib/project-scope.test.ts create mode 100644 src/lib/project-scope.ts 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/lib/project-scope.test.ts b/src/lib/project-scope.test.ts new file mode 100644 index 000000000..2b0650254 --- /dev/null +++ b/src/lib/project-scope.test.ts @@ -0,0 +1,19 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { isCurrentProjectScope, projectScopeKey } from "./project-scope.ts"; + +test("a completed project response becomes unavailable immediately when familiar scope changes", () => { + const globalResult = projectScopeKey(null); + const familiarAResult = projectScopeKey("familiar-a"); + + assert.equal(isCurrentProjectScope(globalResult, null), true, "the unscoped result is usable for an unscoped picker"); + assert.equal(isCurrentProjectScope(globalResult, "familiar-a"), false, "a familiar must not see the retained global result"); + assert.equal(isCurrentProjectScope(familiarAResult, "familiar-a"), true, "the matching familiar may use its completed result"); + assert.equal(isCurrentProjectScope(familiarAResult, "familiar-b"), false, "a second familiar must not see familiar A's retained result"); +}); + +test("pending or failed scoped loads never become ready", () => { + assert.equal(isCurrentProjectScope(null, "familiar-a"), false, "a pending scope has no successful result"); + assert.equal(isCurrentProjectScope(null, null), false, "a failed unscoped request has no successful result"); +}); diff --git a/src/lib/project-scope.ts b/src/lib/project-scope.ts new file mode 100644 index 000000000..f9b59f329 --- /dev/null +++ b/src/lib/project-scope.ts @@ -0,0 +1,12 @@ +/** Stable identity for a project result's familiar access scope. */ +export function projectScopeKey(familiarId: string | null): string { + return familiarId ? `familiar:${familiarId}` : "unscoped"; +} + +/** True only when a successful project response belongs to the current scope. */ +export function isCurrentProjectScope( + loadedScopeKey: string | null, + familiarId: string | null, +): boolean { + return loadedScopeKey === projectScopeKey(familiarId); +} diff --git a/src/lib/use-projects.test.ts b/src/lib/use-projects.test.ts index 0ca289731..86ae5a17a 100644 --- a/src/lib/use-projects.test.ts +++ b/src/lib/use-projects.test.ts @@ -123,7 +123,7 @@ assert.match( assert.match( source, - /const scopeKey = familiarId \? `familiar:\$\{familiarId\}` : "unscoped";[\s\S]*const \[loadedScopeKey, setLoadedScopeKey\] = useState\(null\);[\s\S]*const loadedSuccessfully = enabled && loadedScopeKey === scopeKey;/, + /const scopeKey = projectScopeKey\(familiarId\);[\s\S]*const \[loadedScopeKey, setLoadedScopeKey\] = useState\(null\);[\s\S]*const loadedSuccessfully = enabled && isCurrentProjectScope\(loadedScopeKey, familiarId\);/, "useProjects reports success only when the response belongs to the current familiar scope", ); assert.match( diff --git a/src/lib/use-projects.ts b/src/lib/use-projects.ts index 693160263..fd5dbc202 100644 --- a/src/lib/use-projects.ts +++ b/src/lib/use-projects.ts @@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { sortProjectsAlphabetically, type CaveProject } from "@/lib/cave-projects-types"; +import { isCurrentProjectScope, projectScopeKey } from "./project-scope.ts"; import { emitProjectRegistryMutation, subscribeProjectRegistryMutation } from "./project-registry-events.ts"; import { applyProjectRegistryMutation } from "./project-registry-mutation.ts"; import { clearProjectsCache, fetchProjectsFromCache, type ProjectsPayload } from "./use-projects-cache.ts"; @@ -59,9 +60,9 @@ export function useProjects({ enabled = true, familiarId = null }: UseProjectsOp // The effect below clears state after render. Keep the scope that produced // the successful response so callers can fail closed during that render // when familiarId has already changed but the previous list is still held. - const scopeKey = familiarId ? `familiar:${familiarId}` : "unscoped"; + const scopeKey = projectScopeKey(familiarId); const [loadedScopeKey, setLoadedScopeKey] = useState(null); - const loadedSuccessfully = enabled && loadedScopeKey === scopeKey; + const loadedSuccessfully = enabled && isCurrentProjectScope(loadedScopeKey, familiarId); // Generation guard: bumped on every load() call, scope change, and disable, // so a stale response can't write into newer state. (Replaces the previous // per-instance AbortController — the shared, coalesced request can't be From 832ce13d702a6c05f0b0ed68f197dbd817511c57 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+romgenie@users.noreply.github.com> Date: Fri, 24 Jul 2026 22:35:05 -0400 Subject: [PATCH 4/7] fix(board): close familiar project scope races --- src/components/new-card-modal.test.ts | 9 ++++-- src/components/new-card-modal.tsx | 45 +++++++++++++++++---------- src/components/task-chat-cwd.test.ts | 8 ++--- src/lib/project-scope.test.ts | 25 ++++++++++++++- src/lib/project-scope.ts | 16 ++++++++++ 5 files changed, 80 insertions(+), 23 deletions(-) diff --git a/src/components/new-card-modal.test.ts b/src/components/new-card-modal.test.ts index 2123ee1b7..a9e18171e 100644 --- a/src/components/new-card-modal.test.ts +++ b/src/components/new-card-modal.test.ts @@ -25,8 +25,13 @@ assert.match( ); assert.match( modal, - /const projectPickerReady = !familiarId \|\| \(projectsLoaded && !projectsLoading\)/, - "new-card modal does not enable a familiar-scoped project picker until that familiar's result succeeds", + /const opening = open && !wasOpenRef\.current;[\s\S]{0,1800}useLayoutEffect\(\(\) => \{[\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, diff --git a/src/components/new-card-modal.tsx b/src/components/new-card-modal.tsx index 219f83545..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,6 +73,8 @@ 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(); // When the modal opens with a familiar already selected (such as from a @@ -88,7 +91,14 @@ export function NewCardModal({ 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 ?? ""); @@ -122,20 +132,23 @@ export function NewCardModal({ // 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 = !familiarId || (projectsLoaded && !projectsLoading); - const projectOptions = !familiarId - ? [ - { value: "", label: projectsLoading ? "Loading projects…" : "No project" }, - ...(projectsLoading ? [] : projects.map((project) => ({ value: project.id, label: project.name }))), - ] - : projectsLoading - ? [{ value: "", label: "Loading accessible projects…", disabled: true }] - : !projectsLoaded - ? [{ value: "", label: "Could not load accessible projects", disabled: true }] - : [ - { value: "", label: "No project" }, - ...projects.map((project) => ({ value: project.id, label: project.name })), - ]; + 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 ? [ diff --git a/src/components/task-chat-cwd.test.ts b/src/components/task-chat-cwd.test.ts index 31805b487..51fab0e60 100644 --- a/src/components/task-chat-cwd.test.ts +++ b/src/components/task-chat-cwd.test.ts @@ -139,13 +139,13 @@ assert.match( ); assert.match( boardInspector, - /
[\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", + /
[\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}sessionId: null,[\s\S]{0,180}\}\)/, + "The task Project field should set the runtime root and clear the prior session", ); -assert.match( +assert.doesNotMatch( boardInspector, /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", + "A familiar-scoped project choice must preserve its authorized familiar", ); assert.match( boardInspector, diff --git a/src/lib/project-scope.test.ts b/src/lib/project-scope.test.ts index 2b0650254..d544fa264 100644 --- a/src/lib/project-scope.test.ts +++ b/src/lib/project-scope.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { isCurrentProjectScope, projectScopeKey } from "./project-scope.ts"; +import { isCurrentProjectScope, isProjectPickerReady, projectScopeKey } from "./project-scope.ts"; test("a completed project response becomes unavailable immediately when familiar scope changes", () => { const globalResult = projectScopeKey(null); @@ -17,3 +17,26 @@ test("pending or failed scoped loads never become ready", () => { assert.equal(isCurrentProjectScope(null, "familiar-a"), false, "a pending scope has no successful result"); assert.equal(isCurrentProjectScope(null, null), false, "a failed unscoped request has no successful result"); }); + +test("a reopened modal keeps its previous familiar's result unavailable until new defaults load", () => { + assert.equal( + isProjectPickerReady({ opening: true, loadedSuccessfully: true, loading: false }), + false, + "reopening must not expose familiar A's retained result before default familiar B applies", + ); + assert.equal( + isProjectPickerReady({ opening: false, loadedSuccessfully: false, loading: true }), + false, + "familiar B stays unavailable while its request is pending", + ); + assert.equal( + isProjectPickerReady({ opening: false, loadedSuccessfully: false, loading: false }), + false, + "a failed familiar B request stays unavailable", + ); + assert.equal( + isProjectPickerReady({ opening: false, loadedSuccessfully: true, loading: false }), + true, + "only familiar B's completed result enables the picker", + ); +}); diff --git a/src/lib/project-scope.ts b/src/lib/project-scope.ts index f9b59f329..19f5fdbc9 100644 --- a/src/lib/project-scope.ts +++ b/src/lib/project-scope.ts @@ -10,3 +10,19 @@ export function isCurrentProjectScope( ): boolean { return loadedScopeKey === projectScopeKey(familiarId); } + +/** + * A picker must stay unavailable while a modal is applying a new set of + * defaults, even if the previous familiar's request had completed. + */ +export function isProjectPickerReady({ + opening, + loadedSuccessfully, + loading, +}: { + opening: boolean; + loadedSuccessfully: boolean; + loading: boolean; +}): boolean { + return !opening && loadedSuccessfully && !loading; +} From 147d2b9ff6f5ea6857e425cd2d6ce50a5d544291 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+romgenie@users.noreply.github.com> Date: Fri, 24 Jul 2026 22:40:44 -0400 Subject: [PATCH 5/7] test(board): align project picker scope gate --- src/components/board-project-picker.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/board-project-picker.test.ts b/src/components/board-project-picker.test.ts index ca32bcf13..99af62926 100644 --- a/src/components/board-project-picker.test.ts +++ b/src/components/board-project-picker.test.ts @@ -88,8 +88,8 @@ assert.match( ); assert.match( newCard, - /const projectPickerReady = !familiarId \|\| \(projectsLoaded && !projectsLoading\)/, - "new-card modal fails closed while the current familiar-scoped project result is pending", + /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, From 312a150681c142c1122e42c373459dcaad808a0c Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+romgenie@users.noreply.github.com> Date: Fri, 24 Jul 2026 22:44:09 -0400 Subject: [PATCH 6/7] test(board): cover guarded inspector projects --- src/components/task-chat-cwd.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/task-chat-cwd.test.ts b/src/components/task-chat-cwd.test.ts index 51fab0e60..440fec51a 100644 --- a/src/components/task-chat-cwd.test.ts +++ b/src/components/task-chat-cwd.test.ts @@ -149,8 +149,8 @@ assert.doesNotMatch( ); assert.match( boardInspector, - / \(\{ value: project\.id, label: project\.name \}\)\)/, - "The task project picker should render the persisted project registry through the shared select", + / Date: Sat, 25 Jul 2026 07:44:23 -0400 Subject: [PATCH 7/7] test(board): preserve familiar project assignments --- src/lib/board-search.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/lib/board-search.test.ts b/src/lib/board-search.test.ts index 633d9c4a4..3a92e433c 100644 --- a/src/lib/board-search.test.ts +++ b/src/lib/board-search.test.ts @@ -108,8 +108,13 @@ assert.match(boardInspector, /link-item-anchor/, "Task inspector should render t 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, \{[\s\S]{0,160}projectId: selectedProject\?\.id \?\? null,[\s\S]{0,160}cwd: selectedProject\?\.root \?\? null,[\s\S]{0,160}sessionId: null,[\s\S]{0,160}\}\)/, + "Task project changes should set the persisted cwd and clear the prior session", +); +assert.doesNotMatch( 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", + "A familiar-scoped project change should preserve its authorized familiar", ); assert.doesNotMatch(boardInspector, /function openCwdInExplorer|aria-label="Open CWD in directory explorer"/, "Task inspector should not expose a separate CWD open action");