Skip to content
This repository was archived by the owner on Sep 17, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from 9 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
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