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
177 changes: 177 additions & 0 deletions apps/web/src/components/delivery/OrchestratorComposerControls.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { EnvironmentId } from "@t3tools/contracts";
import { act } from "react";
import { create, type ReactTestRenderer } from "react-test-renderer";
import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
import { useDeliveryDraftStore, useOrchestratorDraftStore } from "../../state/delivery";
import { OrchestratorComposerControls } from "./OrchestratorComposerControls";

const mock = vi.hoisted(() => ({
reading: { body: null as unknown, error: null as string | null },
action: vi.fn(),
navigate: vi.fn(),
}));
vi.mock("@tanstack/react-router", () => ({ useNavigate: () => mock.navigate }));
vi.mock("../../state/delivery", async (original) => ({
...(await original<typeof import("../../state/delivery")>()),
useDeliveryRead: () => mock.reading,
useDeliveryAct: () => mock.action,
}));
vi.mock("./OrchestratorPanel", () => ({ useFlowPreview: () => null }));
vi.mock("../ui/select", () => ({
Select: "select-root",
SelectTrigger: "select-trigger",
SelectValue: "output",
SelectPopup: "select-popup",
SelectItem: "select-item",
}));
vi.mock("../ui/popover", () => ({
Popover: "popover-root",
PopoverTrigger: "popover-trigger",
PopoverPopup: "popover-popup",
PopoverTitle: "popover-title",
}));
vi.mock("./InfoPopover", () => ({ InfoPopover: "info-popover" }));
vi.mock("./SeatSettingsPanel", () => ({ SeatSettingsPanel: "seat-settings" }));
vi.mock("./SetupPanel", () => ({ SetupPanel: "setup-panel" }));
vi.mock("./TeamDefaultsDialog", () => ({ TeamDefaultsDialog: "team-defaults" }));
const store = () => useOrchestratorDraftStore.getState();
const teams = ["rnd", "alpha"].map((team) => ({ team, available: true, seats: [], settings: [] }));
let renderer: ReactTestRenderer | undefined;
const view = () => (
<OrchestratorComposerControls
environmentId={EnvironmentId.make("env-a")}
threadId="t1"
prompt="Build this"
onPromptCleared={() => {}}
/>
);
async function render() {
await act(() => {
if (renderer) renderer.update(view());
else renderer = create(view());
});
}
const alert = () =>
renderer!.root
.findAllByProps({ role: "alert" })
.map((node) => node.children.join(""))
.join(" ");
beforeEach(() => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
useDeliveryDraftStore.setState({ choices: {}, inherited: {}, remembered: {} });
useOrchestratorDraftStore.setState({
drafts: {},
activity: {},
saveRequests: {},
startRequests: {},
});
mock.reading = { body: null, error: null };
mock.navigate.mockReset();
mock.action.mockReset().mockImplementation(async (path: string, body: { team?: string }) => {
if (path === "/api/tasks" && !teams.some((team) => team.team === body.team))
return { ok: false, problems: ["unknown team"] };
return { ok: true, body: { id: "task-1", card: {} } };
});
});
afterEach(async () => {
await act(() => renderer?.unmount());
renderer = undefined;
vi.unstubAllGlobals();
});

it.each([null, "gone"])(
"starts with the first offered team (remembered %s)",
async (remembered) => {
if (remembered) useDeliveryDraftStore.getState().rememberTeam("env-a", remembered);
store().enter("t1", "env-a");
await render();
await act(() => store().requestStart("t1"));
expect(mock.action).not.toHaveBeenCalled();
expect(store().activity.t1?.blocked).toBe("Teams are still loading.");
mock.reading = { body: teams, error: null };
await render();
expect(mock.action).not.toHaveBeenCalled();
expect(
renderer!.root.findByProps({ "aria-label": "Team" }).findByType("output").children,
).toEqual(["rnd"]);
const navigated = new Promise<void>((resolve) =>
mock.navigate.mockImplementation(() => resolve()),
);
await act(async () => {
store().requestStart("t1");
});
await navigated;
expect(mock.action).toHaveBeenCalledWith(
"/api/tasks",
expect.objectContaining({ team: "rnd", draft: true }),
);
expect(mock.action).toHaveBeenCalledWith("/api/tasks/task-1/submit", expect.anything());
},
);

it.each([{ body: [] }, { body: [{ team: "triage", available: true }] }])(
"blocks empty selectable lists before any press",
async ({ body }) => {
store().enter("t1", "env-a");
await render();
mock.reading = { body, error: null };
await render();
expect(store().drafts.t1).not.toHaveProperty("awaitingTeams");
expect(alert()).toBe("No teams are available to choose from.");
expect(store().activity.t1?.blocked).toBe(alert());
await act(() => {
store().requestStart("t1");
store().requestSave("t1");
});
expect(mock.action).not.toHaveBeenCalled();
expect(renderer!.root.findAllByType("p").flatMap((node) => node.children)).not.toContain(
"Reading the team.",
);
},
);

it.each([null, "saved-task"])(
"preserves a missing choice and blocks create or edit (%s)",
async (engineThread) => {
store().enter("t1", "env-a");
store().chooseTeam("t1", "env-a", "missing");
if (engineThread) store().update("t1", { engineThread });
await render();
expect(alert()).toBe("");
mock.reading = { body: teams, error: null };
await render();
await act(() => {
store().requestStart("t1");
store().requestSave("t1");
});
expect(store().drafts.t1?.team).toBe("missing");
expect(alert()).toBe("Team missing is not offered here. Choose another team.");
expect(store().activity.t1?.blocked).toBe(alert());
expect(mock.action).not.toHaveBeenCalled();
await act(() => store().chooseTeam("t1", "env-a", "alpha"));
expect(alert()).toBe("");
},
);

it("keeps read errors distinct from empty lists", async () => {
store().enter("t1", "env-a");
mock.reading = { body: null, error: "offline" };
await render();
await act(() => store().requestStart("t1"));
expect(alert()).toBe("Delivery engine not reachable. offline");
expect(mock.action).not.toHaveBeenCalled();
});

it("does not skip an ineligible first offered team", async () => {
store().enter("t1", "env-a");
mock.reading = {
body: [{ ...teams[0], available: false, why: "Needs setup" }, teams[1]],
error: null,
};
await render();
expect(store().drafts.t1?.team).toBe("rnd");
expect(store().activity.t1?.blocked).toBeTruthy();
await act(() => store().requestStart("t1"));
expect(alert()).toBe(store().activity.t1?.blocked);
expect(mock.action).not.toHaveBeenCalled();
});
76 changes: 58 additions & 18 deletions apps/web/src/components/delivery/OrchestratorComposerControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ export function OrchestratorComposerControls(props: {
const teamsLoaded = teamListStatus(teamsRead) === "loaded";
const awaitingTeams = draft?.awaitingTeams === true;
// A new draft starts from the remembered team before the teams are read. Once
// they are, a team that is gone gives way to the default team, one time.
// they are, prefer development if offered, else the first offered team, one time.
useEffect(() => {
if (!awaitingTeams || !teamsLoaded) return;
reconcileTeam(
Expand All @@ -84,6 +84,14 @@ export function OrchestratorComposerControls(props: {
const [editingDefaults, setEditingDefaults] = useState(false);

const team = teams.find((candidate) => candidate.team === draft?.team) ?? null;
const teamAvailabilityReason =
teamsLoaded && !awaitingTeams
? teams.length === 0
? "No teams are available to choose from."
: !team
? `Team ${draft?.team} is not offered here. Choose another team.`
: null
: null;
const flow = flowFor(team, draft?.workflow ?? null);
const text = props.prompt.trim();
const seatsToSend = useMemo(
Expand All @@ -107,24 +115,37 @@ export function OrchestratorComposerControls(props: {
? `Delivery engine not reachable. ${teamsRead.error}`
: awaitingTeams
? "Teams are still loading."
: team && teamTaskBlock(team, flow)
? teamTaskBlock(team, flow)
: chosen && chosen.problems.length > 0
? chosen.problems.join(" ")
: text.length === 0
? "Write what the team is asked to do."
: null;
: teamAvailabilityReason
? teamAvailabilityReason
: team && teamTaskBlock(team, flow)
? teamTaskBlock(team, flow)
: chosen && chosen.problems.length > 0
? chosen.problems.join(" ")
: text.length === 0
? "Write what the team is asked to do."
: null;

useEffect(() => {
setActivity(props.threadId, {
busy,
blocked,
awaitingTeams,
teamAvailabilityReason,
team: draft?.team ?? null,
saved,
flow,
});
}, [awaitingTeams, blocked, busy, draft?.team, flow, props.threadId, saved, setActivity]);
}, [
awaitingTeams,
blocked,
busy,
draft?.team,
flow,
props.threadId,
saved,
setActivity,
teamAvailabilityReason,
]);

const save = useCallback(async (): Promise<string | null> => {
if (!draft) return null;
Expand Down Expand Up @@ -165,15 +186,16 @@ export function OrchestratorComposerControls(props: {
setProblems(["Teams are still loading."]);
return;
}
if (teamAvailabilityReason) return;
void once("save", async () => {
await save();
});
}, [awaitingTeams, once, save, text.length]);
}, [awaitingTeams, once, save, text.length, teamAvailabilityReason]);

const { onPromptCleared, threadId } = props;
const onStart = useCallback(() => {
if (blocked) {
setProblems([blocked]);
if (!teamAvailabilityReason) setProblems([blocked]);
return;
}
void once("start", async () => {
Expand All @@ -189,7 +211,18 @@ export function OrchestratorComposerControls(props: {
// What is written next is written on the task, in its own composer.
void navigate({ to: "/board", search: { task: id } });
});
}, [act, blocked, leave, navigate, once, onPromptCleared, person, save, threadId]);
}, [
act,
blocked,
leave,
navigate,
once,
onPromptCleared,
person,
save,
threadId,
teamAvailabilityReason,
]);

// The buttons stand in the composer's own place for Send, and ask from there.
const handledSave = useRef(saveRequest);
Expand All @@ -214,11 +247,12 @@ export function OrchestratorComposerControls(props: {
>
<Select
value={draft.team}
disabled={teamsLoaded && teams.length === 0}
onValueChange={(value) => chooseTeam(props.threadId, props.environmentId, String(value))}
>
<SelectTrigger aria-label="Team" size="compact" variant="ghost" className="w-auto min-w-0">
<UsersIcon className="size-3.5" />
<SelectValue>{draft.team}</SelectValue>
<SelectValue>{teamsLoaded && teams.length === 0 ? "No teams" : draft.team}</SelectValue>
</SelectTrigger>
<SelectPopup alignItemWithTrigger={false}>
{teams.map((item) => (
Expand Down Expand Up @@ -307,7 +341,7 @@ export function OrchestratorComposerControls(props: {
<p className="text-xs text-warning">
{teamsRead.error
? `Delivery engine not reachable. ${teamsRead.error}`
: "Reading the team."}
: (teamAvailabilityReason ?? "Reading the team.")}
</p>
)}
</PopoverPopup>
Expand Down Expand Up @@ -344,13 +378,13 @@ export function OrchestratorComposerControls(props: {
{whatStartDoes(flow, "Start")}
</InfoPopover>

{problems.length > 0 ? (
{teamAvailabilityReason || problems.length > 0 ? (
<span
className="ml-auto max-w-[32rem] text-xs text-warning"
data-delivery-orchestrator-status
role="alert"
>
{problems.join(" ")}
{teamAvailabilityReason ?? problems.join(" ")}
</span>
) : null}

Expand Down Expand Up @@ -448,7 +482,12 @@ export function OrchestratorPrimaryActions(props: {
variant="ghost"
className="rounded-full"
aria-label="Save draft"
disabled={busy || activity.awaitingTeams || !props.promptHasText}
disabled={
busy ||
activity.awaitingTeams ||
activity.teamAvailabilityReason !== null ||
!props.promptHasText
}
{...pointerFocusProps}
onClick={() => requestSave(props.threadId)}
data-delivery-save-draft
Expand All @@ -460,7 +499,8 @@ export function OrchestratorPrimaryActions(props: {
<TooltipPopup side="top">
{activity.awaitingTeams
? "Teams are still loading."
: "Save draft. It is kept on the engine and nothing is started."}
: (activity.teamAvailabilityReason ??
"Save draft. It is kept on the engine and nothing is started.")}
</TooltipPopup>
</Tooltip>
<Tooltip>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ function Probe(props: ProbeProps) {

let shown: ProbeProps;
async function render(props: Partial<ProbeProps> = {}) {
shown = { environmentId: ENV_A, threadId: "t1", driver: "claudeAgent", ...shown, ...props };
shown = { ...shown, ...props };
await act(() => {
if (renderer) renderer.update(<Probe {...shown} />);
else renderer = create(<Probe {...shown} />);
Expand Down
Loading