Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions scripts/run-tests.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
42 changes: 31 additions & 11 deletions src/components/board-inspector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -1330,24 +1353,21 @@ export function BoardInspector({ card, familiars, sessions, projects, onClose, o
<StandardSelect
label="Project"
className="board-drawer-field-select board-drawer-field-select--styled"
value={card.projectId ?? ""}
value={projectPickerReady ? card.projectId ?? "" : ""}
onChange={(next) => {
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}
/>
<Icon name="ph:caret-up-down-bold" width={11} className="board-drawer-select-caret" />
Expand Down
40 changes: 30 additions & 10 deletions src/components/board-project-picker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand All @@ -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,
Expand Down
29 changes: 27 additions & 2 deletions src/components/new-card-modal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,33 @@ assert.doesNotMatch(modal, /<button\b/, "new-card modal should not hand-roll but
assert.doesNotMatch(modal, /<select\b|<option\b/, "new-card modal should not use native select controls");
assert.doesNotMatch(modal, /rounded-md/, "new-card modal should use control radius tokens instead of hard-coded rounded-md");

// The Project is chosen first. Its server-filtered familiar list must not offer
// stale options while a project access lookup is in flight.
// When a New Task opens with a familiar already selected, its project list is
// server-filtered. The Project-first path still filters familiars afterward.
assert.match(
modal,
/useProjects\(\{ familiarId, enabled: open \}\)/,
"new-card modal fetches only projects accessible to its preselected familiar",
);
assert.match(
modal,
/loadedSuccessfully: projectsLoaded/,
"new-card modal reads the project scope readiness signal",
);
assert.match(
modal,
/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,
/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 \}\)/,
Expand Down
67 changes: 48 additions & 19 deletions src/components/new-card-modal.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -72,20 +73,32 @@ export function NewCardModal({
const [endDate, setEndDate] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(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 ?? "");
Expand Down Expand Up @@ -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
? [
Expand All @@ -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 {
Expand Down Expand Up @@ -215,7 +248,7 @@ export function NewCardModal({
<Button
variant="primary"
onClick={create}
disabled={!title.trim() || busy}
disabled={!title.trim() || busy || (projectId !== null && !projectPickerReady)}
>
{busy ? "Creating…" : "Create"}
</Button>
Expand Down Expand Up @@ -260,20 +293,16 @@ export function NewCardModal({

<Field label="Project">
<Select
value={projectId ?? ""}
value={projectPickerReady ? projectId ?? "" : ""}
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.
// With a familiar already selected, this list is server-scoped
// to its session-launch access, so the familiar remains valid.
// The linked session can still belong to another 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 }))),
]}
options={projectOptions}
disabled={!projectPickerReady}
/>
</Field>

Expand Down
12 changes: 6 additions & 6 deletions src/components/task-chat-cwd.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,18 +139,18 @@ assert.match(
);
assert.match(
boardInspector,
/<div className="board-drawer-field-label board-drawer-field-label--split">[\s\S]{0,120}<span>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",
/<div className="board-drawer-field-label board-drawer-field-label--split">[\s\S]{0,120}<span>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,
/<StandardSelect[\s\S]{0,120}label="Project"[\s\S]{0,260}value=\{card\.projectId \?\? ""\}[\s\S]{0,1000}\{ value: "", label: "No project" \}[\s\S]{0,160}\.\.\.projects\.map\(\(project\) => \(\{ value: project\.id, label: project\.name \}\)\)/,
"The task project picker should render the persisted project registry through the shared select",
/<StandardSelect[\s\S]{0,120}label="Project"[\s\S]{0,260}value=\{projectPickerReady \? card\.projectId \?\? "" : ""\}[\s\S]{0,1000}options=\{projectOptions\}[\s\S]{0,180}disabled=\{!projectPickerReady\}/,
"The task project picker should use the guarded scoped-project options through the shared select",
);
assert.doesNotMatch(
boardInspector,
Expand Down
7 changes: 6 additions & 1 deletion src/lib/board-search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, /<div className="board-drawer-field-label board-drawer-field-label--split">[\s\S]{0,120}<span>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");
Loading
Loading