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
7 changes: 4 additions & 3 deletions HANDOFF.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,10 +120,11 @@
- Boardroom files: `src/pages/boardroom/`, `src/components/boardroom/`, `src/styles/advisory.css`

### Test Counts
- 249 test files passing (EDDI-Manager)
- 3613 Tests passing (`npm run test`)
- 271 test files passing (EDDI-Manager)
- 3937 Tests passing (`npm run test`)
- 76.59% statement coverage
- 112 Backend tenancy tests passing (`mvn test`)

### Last Commit Focus
- Frontend: `feat: add Protocol, HITL, Dynamic Agents, and Task config to workforce settings` on `test/debug-workforce-coverage` (`feeb5aef`)
- Frontend: `fix: address PR review feedback — bugs, coverage, code quality` on `feat/group-chat-followup-ux` (`44084657`)
- Includes: mode switcher, live logging optimisation, disabled bypass fix, keyboard a11y fix, usePersistedBoolean DRY, GROUP_CONVERSATIONS_KEY export, i18n updates
4 changes: 2 additions & 2 deletions e2e/resource-editor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ test.describe("Resource Editor — Rules", () => {
test("can switch to JSON tab", async ({ page }) => {
const jsonTab = page.getByTestId("tab-json");
await jsonTab.click();
// Radix Tabs sets data-state="active" on the selected trigger
await expect(jsonTab).toHaveAttribute("data-state", "active");
// Custom tab buttons use aria-selected (not Radix data-state)
await expect(jsonTab).toHaveAttribute("aria-selected", "true");
});

test("shows rules editor with behavior groups", async ({ page }) => {
Expand Down
23 changes: 4 additions & 19 deletions src/components/groups/__tests__/discussion-actions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,32 +12,30 @@ const MEMBERS = [
function renderActions(
availableActions: GroupConversationAction[],
overrides: Partial<{
onContinue: (q: string) => void;
onFollowup: (t: string, q: string) => void;
onCloseDiscussion: () => void;
isPending: boolean;
}> = {},
) {
const onContinue = overrides.onContinue ?? vi.fn();
const onFollowup = overrides.onFollowup ?? vi.fn();
const onCloseDiscussion = overrides.onCloseDiscussion ?? vi.fn();
renderWithProviders(
<DiscussionActions
availableActions={availableActions}
members={MEMBERS}
isPending={overrides.isPending}
onContinue={onContinue}
onFollowup={onFollowup}
onCloseDiscussion={onCloseDiscussion}
/>,
);
return { onContinue, onFollowup, onCloseDiscussion };
return { onFollowup, onCloseDiscussion };
}

describe("DiscussionActions", () => {
it("renders exactly the backend's availableActions (COMPLETED → all three)", () => {
it("renders followup and close for a COMPLETED conversation (continue is handled by input)", () => {
renderActions(["followup", "continue", "close"]);
expect(screen.getByTestId("action-continue")).toBeInTheDocument();
// "continue" is handled by the context-aware input, not this bar
expect(screen.queryByTestId("action-continue")).not.toBeInTheDocument();
expect(screen.getByTestId("action-followup")).toBeInTheDocument();
expect(screen.getByTestId("action-close")).toBeInTheDocument();
});
Expand All @@ -55,19 +53,6 @@ describe("DiscussionActions", () => {
expect(screen.queryByTestId("action-close")).not.toBeInTheDocument();
});

it("continue is a separate composer that submits the typed question", async () => {
const user = userEvent.setup();
const onContinue = vi.fn();
renderActions(["followup", "continue", "close"], { onContinue });

await user.click(screen.getByTestId("action-continue"));
const input = await screen.findByTestId("group-continue-input");
await user.type(input, "Re-evaluate with the new data");
await user.click(screen.getByTestId("group-continue-submit"));

expect(onContinue).toHaveBeenCalledWith("Re-evaluate with the new data");
});

it("follow-up submits the selected member and question", async () => {
const user = userEvent.setup();
const onFollowup = vi.fn();
Expand Down
62 changes: 62 additions & 0 deletions src/components/groups/__tests__/discussion-input.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,4 +114,66 @@ describe("DiscussionInput", () => {
await user.type(screen.getByTestId("discussion-input"), " ");
expect(screen.getByTestId("start-discussion-btn")).toBeDisabled();
});

describe("mode='continue'", () => {
it("shows continue placeholder instead of new-discussion placeholder", () => {
renderWithProviders(<DiscussionInput onSubmit={vi.fn()} mode="continue" />);
const textarea = screen.getByTestId("discussion-input");
expect(textarea).toHaveAttribute(
"placeholder",
expect.stringContaining("follow-up"),
);
});

it("shows Continue label on submit button", () => {
renderWithProviders(<DiscussionInput onSubmit={vi.fn()} mode="continue" />);
expect(screen.getByTestId("start-discussion-btn")).toHaveTextContent("Continue");
});

it("shows RotateCw icon instead of Send", () => {
renderWithProviders(<DiscussionInput onSubmit={vi.fn()} mode="continue" />);
const btn = screen.getByTestId("start-discussion-btn");
expect(btn.querySelector("svg.lucide-rotate-cw")).not.toBeNull();
expect(btn.querySelector("svg.lucide-send")).toBeNull();
});
});

describe("disabled with message", () => {
it("shows disabledMessage as placeholder when disabled", () => {
renderWithProviders(
<DiscussionInput
onSubmit={vi.fn()}
disabled
disabledMessage="This discussion is closed"
/>,
);
const textarea = screen.getByTestId("discussion-input");
expect(textarea).toHaveAttribute("placeholder", "This discussion is closed");
});

it("button is disabled when disabled prop is true", () => {
renderWithProviders(
<DiscussionInput onSubmit={vi.fn()} disabled disabledMessage="Closed" />,
);
expect(screen.getByTestId("start-discussion-btn")).toBeDisabled();
});

it("expand button is disabled when component is disabled", () => {
renderWithProviders(
<DiscussionInput onSubmit={vi.fn()} disabled disabledMessage="Closed" />,
);
const expandBtn = screen.getByRole("button", { name: /expand/i });
expect(expandBtn).toBeDisabled();
});

it("blocks submission when disabled even if text is present", () => {
const onSubmit = vi.fn();
renderWithProviders(
<DiscussionInput onSubmit={onSubmit} disabled disabledMessage="Closed" />,
);
expect(screen.getByTestId("discussion-input")).toBeDisabled();
expect(screen.getByTestId("start-discussion-btn")).toBeDisabled();
expect(onSubmit).not.toHaveBeenCalled();
});
});
});
105 changes: 8 additions & 97 deletions src/components/groups/discussion-actions.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
RotateCw,
MessageCircleReply,
Loader2,
Send,
Expand All @@ -14,43 +13,38 @@ import type { GroupConversationAction, GroupMember } from "@/lib/api/groups";

interface DiscussionActionsProps {
/** Backend-computed available operations. The bar renders EXACTLY these — the
* set is never hardcoded or derived from state on the client. */
* set is never hardcoded or derived from state on the client.
* Note: "continue" is handled by the context-aware input, not this bar. */
availableActions: GroupConversationAction[];
/** Group members offered in the follow-up picker (agents only). */
members: Pick<GroupMember, "agentId" | "displayName" | "memberType">[];
/** True while a continue/followup/close request is in flight. */
/** True while a followup/close request is in flight. */
isPending?: boolean;
/** Current round (1-based) — shown as context for "continue". */
round?: number;
onContinue: (question: string) => void;
onFollowup: (targetAgentId: string, question: string) => void;
onCloseDiscussion: () => void;
}

type ComposerMode = "none" | "continue" | "followup";
type ComposerMode = "none" | "followup";

/**
* Post-COMPLETED lifecycle action bar for a group discussion.
*
* Rendered only when the backend reports a non-empty `availableActions`
* (COMPLETED → followup/continue/close; FAILED/CANCELLED → close; CLOSED → none,
* so the bar disappears entirely). "Continue" starts a NEW round of the SAME
* discussion — deliberately distinct from the DiscussionInput below, which
* starts a brand-new discussion.
* so the bar disappears entirely). "Continue" is handled by the context-aware
* input field below — this bar provides only "Follow up with a member" and
* "Close discussion".
*/
export function DiscussionActions({
availableActions,
members,
isPending = false,
round,
onContinue,
onFollowup,
onCloseDiscussion,
}: DiscussionActionsProps) {
const { t } = useTranslation();
const [mode, setMode] = useState<ComposerMode>("none");
const [closeOpen, setCloseOpen] = useState(false);
const [continueQuestion, setContinueQuestion] = useState("");
const [followupQuestion, setFollowupQuestion] = useState("");

// Only real agents can receive a direct follow-up (a nested GROUP member is not
Expand All @@ -63,25 +57,16 @@ export function DiscussionActions({
() => eligibleMembers[0]?.agentId ?? "",
);

const canContinue = availableActions.includes("continue");
const canFollowup =
availableActions.includes("followup") && eligibleMembers.length > 0;
const canClose = availableActions.includes("close");

if (!canContinue && !canFollowup && !canClose) return null;
if (!canFollowup && !canClose) return null;

function toggle(next: ComposerMode) {
setMode((prev) => (prev === next ? "none" : next));
}

function submitContinue() {
const q = continueQuestion.trim();
if (!q || isPending) return;
onContinue(q);
setContinueQuestion("");
setMode("none");
}

function submitFollowup() {
const q = followupQuestion.trim();
const target = followupTarget || eligibleMembers[0]?.agentId || "";
Expand All @@ -97,22 +82,6 @@ export function DiscussionActions({
data-testid="discussion-actions"
>
<div className="flex flex-wrap items-center gap-2">
<span className="me-1 text-xs font-medium text-muted-foreground">
{t("groups.discussionComplete", "Discussion complete")}
</span>
{canContinue && (
<Button
type="button"
variant={mode === "continue" ? "secondary" : "outline"}
size="sm"
onClick={() => toggle("continue")}
disabled={isPending}
data-testid="action-continue"
>
<RotateCw className="h-3.5 w-3.5" />
{t("groups.continueDiscussion", "Continue (new round)")}
</Button>
)}
{canFollowup && (
<Button
type="button"
Expand Down Expand Up @@ -142,64 +111,6 @@ export function DiscussionActions({
)}
</div>

{/* Continue composer — a NEW round of THIS discussion (memory retained). */}
{mode === "continue" && canContinue && (
<div className="mt-2.5 space-y-1.5" data-testid="continue-composer">
<p className="text-[11px] text-muted-foreground">
{t(
"groups.continueHint",
"Re-runs all phases as a new round; every agent keeps memory of prior rounds. Attachments can't be added to a continuation.",
)}
{typeof round === "number" && round >= 1
? ` ${t("groups.currentRound", "Currently on round")} ${round}.`
: ""}
</p>
<textarea
value={continueQuestion}
onChange={(e) => setContinueQuestion(e.target.value)}
placeholder={t(
"groups.continuePlaceholder",
"What should the group discuss in the next round?",
)}
className="w-full resize-y rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
rows={2}
disabled={isPending}
data-testid="group-continue-input"
onKeyDown={(e) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
submitContinue();
}
}}
/>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setMode("none")}
>
<X className="h-3.5 w-3.5" />
{t("common.cancel", "Cancel")}
</Button>
<Button
type="button"
size="sm"
onClick={submitContinue}
disabled={!continueQuestion.trim() || isPending}
data-testid="group-continue-submit"
>
{isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RotateCw className="h-3.5 w-3.5" />
)}
{t("groups.startNextRound", "Start next round")}
</Button>
</div>
</div>
)}

{/* Follow-up composer — one direct question to a single member agent. */}
{mode === "followup" && canFollowup && (
<div className="mt-2.5 space-y-1.5" data-testid="followup-composer">
Expand Down
Loading
Loading