diff --git a/src/components/groups/__tests__/discussion-transcript.test.tsx b/src/components/groups/__tests__/discussion-transcript.test.tsx index 5eb5b4e3..aed7575b 100644 --- a/src/components/groups/__tests__/discussion-transcript.test.tsx +++ b/src/components/groups/__tests__/discussion-transcript.test.tsx @@ -51,6 +51,10 @@ const mockConversation: GroupConversation = { currentPhaseName: "Synthesis", synthesizedAnswer: "### Hybrid Systems\nWe need both deterministic guardrails and LLMs.", depth: 0, + taskList: null, + dynamicMembers: [], + createdAgentIds: [], + retainedAgentIds: [], created: "2026-06-09T12:00:00.000Z", lastModified: "2026-06-09T12:05:00.000Z", }; @@ -175,6 +179,10 @@ describe("DiscussionTranscript", () => { activeSpeakers: new Set(["agent-2"]), synthesizedAnswer: null, error: null, + taskPlan: null, + taskVerifications: new Map(), + tasksInProgress: new Set(), + tasksCompleted: new Set(), }; renderWithProviders( @@ -205,6 +213,10 @@ describe("DiscussionTranscript", () => { activeSpeakers: new Set(), synthesizedAnswer: null, error: "SSE Connection Aborted", + taskPlan: null, + taskVerifications: new Map(), + tasksInProgress: new Set(), + tasksCompleted: new Set(), }; renderWithProviders( diff --git a/src/components/groups/__tests__/group-card.test.tsx b/src/components/groups/__tests__/group-card.test.tsx index 5f44d738..f5ee249c 100644 --- a/src/components/groups/__tests__/group-card.test.tsx +++ b/src/components/groups/__tests__/group-card.test.tsx @@ -131,4 +131,130 @@ describe("GroupCard", () => { await user.click(screen.getByText("Duplicate")); expect(screen.queryByText("Duplicate")).not.toBeInTheDocument(); }); + + // ─── Style badge variants ────────────────────────────────────────── + + it("renders style badge with icon and label for ROUND_TABLE", () => { + renderWithProviders( + + ); + expect(screen.getByText("Round Table")).toBeInTheDocument(); + expect(screen.getByText("🗣️")).toBeInTheDocument(); + }); + + it("renders style badge for TASK_FORCE", () => { + renderWithProviders( + + ); + expect(screen.getByText("Task Force")).toBeInTheDocument(); + expect(screen.getByText("🎯")).toBeInTheDocument(); + }); + + // ─── Member preview chips ───────────────────────────────────────── + + it("renders member preview chips (up to 4)", () => { + const members = [ + { displayName: "Agent Alpha", memberType: "AGENT" }, + { displayName: "Agent Beta", memberType: "AGENT" }, + { displayName: "Sub Group", memberType: "GROUP" }, + ]; + renderWithProviders( + + ); + expect(screen.getByText("Agent Alpha")).toBeInTheDocument(); + expect(screen.getByText("Agent Beta")).toBeInTheDocument(); + expect(screen.getByText("Sub Group")).toBeInTheDocument(); + }); + + it("shows Bot icon for AGENT type members", () => { + const members = [{ displayName: "Agent Alpha", memberType: "AGENT" }]; + renderWithProviders( + + ); + // Bot icon from lucide has aria-hidden="true" on SVG inside the chip + const chip = screen.getByRole("listitem", { name: "Agent Alpha" }); + const svg = chip.querySelector("svg"); + expect(svg).toBeInTheDocument(); + expect(svg).toHaveAttribute("aria-hidden", "true"); + }); + + it("shows Users icon for GROUP type members", () => { + const members = [{ displayName: "Sub Group", memberType: "GROUP" }]; + renderWithProviders( + + ); + const chip = screen.getByRole("listitem", { name: "Sub Group" }); + const svg = chip.querySelector("svg"); + expect(svg).toBeInTheDocument(); + expect(svg).toHaveAttribute("aria-hidden", "true"); + }); + + it("shows overflow count when more than 4 members", () => { + const members = [ + { displayName: "A1", memberType: "AGENT" }, + { displayName: "A2", memberType: "AGENT" }, + { displayName: "A3", memberType: "AGENT" }, + { displayName: "A4", memberType: "AGENT" }, + { displayName: "A5", memberType: "AGENT" }, + { displayName: "A6", memberType: "AGENT" }, + ]; + renderWithProviders( + + ); + // Only first 4 chips rendered; overflow text shows "+2 more" + expect(screen.queryByText("A5")).not.toBeInTheDocument(); + expect(screen.queryByText("A6")).not.toBeInTheDocument(); + expect(screen.getByText(/\+2 more/)).toBeInTheDocument(); + }); + + it("does not render member chips when members is empty", () => { + renderWithProviders( + + ); + expect(screen.queryByRole("list")).not.toBeInTheDocument(); + }); + + it("member chips have role='listitem' and aria-label", () => { + const members = [ + { displayName: "Agent Alpha", memberType: "AGENT" }, + { displayName: "Agent Beta", memberType: "AGENT" }, + ]; + renderWithProviders( + + ); + const items = screen.getAllByRole("listitem"); + expect(items.length).toBe(2); + expect(items[0]).toHaveAttribute("aria-label", "Agent Alpha"); + expect(items[1]).toHaveAttribute("aria-label", "Agent Beta"); + }); + + it("member container has role='list' and data-testid", () => { + const members = [{ displayName: "Agent Alpha", memberType: "AGENT" }]; + renderWithProviders( + + ); + const list = screen.getByRole("list"); + expect(list).toHaveAttribute("data-testid", "group-card-members-grp-123"); + }); + + // ─── Name as link ───────────────────────────────────────────────── + + it("renders group name as a navigable link", () => { + renderWithProviders(); + const link = screen.getByText("Product Review Panel").closest("a"); + expect(link).toBeInTheDocument(); + expect(link).toHaveAttribute("href", "/manage/groups/grp-123?version=2"); + }); + + // ─── Relative time ──────────────────────────────────────────────── + + it("renders relative time", () => { + // baseGroup.lastModifiedOn is 1 hour ago + renderWithProviders(); + // formatRelativeTime returns something like "1h ago" — just verify something renders + const card = screen.getByTestId("group-card-grp-123"); + // The footer area should contain time text (not empty) + const timeSpan = card.querySelector("[title]"); + expect(timeSpan).toBeInTheDocument(); + }); }); diff --git a/src/components/groups/__tests__/task-board.test.tsx b/src/components/groups/__tests__/task-board.test.tsx new file mode 100644 index 00000000..dcb1dcbe --- /dev/null +++ b/src/components/groups/__tests__/task-board.test.tsx @@ -0,0 +1,601 @@ +import { describe, it, expect } from "vitest"; +import { screen, within } from "@testing-library/react"; +import { renderWithProviders } from "@/test/test-utils"; +import { TaskBoard } from "../task-board"; + +/* ------------------------------------------------------------------ */ +/* Helpers */ +/* ------------------------------------------------------------------ */ + +function makeTask( + id: string, + subject: string, + assignedTo: string, + priority: number, +) { + return { id, subject, assignedTo, priority }; +} + +/** Minimal default props – override per test */ +function defaultProps() { + return { + taskPlan: null as ReturnType[] | null, + tasksInProgress: new Set(), + tasksCompleted: new Set(), + taskVerifications: new Map(), + isStreaming: false, + }; +} + +/* ------------------------------------------------------------------ */ +/* Tests */ +/* ------------------------------------------------------------------ */ + +describe("TaskBoard", () => { + // ------------------------------------------------------------------ + // 1. Empty state + // ------------------------------------------------------------------ + it("renders empty state when taskPlan is null", () => { + renderWithProviders(); + + expect(screen.getByTestId("task-board-empty")).toBeInTheDocument(); + expect( + screen.getByText( + "Task plan will appear here when the moderator creates it", + ), + ).toBeInTheDocument(); + // The main board should NOT be rendered + expect(screen.queryByTestId("task-board")).not.toBeInTheDocument(); + }); + + // ------------------------------------------------------------------ + // 2. Progress bar with correct counts + // ------------------------------------------------------------------ + it("renders progress bar with correct done/total counts", () => { + const tasks = [ + makeTask("t1", "Task 1", "Agent A", 0), + makeTask("t2", "Task 2", "Agent B", 1), + makeTask("t3", "Task 3", "Agent C", 2), + makeTask("t4", "Task 4", "Agent D", 3), + ]; + + renderWithProviders( + , + ); + + // done = completed(1) + verified(1) = 2, total = 4 → 50% + expect(screen.getByText("2/4 (50%)")).toBeInTheDocument(); + }); + + // ------------------------------------------------------------------ + // 3. Task cards in correct columns + // ------------------------------------------------------------------ + it("distributes tasks into correct columns based on status", () => { + const tasks = [ + makeTask("pending-1", "Pending task", "Alice", 2), + makeTask("active-1", "Active task", "Bob", 1), + makeTask("done-1", "Done task", "Carol", 0), + makeTask("verified-1", "Verified task", "Dave", 3), + ]; + + renderWithProviders( + , + ); + + // Pending column has pending-1 + const pendingCol = screen.getByTestId("task-column-pending"); + expect( + within(pendingCol).getByTestId("task-card-pending-1"), + ).toBeInTheDocument(); + + // Active column has active-1 + const activeCol = screen.getByTestId("task-column-in-progress"); + expect( + within(activeCol).getByTestId("task-card-active-1"), + ).toBeInTheDocument(); + + // Done column has done-1 + const doneCol = screen.getByTestId("task-column-completed"); + expect( + within(doneCol).getByTestId("task-card-done-1"), + ).toBeInTheDocument(); + + // Verified column has verified-1 + const verifiedCol = screen.getByTestId("task-column-verified"); + expect( + within(verifiedCol).getByTestId("task-card-verified-1"), + ).toBeInTheDocument(); + }); + + // ------------------------------------------------------------------ + // 4. Streaming indicator visible when isStreaming=true + // ------------------------------------------------------------------ + it("shows streaming indicator when isStreaming is true", () => { + renderWithProviders( + , + ); + + const progressBar = screen.getByTestId("task-board-progress"); + // Loader2 renders as SVG with animate-spin class + const spinner = progressBar.querySelector(".animate-spin"); + expect(spinner).toBeInTheDocument(); + }); + + // ------------------------------------------------------------------ + // 5. Streaming indicator hidden when isStreaming=false + // ------------------------------------------------------------------ + it("hides streaming indicator when isStreaming is false", () => { + renderWithProviders( + , + ); + + const progressBar = screen.getByTestId("task-board-progress"); + const spinner = progressBar.querySelector(".animate-spin"); + expect(spinner).not.toBeInTheDocument(); + }); + + // ------------------------------------------------------------------ + // 6. Verified-passed card with green feedback + // ------------------------------------------------------------------ + it("renders verified-passed card with feedback text", () => { + const tasks = [makeTask("v1", "Verify me", "Agent V", 1)]; + const verifications = new Map([ + ["v1", { passed: true, feedback: "All checks passed" }], + ]); + + renderWithProviders( + , + ); + + // Scope within the desktop verified column to avoid mobile duplicate + const verifiedCol = screen.getByTestId("task-column-verified"); + const card = within(verifiedCol).getByTestId("task-card-v1"); + expect(within(card).getByText("All checks passed")).toBeInTheDocument(); + // The feedback container should have emerald (green) styling + const feedbackEl = within(card).getByText("All checks passed").parentElement!; + expect(feedbackEl.className).toMatch(/emerald/); + }); + + // ------------------------------------------------------------------ + // 7. Verified-failed card with red feedback + // ------------------------------------------------------------------ + it("renders verified-failed card with feedback text", () => { + const tasks = [makeTask("f1", "Failing task", "Agent F", 0)]; + const verifications = new Map([ + ["f1", { passed: false, feedback: "Quality check failed" }], + ]); + + renderWithProviders( + , + ); + + // Scope within the desktop verified column to avoid mobile duplicate + const verifiedCol = screen.getByTestId("task-column-verified"); + const card = within(verifiedCol).getByTestId("task-card-f1"); + expect( + within(card).getByText("Quality check failed"), + ).toBeInTheDocument(); + // The feedback container should have destructive (red) styling + const feedbackEl = + within(card).getByText("Quality check failed").parentElement!; + expect(feedbackEl.className).toMatch(/destructive/); + }); + + // ------------------------------------------------------------------ + // 8. Priority badges (P0, P1, P2, P3) + // ------------------------------------------------------------------ + it("shows correct priority badges for P0, P1, P2, P3", () => { + const tasks = [ + makeTask("p0", "Critical", "A", 0), + makeTask("p1", "High", "B", 1), + makeTask("p2", "Medium", "C", 2), + makeTask("p3", "Low", "D", 3), + ]; + + renderWithProviders( + , + ); + + // All tasks are pending — scope within desktop pending column + const pendingCol = screen.getByTestId("task-column-pending"); + + const p0Card = within(pendingCol).getByTestId("task-card-p0"); + expect(within(p0Card).getByText("P0")).toBeInTheDocument(); + + const p1Card = within(pendingCol).getByTestId("task-card-p1"); + expect(within(p1Card).getByText("P1")).toBeInTheDocument(); + + const p2Card = within(pendingCol).getByTestId("task-card-p2"); + expect(within(p2Card).getByText("P2")).toBeInTheDocument(); + + const p3Card = within(pendingCol).getByTestId("task-card-p3"); + expect(within(p3Card).getByText("P3")).toBeInTheDocument(); + }); + + // ------------------------------------------------------------------ + // 9. Unknown priority falls back to P3 + // ------------------------------------------------------------------ + it("handles unknown priority gracefully by falling back to P3", () => { + const tasks = [makeTask("px", "Unknown priority", "Agent X", 5)]; + + renderWithProviders( + , + ); + + // Scope within desktop pending column to avoid mobile duplicate + const pendingCol = screen.getByTestId("task-column-pending"); + const card = within(pendingCol).getByTestId("task-card-px"); + // Fallback: PRIORITY_CONFIG[5] is undefined → falls back to PRIORITY_CONFIG[3] which has label "P3" + expect(within(card).getByText("P3")).toBeInTheDocument(); + }); + + // ------------------------------------------------------------------ + // 10. Agent avatar with initials and name + // ------------------------------------------------------------------ + it("shows agent avatar with initials and agent name", () => { + const tasks = [makeTask("a1", "Agent task", "Research Agent", 2)]; + + renderWithProviders( + , + ); + + // Scope within desktop pending column to avoid mobile duplicate + const pendingCol = screen.getByTestId("task-column-pending"); + const card = within(pendingCol).getByTestId("task-card-a1"); + // Agent name text should appear + expect(within(card).getByText("Research Agent")).toBeInTheDocument(); + // Avatar should show initials (getInitials("Research Agent") → "RA") + expect(within(card).getByText("RA")).toBeInTheDocument(); + }); + + // ------------------------------------------------------------------ + // 11. Empty columns show placeholder text + // ------------------------------------------------------------------ + it("shows dash placeholder in empty columns", () => { + // Single pending task → Active, Done, Verified are empty + const tasks = [makeTask("only", "Only task", "Solo", 1)]; + + renderWithProviders( + , + ); + + // The "in-progress", "completed", "verified" columns should have "—" placeholder + const activeCol = screen.getByTestId("task-column-in-progress"); + expect(within(activeCol).getByText("—")).toBeInTheDocument(); + + const doneCol = screen.getByTestId("task-column-completed"); + expect(within(doneCol).getByText("—")).toBeInTheDocument(); + + const verifiedCol = screen.getByTestId("task-column-verified"); + expect(within(verifiedCol).getByText("—")).toBeInTheDocument(); + }); + + // ------------------------------------------------------------------ + // 12. Section heading with Task Board title + // ------------------------------------------------------------------ + it("shows section heading with Task Board title", () => { + renderWithProviders( + , + ); + + expect(screen.getByText("Task Board")).toBeInTheDocument(); + }); + + // ------------------------------------------------------------------ + // 13. Progress bar percentage + // ------------------------------------------------------------------ + it("renders progress bar with correct percentage via aria-valuenow", () => { + const tasks = [ + makeTask("t1", "T1", "A", 0), + makeTask("t2", "T2", "B", 0), + makeTask("t3", "T3", "C", 0), + makeTask("t4", "T4", "D", 0), + ]; + + renderWithProviders( + , + ); + + // 3 done out of 4 → 75% + const progressBar = screen.getByRole("progressbar"); + expect(progressBar).toHaveAttribute("aria-valuenow", "75"); + expect(screen.getByText("3/4 (75%)")).toBeInTheDocument(); + }); + + // ------------------------------------------------------------------ + // 14. All 4 columns are always rendered (desktop) + // ------------------------------------------------------------------ + it("renders all four columns in the desktop layout", () => { + const tasks = [makeTask("t1", "One task", "Agent", 0)]; + + renderWithProviders( + , + ); + + expect(screen.getByTestId("task-column-pending")).toBeInTheDocument(); + expect( + screen.getByTestId("task-column-in-progress"), + ).toBeInTheDocument(); + expect(screen.getByTestId("task-column-completed")).toBeInTheDocument(); + expect(screen.getByTestId("task-column-verified")).toBeInTheDocument(); + }); + + // ------------------------------------------------------------------ + // 15. Multiple tasks distributed across columns + // ------------------------------------------------------------------ + it("distributes multiple tasks across all four columns correctly", () => { + const tasks = [ + makeTask("p1", "Pending 1", "A", 0), + makeTask("p2", "Pending 2", "B", 1), + makeTask("a1", "Active 1", "C", 2), + makeTask("a2", "Active 2", "D", 0), + makeTask("d1", "Done 1", "E", 1), + makeTask("v1", "Verified 1", "F", 0), + makeTask("v2", "Verified 2", "G", 3), + ]; + + renderWithProviders( + , + ); + + // Pending: p1, p2 + const pendingCol = screen.getByTestId("task-column-pending"); + expect(within(pendingCol).getByTestId("task-card-p1")).toBeInTheDocument(); + expect(within(pendingCol).getByTestId("task-card-p2")).toBeInTheDocument(); + + // Active: a1, a2 + const activeCol = screen.getByTestId("task-column-in-progress"); + expect(within(activeCol).getByTestId("task-card-a1")).toBeInTheDocument(); + expect(within(activeCol).getByTestId("task-card-a2")).toBeInTheDocument(); + + // Done: d1 + const doneCol = screen.getByTestId("task-column-completed"); + expect(within(doneCol).getByTestId("task-card-d1")).toBeInTheDocument(); + + // Verified: v1, v2 + const verifiedCol = screen.getByTestId("task-column-verified"); + expect( + within(verifiedCol).getByTestId("task-card-v1"), + ).toBeInTheDocument(); + expect( + within(verifiedCol).getByTestId("task-card-v2"), + ).toBeInTheDocument(); + }); + + // ------------------------------------------------------------------ + // 16. Column count badges show correct numbers + // ------------------------------------------------------------------ + it("shows correct task count badges per column header", () => { + const tasks = [ + makeTask("p1", "Pending 1", "A", 0), + makeTask("p2", "Pending 2", "B", 1), + makeTask("a1", "Active 1", "C", 2), + makeTask("d1", "Done 1", "D", 0), + makeTask("d2", "Done 2", "E", 1), + makeTask("d3", "Done 3", "F", 2), + ]; + + renderWithProviders( + , + ); + + // Pending column header badge should show "2" + const pendingCol = screen.getByTestId("task-column-pending"); + expect(within(pendingCol).getByText("2")).toBeInTheDocument(); + + // Active column header badge should show "1" + const activeCol = screen.getByTestId("task-column-in-progress"); + expect(within(activeCol).getByText("1")).toBeInTheDocument(); + + // Done column header badge should show "3" + const doneCol = screen.getByTestId("task-column-completed"); + expect(within(doneCol).getByText("3")).toBeInTheDocument(); + + // Verified column header badge should show "0" + const verifiedCol = screen.getByTestId("task-column-verified"); + expect(within(verifiedCol).getByText("0")).toBeInTheDocument(); + }); + + // ------------------------------------------------------------------ + // 17. Verification priority: verified > completed > in-progress + // ------------------------------------------------------------------ + it("prioritises verified status over completed and in-progress", () => { + // Task is in all three sets — verified should win + const tasks = [makeTask("multi", "Multi-state", "Agent M", 0)]; + + renderWithProviders( + , + ); + + // Should appear in verified column, not completed or in-progress + const verifiedCol = screen.getByTestId("task-column-verified"); + expect( + within(verifiedCol).getByTestId("task-card-multi"), + ).toBeInTheDocument(); + + const activeCol = screen.getByTestId("task-column-in-progress"); + expect( + within(activeCol).queryByTestId("task-card-multi"), + ).not.toBeInTheDocument(); + + const doneCol = screen.getByTestId("task-column-completed"); + expect( + within(doneCol).queryByTestId("task-card-multi"), + ).not.toBeInTheDocument(); + }); + + // ------------------------------------------------------------------ + // 18. Completed status takes priority over in-progress + // ------------------------------------------------------------------ + it("prioritises completed status over in-progress", () => { + const tasks = [makeTask("dual", "Dual state", "Agent D", 1)]; + + renderWithProviders( + , + ); + + const doneCol = screen.getByTestId("task-column-completed"); + expect( + within(doneCol).getByTestId("task-card-dual"), + ).toBeInTheDocument(); + + const activeCol = screen.getByTestId("task-column-in-progress"); + expect( + within(activeCol).queryByTestId("task-card-dual"), + ).not.toBeInTheDocument(); + }); + + // ------------------------------------------------------------------ + // 19. Progress bar at 0% with no completed tasks + // ------------------------------------------------------------------ + it("shows 0% progress when no tasks are completed", () => { + const tasks = [ + makeTask("t1", "T1", "A", 0), + makeTask("t2", "T2", "B", 1), + ]; + + renderWithProviders( + , + ); + + expect(screen.getByText("0/2 (0%)")).toBeInTheDocument(); + const progressBar = screen.getByRole("progressbar"); + expect(progressBar).toHaveAttribute("aria-valuenow", "0"); + }); + + // ------------------------------------------------------------------ + // 20. Progress bar at 100% when all tasks are verified + // ------------------------------------------------------------------ + it("shows 100% progress when all tasks are done or verified", () => { + const tasks = [ + makeTask("t1", "T1", "A", 0), + makeTask("t2", "T2", "B", 1), + ]; + + renderWithProviders( + , + ); + + expect(screen.getByText("2/2 (100%)")).toBeInTheDocument(); + const progressBar = screen.getByRole("progressbar"); + expect(progressBar).toHaveAttribute("aria-valuenow", "100"); + }); + + // ------------------------------------------------------------------ + // 21. Task card shows subject text + // ------------------------------------------------------------------ + it("renders the task subject text on the card", () => { + const tasks = [ + makeTask("s1", "Implement search feature", "Engineer Bot", 0), + ]; + + renderWithProviders( + , + ); + + // Scope within desktop pending column to avoid mobile duplicate + const pendingCol = screen.getByTestId("task-column-pending"); + const card = within(pendingCol).getByTestId("task-card-s1"); + expect( + within(card).getByText("Implement search feature"), + ).toBeInTheDocument(); + }); + + // ------------------------------------------------------------------ + // 22. Verified card with empty feedback falls back to "Verified" + // ------------------------------------------------------------------ + it("shows fallback text when verified feedback is empty", () => { + const tasks = [makeTask("ef1", "Empty feedback", "Agent E", 2)]; + const verifications = new Map([ + ["ef1", { passed: true, feedback: "" }], + ]); + + renderWithProviders( + , + ); + + // Scope within desktop verified column to avoid mobile duplicate + const verifiedCol = screen.getByTestId("task-column-verified"); + const card = within(verifiedCol).getByTestId("task-card-ef1"); + // Empty string is falsy → falls back to t("taskBoard.verified", "Verified") + expect(within(card).getByText("Verified")).toBeInTheDocument(); + }); +}); diff --git a/src/components/groups/agent-response-card.tsx b/src/components/groups/agent-response-card.tsx index 5fde1027..51eeb306 100644 --- a/src/components/groups/agent-response-card.tsx +++ b/src/components/groups/agent-response-card.tsx @@ -4,7 +4,7 @@ import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import rehypeRaw from "rehype-raw"; import DOMPurify from "dompurify"; -import { ChevronDown, ChevronUp } from "lucide-react"; +import { ChevronDown, ChevronUp, ClipboardList, CheckCircle2 } from "lucide-react"; import { cn, hashColor, getInitials } from "@/lib/utils"; import { Badge } from "@/components/ui/badge"; import type { TranscriptEntry, TranscriptEntryType, DiscussionStyle } from "@/lib/api/groups"; @@ -21,6 +21,11 @@ const STYLE_BADGE_OVERRIDES: Partial - {getInitials(entry.speakerDisplayName)} + {isPlan ? ( + + ) : isVerification ? ( + + ) : ( + getInitials(entry.speakerDisplayName) + )} {/* Content */} @@ -125,7 +148,7 @@ export function AgentResponseCard({ entry, isSpeaking, allowHtml, discussionStyl {entry.speakerDisplayName} - {info.label} + {t(`groups.entryType.${entry.type}`, info.label)} {entry.targetAgentId && ( diff --git a/src/components/groups/discussion-transcript.tsx b/src/components/groups/discussion-transcript.tsx index 1c0aee0b..7bc582c1 100644 --- a/src/components/groups/discussion-transcript.tsx +++ b/src/components/groups/discussion-transcript.tsx @@ -6,8 +6,9 @@ import remarkGfm from "remark-gfm"; import rehypeRaw from "rehype-raw"; import { PhaseHeader } from "./phase-header"; import { AgentResponseCard } from "./agent-response-card"; +import { TaskBoard } from "./task-board"; import { parseTranscriptContent, safeFormatDate } from "./group-utils"; -import type { GroupConversation, TranscriptEntry, PhaseType, TranscriptEntryType, DiscussionStyle } from "@/lib/api/groups"; +import type { GroupConversation, TranscriptEntry, PhaseType, TranscriptEntryType, DiscussionStyle, SharedTaskList } from "@/lib/api/groups"; import type { GroupStreamState } from "@/hooks/use-group-discussion-stream"; import { cn } from "@/lib/utils"; import { Badge } from "@/components/ui/badge"; @@ -33,6 +34,7 @@ interface PhaseGroup { /** Style-aware accent colors for transcript theming */ const STYLE_THEME: Record = { ROUND_TABLE: { accent: "text-amber-500", + dotColor: "bg-amber-500", phaseAccent: "border-amber-500/30 bg-amber-500/5", questionBg: "bg-amber-500/5 border-b-amber-500/20", flowBg: "bg-amber-500/10", @@ -53,6 +56,7 @@ const STYLE_THEME: Record {isStreaming && ( - ● LIVE + {t("groups.liveIndicator", "● LIVE")} )} {/* Allow HTML toggle — opt-in for trusted content */} @@ -311,7 +335,7 @@ export function DiscussionTranscript({ title={t("groups.allowHtmlTooltip", "When enabled, renders HTML content (sanitized). Use only with trusted agents.")} > - HTML + {t("groups.htmlToggle", "HTML")} {safeFormatDate(effectiveCreated, "full")} @@ -344,7 +368,7 @@ export function DiscussionTranscript({ {isCompleted && !isActive && ( )} - {step} + {t(`groups.flow.${step.replace(/\s+/g, "")}`, step)} {idx < flowSteps.length - 1 && ( @@ -381,6 +405,32 @@ export function DiscussionTranscript({ ))} + {/* Task Board — shown for TASK_FORCE style during/after streaming (until API data loads) */} + {style === "TASK_FORCE" && streamState?.taskPlan && !conversation?.taskList && ( + + )} + {/* Show empty task board placeholder during TASK_FORCE streaming before plan arrives */} + {style === "TASK_FORCE" && isStreaming && streamState && !streamState.taskPlan && !conversation?.taskList && ( + + )} + + {/* Also show task board for completed TASK_FORCE conversations loaded from API */} + {style === "TASK_FORCE" && !isStreaming && conversation?.taskList && ( + + )} + {/* Synthesized answer highlight */} {parsedSynthesis && (
- - - + + +
{effectiveState === "SYNTHESIZING" @@ -488,13 +538,53 @@ export function DiscussionTranscript({ ); } +/** Memoized wrapper for API-loaded task boards to avoid re-creating Set/Map on every render */ +function MemoizedApiTaskBoard({ taskList, t }: { taskList: SharedTaskList; t: (key: string, fallback: string) => string }) { + const taskPlan = useMemo( + () => taskList.tasks.map(task => ({ + id: task.id, + subject: task.subject, + assignedTo: task.assignedDisplayName || task.assignedAgentId || t("taskBoard.unassigned", "Unassigned"), + priority: task.priority, + })), + [taskList, t], + ); + const tasksInProgress = useMemo( + () => new Set(taskList.tasks.filter(task => task.status === "IN_PROGRESS").map(task => task.id)), + [taskList], + ); + const tasksCompleted = useMemo( + () => new Set(taskList.tasks.filter(task => task.status === "COMPLETED").map(task => task.id)), + [taskList], + ); + const taskVerifications = useMemo( + () => new Map( + taskList.tasks + .filter(task => task.status === "VERIFIED" || task.verificationNote != null) + .map(task => [task.id, { passed: task.verified, feedback: task.verificationNote || "" }] as const), + ), + [taskList], + ); + + return ( + + ); +} + /** Phase flow steps per discussion style for the breadcrumb indicator */ const STYLE_INFO_FLOW: Record = { ROUND_TABLE: ["Opinion", "Discussion", "Synthesis"], PEER_REVIEW: ["Opinion", "Critique", "Revision", "Synthesis"], DEVIL_ADVOCATE: ["Opinion", "Challenge", "Defense", "Synthesis"], - DELPHI: ["Independent", "Anonymous", "Revised", "Synthesis"], + DELPHI: ["Independent", "Anonymous Sharing", "Revised", "Synthesis"], DEBATE: ["Pro Opening", "Con Opening", "Rebuttals", "Judgment"], + TASK_FORCE: ["Plan", "Execute", "Verify", "Synthesize"], }; // Re-export for use in group-detail diff --git a/src/components/groups/group-card.tsx b/src/components/groups/group-card.tsx index 4af88ead..51fbb7b2 100644 --- a/src/components/groups/group-card.tsx +++ b/src/components/groups/group-card.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { Link } from "react-router-dom"; import { useTranslation } from "react-i18next"; -import { Users, Copy, Trash2, MoreVertical, ExternalLink } from "lucide-react"; +import { Users, Bot, Copy, Trash2, MoreVertical, ExternalLink } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { cn, formatRelativeTime } from "@/lib/utils"; import { STYLE_INFO, type DiscussionStyle } from "@/lib/api/groups"; @@ -15,6 +15,7 @@ interface GroupCardProps { lastModifiedOn: number; }; memberCount?: number; + members?: { displayName: string; memberType?: string }[]; style?: DiscussionStyle; onDuplicate?: (id: string, version: number) => void; onDelete?: (id: string, version: number) => void; @@ -23,6 +24,7 @@ interface GroupCardProps { export function GroupCard({ group, memberCount = 0, + members = [], style, onDuplicate, onDelete, @@ -31,6 +33,7 @@ export function GroupCard({ const [menuOpen, setMenuOpen] = useState(false); const styleInfo = style ? STYLE_INFO[style] : null; const timeAgo = formatRelativeTime(group.lastModifiedOn); + const effectiveMemberCount = members.length > 0 ? members.length : memberCount; return (
- Group + {t("groups.defaultLabel", "Group")}
)} @@ -63,6 +66,7 @@ export function GroupCard({
{/* Footer: meta + badges */} @@ -130,7 +166,7 @@ export function GroupCard({
- {memberCount} + {effectiveMemberCount} v{group.version} diff --git a/src/components/groups/group-config-panel.tsx b/src/components/groups/group-config-panel.tsx index 9d5355fc..608a0985 100644 --- a/src/components/groups/group-config-panel.tsx +++ b/src/components/groups/group-config-panel.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { Users, Settings2, ArrowRight, Trash2, AlertTriangle, RefreshCw } from "lucide-react"; +import { Users, Settings2, ArrowRight, Trash2, AlertTriangle, RefreshCw, ClipboardList, Bot, Link2 } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { cn, hashColor, getInitials } from "@/lib/utils"; @@ -24,6 +24,7 @@ const PANEL_STYLE_COLORS: Record - Group + + {t("groups.memberTypeGroup", "Group")} )} {config.moderatorAgentId === member.agentId && ( - ⭐ Mod + {t("groups.moderatorBadge", "⭐ Mod")} )}
@@ -144,6 +146,83 @@ export function GroupConfigPanel({ config, groupId, groupVersion, className }: G )} + {/* Pre-configured Tasks (TASK_FORCE) */} + {config.tasks && config.tasks.length > 0 && ( +
+

+ + {t("groups.preConfiguredTasksCount", "Pre-configured Tasks ({{count}})", { count: config.tasks.length })} +

+
+ {config.tasks.map((task, idx) => ( +
+
+ {task.subject} + + P{task.priority} + +
+ {task.description && ( +

{task.description}

+ )} +
+ → {task.assignToRole} + {task.dependsOn && task.dependsOn.length > 0 && ( + + + {task.dependsOn.join(", ")} + + )} +
+
+ ))} +
+
+ )} + + {/* Dynamic Agents */} + {config.dynamicAgents && config.dynamicAgents.enabled && ( +
+

+ + {t("groups.dynamicAgents", "Dynamic Agents")} +

+
+ {config.dynamicAgents.allowCreation && ( + + )} + {config.dynamicAgents.allowRecruitment && ( + + )} + {config.dynamicAgents.allowDelegation && ( + + )} + + {config.dynamicAgents.allowedProviders.length > 0 && ( + + )} +
+
+ )} + {/* Delete group + all agents */} {groupId && groupVersion != null && (
diff --git a/src/components/groups/phase-header.tsx b/src/components/groups/phase-header.tsx index 69e4094e..b1dfc64d 100644 --- a/src/components/groups/phase-header.tsx +++ b/src/components/groups/phase-header.tsx @@ -23,6 +23,9 @@ const PHASE_ICONS: Record = { ARGUE: "📢", REBUTTAL: "↩️", SYNTHESIS: "⭐", + PLAN: "📋", + EXECUTE: "⚡", + VERIFY: "✅", }; export function PhaseHeader({ diff --git a/src/components/groups/task-board.tsx b/src/components/groups/task-board.tsx new file mode 100644 index 00000000..4c7ee5d0 --- /dev/null +++ b/src/components/groups/task-board.tsx @@ -0,0 +1,446 @@ +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { + ClipboardList, + Loader2, + CheckCircle2, + XCircle, + Clock, + Zap, + Shield, +} from "lucide-react"; +import { cn, hashColor, getInitials } from "@/lib/utils"; +import { Badge } from "@/components/ui/badge"; + +/* ------------------------------------------------------------------ */ +/* Types */ +/* ------------------------------------------------------------------ */ + +interface Task { + id: string; + subject: string; + assignedTo: string; + priority: number; +} + +interface TaskVerification { + passed: boolean; + feedback: string; +} + +interface TaskBoardProps { + /** Task plan from task_plan_created SSE event */ + taskPlan: Task[] | null; + /** Set of task IDs currently being executed */ + tasksInProgress: Set; + /** Set of task IDs that have been completed */ + tasksCompleted: Set; + /** Verification results per task ID */ + taskVerifications: Map; + /** Whether the stream is still active */ + isStreaming: boolean; +} + +type TaskStatus = "pending" | "in-progress" | "completed" | "verified"; + +/* ------------------------------------------------------------------ */ +/* Constants */ +/* ------------------------------------------------------------------ */ + +const PRIORITY_CONFIG: Record = { + 0: { label: "P0", className: "bg-red-500/15 text-red-700 dark:text-red-400 border-red-500/30" }, + 1: { label: "P1", className: "bg-orange-500/15 text-orange-700 dark:text-orange-400 border-orange-500/30" }, + 2: { label: "P2", className: "bg-blue-500/15 text-blue-700 dark:text-blue-400 border-blue-500/30" }, + 3: { label: "P3", className: "bg-muted text-muted-foreground border-border" }, +}; + +/* ------------------------------------------------------------------ */ +/* Helpers */ +/* ------------------------------------------------------------------ */ + +function deriveStatus( + taskId: string, + tasksInProgress: Set, + tasksCompleted: Set, + taskVerifications: Map, +): TaskStatus { + if (taskVerifications.has(taskId)) return "verified"; + if (tasksCompleted.has(taskId)) return "completed"; + if (tasksInProgress.has(taskId)) return "in-progress"; + return "pending"; +} + +/* ------------------------------------------------------------------ */ +/* Sub-components */ +/* ------------------------------------------------------------------ */ + +function TaskCard({ + task, + status, + verification, +}: { + task: Task; + status: TaskStatus; + verification?: TaskVerification; +}) { + const { t } = useTranslation(); + const avatarColor = hashColor(task.assignedTo); + const initials = getInitials(task.assignedTo); + const priority = PRIORITY_CONFIG[task.priority] ?? PRIORITY_CONFIG[3]!; + + return ( +
+ {/* Subject */} +

+ {task.subject} +

+ + {/* Agent + Priority row */} +
+ {/* Agent avatar + name */} +
+
+ {initials} +
+ + {task.assignedTo} + +
+ + {/* Priority badge */} + + {priority.label} + +
+ + {/* Verification feedback */} + {status === "verified" && verification && ( +
+ {verification.passed ? ( + + ) : ( + + )} + + {verification.feedback || + t("taskBoard.verified", "Verified")} + +
+ )} +
+ ); +} + +function ColumnHeader({ + icon, + label, + count, + colorClass, +}: { + icon: React.ReactNode; + label: string; + count: number; + colorClass: string; +}) { + return ( +
+
+ {icon} + {label} +
+ + {count} + +
+ ); +} + +/* ------------------------------------------------------------------ */ +/* Progress bar */ +/* ------------------------------------------------------------------ */ + +function ProgressBar({ + total, + completed, + verified, + isStreaming, +}: { + total: number; + completed: number; + verified: number; + isStreaming: boolean; +}) { + const { t } = useTranslation(); + const done = completed + verified; + const pct = total > 0 ? Math.round((done / total) * 100) : 0; + + return ( +
+
+ + {t("taskBoard.progress", "{{done}} of {{total}} tasks done", { done, total })} + +
+ {isStreaming && ( + + )} + + {done}/{total} ({pct}%) + +
+
+
+
+
+
+ ); +} + +/* ------------------------------------------------------------------ */ +/* Main component */ +/* ------------------------------------------------------------------ */ + +export function TaskBoard({ + taskPlan, + tasksInProgress, + tasksCompleted, + taskVerifications, + isStreaming, +}: TaskBoardProps) { + const { t } = useTranslation(); + + // Bucket tasks into columns + const { pending, inProgress, completed, verified } = useMemo(() => { + const buckets = { + pending: [] as Task[], + inProgress: [] as Task[], + completed: [] as Task[], + verified: [] as Task[], + }; + + if (!taskPlan) return buckets; + + for (const task of taskPlan) { + const status = deriveStatus( + task.id, + tasksInProgress, + tasksCompleted, + taskVerifications, + ); + switch (status) { + case "pending": + buckets.pending.push(task); + break; + case "in-progress": + buckets.inProgress.push(task); + break; + case "completed": + buckets.completed.push(task); + break; + case "verified": + buckets.verified.push(task); + break; + } + } + + return buckets; + }, [taskPlan, tasksInProgress, tasksCompleted, taskVerifications]); + + const total = taskPlan?.length ?? 0; + + // ------------------------------------------------------------------ + // Empty state + // ------------------------------------------------------------------ + if (!taskPlan) { + return ( +
+ +

+ {t( + "taskBoard.emptyState", + "Task plan will appear here when the moderator creates it", + )} +

+
+ ); + } + + // ------------------------------------------------------------------ + // Column definitions + // ------------------------------------------------------------------ + const columns = [ + { + key: "pending" as const, + label: t("taskBoard.pending", "Pending"), + icon: , + colorClass: "bg-muted/60 text-muted-foreground", + tasks: pending, + }, + { + key: "in-progress" as const, + label: t("taskBoard.inProgress", "Active"), + icon: , + colorClass: "bg-amber-500/15 text-amber-700 dark:text-amber-300", + tasks: inProgress, + }, + { + key: "completed" as const, + label: t("taskBoard.completed", "Done"), + icon: , + colorClass: "bg-sky-500/15 text-sky-700 dark:text-sky-300", + tasks: completed, + }, + { + key: "verified" as const, + label: t("taskBoard.verified", "Verified"), + icon: , + colorClass: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300", + tasks: verified, + }, + ]; + + // ------------------------------------------------------------------ + // Render + // ------------------------------------------------------------------ + return ( +
+ {/* Section heading */} +

+ + {t("taskBoard.title", "Task Board")} +

+ + {/* Progress bar */} + + + {/* ---- Desktop: 4-column kanban ---- */} +
+ {columns.map((col) => ( +
+ +
+ {col.tasks.length === 0 && ( +

+ — +

+ )} + {col.tasks.map((task) => ( + + ))} +
+
+ ))} +
+ + {/* ---- Mobile: vertical list with status indicators ---- */} +
+ {columns.map((col) => + col.tasks.length > 0 ? ( +
+ {/* Section header */} +
+ {col.icon} + + {col.label} + + + {col.tasks.length} + +
+
+ {col.tasks.map((task) => ( + + ))} +
+
+ ) : null, + )} +
+
+ ); +} diff --git a/src/hooks/__tests__/use-group-discussion-stream.test.ts b/src/hooks/__tests__/use-group-discussion-stream.test.ts index 7cce7b70..629d1cbb 100644 --- a/src/hooks/__tests__/use-group-discussion-stream.test.ts +++ b/src/hooks/__tests__/use-group-discussion-stream.test.ts @@ -62,7 +62,7 @@ describe("useGroupDiscussionStream", () => { it("streams discussion events and updates state successfully", async () => { async function* mockEvents() { - yield { type: "group_start", data: JSON.stringify({ conversationId: "conv-123", question: "Is 2+2=4?" }) }; + yield { type: "group_start", data: JSON.stringify({ groupConversationId: "conv-123", question: "Is 2+2=4?" }) }; yield { type: "phase_start", data: JSON.stringify({ phaseIndex: 0, phaseName: "Opinion Gathering", phaseType: "OPINION" }) }; yield { type: "speaker_start", data: JSON.stringify({ agentId: "agent-1", displayName: "MathBot", phaseIndex: 0, phaseName: "Opinion Gathering" }) }; yield { type: "speaker_complete", data: JSON.stringify({ agentId: "agent-1", displayName: "MathBot", phaseIndex: 0, response: "Yes, 2+2=4." }) }; @@ -89,7 +89,7 @@ describe("useGroupDiscussionStream", () => { it("handles speaker_complete without matching speaker_start placeholder", async () => { async function* mockEvents() { - yield { type: "group_start", data: JSON.stringify({ conversationId: "conv-123", question: "Is 2+2=4?" }) }; + yield { type: "group_start", data: JSON.stringify({ groupConversationId: "conv-123", question: "Is 2+2=4?" }) }; yield { type: "phase_start", data: JSON.stringify({ phaseIndex: 0, phaseName: "Opinion Gathering", phaseType: "CRITIQUE" }) }; yield { type: "speaker_complete", data: JSON.stringify({ agentId: "agent-1", displayName: "MathBot", phaseIndex: 0, content: "Direct reply" }) }; yield { type: "group_complete", data: "{}" }; // empty data @@ -110,7 +110,7 @@ describe("useGroupDiscussionStream", () => { it("handles stream error event", async () => { async function* mockEvents() { - yield { type: "group_start", data: JSON.stringify({ conversationId: "conv-123", question: "Is 2+2=4?" }) }; + yield { type: "group_start", data: JSON.stringify({ groupConversationId: "conv-123", question: "Is 2+2=4?" }) }; yield { type: "group_error", data: JSON.stringify({ error: "Failed to fetch model" }) }; } @@ -165,7 +165,7 @@ describe("useGroupDiscussionStream", () => { it("handles exception thrown in generator", async () => { async function* mockEvents() { - yield { type: "group_start", data: JSON.stringify({ conversationId: "conv-123", question: "Is 2+2=4?" }) }; + yield { type: "group_start", data: JSON.stringify({ groupConversationId: "conv-123", question: "Is 2+2=4?" }) }; throw new Error("Network interrupted"); } @@ -183,7 +183,7 @@ describe("useGroupDiscussionStream", () => { it("swallows AbortError exception in generator", async () => { async function* mockEvents() { - yield { type: "group_start", data: JSON.stringify({ conversationId: "conv-123", question: "Is 2+2=4?" }) }; + yield { type: "group_start", data: JSON.stringify({ groupConversationId: "conv-123", question: "Is 2+2=4?" }) }; throw new DOMException("The operation was aborted.", "AbortError"); } @@ -198,4 +198,186 @@ describe("useGroupDiscussionStream", () => { expect(result.current.streamState.state).toBe("IN_PROGRESS"); expect(result.current.streamState.isStreaming).toBe(false); }); + + it("handles task_plan_created event and populates taskPlan state", async () => { + async function* mockEvents() { + yield { type: "group_start", data: JSON.stringify({ groupConversationId: "conv-200", question: "Plan tasks" }) }; + yield { + type: "task_plan_created", + data: JSON.stringify({ + tasks: [ + { id: "t1", subject: "Research", assignedTo: "Agent Alpha", priority: 0 }, + { id: "t2", subject: "Summarize", assignedTo: "Agent Beta", priority: 1 }, + ], + }), + }; + yield { type: "group_complete", data: JSON.stringify({ synthesizedAnswer: "Done" }) }; + } + + mockStreamGroupDiscussion.mockReturnValue(mockEvents()); + + const { result } = renderHook(() => useGroupDiscussionStream()); + + await act(async () => { + await result.current.startStream("group-1", "Plan tasks"); + }); + + expect(result.current.streamState.taskPlan).toEqual([ + { id: "t1", subject: "Research", assignedTo: "Agent Alpha", priority: 0 }, + { id: "t2", subject: "Summarize", assignedTo: "Agent Beta", priority: 1 }, + ]); + }); + + it("handles task_verified event and populates taskVerifications map", async () => { + async function* mockEvents() { + yield { type: "group_start", data: JSON.stringify({ groupConversationId: "conv-201", question: "Verify tasks" }) }; + yield { + type: "task_plan_created", + data: JSON.stringify({ + tasks: [{ id: "t1", subject: "Research", assignedTo: "Agent Alpha", priority: 0 }], + }), + }; + yield { type: "phase_start", data: JSON.stringify({ phaseIndex: 0, phaseName: "Verification", phaseType: "VERIFY" }) }; + yield { type: "task_verified", data: JSON.stringify({ taskId: "t1", passed: true, feedback: "Good" }) }; + yield { type: "group_complete", data: JSON.stringify({ synthesizedAnswer: "Verified" }) }; + } + + mockStreamGroupDiscussion.mockReturnValue(mockEvents()); + + const { result } = renderHook(() => useGroupDiscussionStream()); + + await act(async () => { + await result.current.startStream("group-1", "Verify tasks"); + }); + + const verification = result.current.streamState.taskVerifications.get("t1"); + expect(verification).toEqual({ passed: true, feedback: "Good" }); + }); + + it("tracks task in-progress during EXECUTE phase on speaker_start", async () => { + async function* mockEvents() { + yield { type: "group_start", data: JSON.stringify({ groupConversationId: "conv-202", question: "Execute tasks" }) }; + yield { + type: "task_plan_created", + data: JSON.stringify({ + tasks: [{ id: "t1", subject: "Research", assignedTo: "Agent Alpha", priority: 0 }], + }), + }; + yield { type: "phase_start", data: JSON.stringify({ phaseIndex: 0, phaseName: "Execution", phaseType: "EXECUTE" }) }; + yield { type: "speaker_start", data: JSON.stringify({ agentId: "agent-1", displayName: "Agent Alpha", phaseIndex: 0, phaseName: "Execution" }) }; + // Do NOT yield speaker_complete — we want to observe in-progress state + yield { type: "group_complete", data: JSON.stringify({ synthesizedAnswer: "In progress" }) }; + } + + mockStreamGroupDiscussion.mockReturnValue(mockEvents()); + + const { result } = renderHook(() => useGroupDiscussionStream()); + + await act(async () => { + await result.current.startStream("group-1", "Execute tasks"); + }); + + expect(result.current.streamState.tasksInProgress.has("t1")).toBe(true); + }); + + it("tracks task completion during EXECUTE phase on speaker_complete", async () => { + async function* mockEvents() { + yield { type: "group_start", data: JSON.stringify({ groupConversationId: "conv-203", question: "Complete tasks" }) }; + yield { + type: "task_plan_created", + data: JSON.stringify({ + tasks: [{ id: "t1", subject: "Research", assignedTo: "Agent Alpha", priority: 0 }], + }), + }; + yield { type: "phase_start", data: JSON.stringify({ phaseIndex: 0, phaseName: "Execution", phaseType: "EXECUTE" }) }; + yield { type: "speaker_start", data: JSON.stringify({ agentId: "agent-1", displayName: "Agent Alpha", phaseIndex: 0, phaseName: "Execution" }) }; + yield { type: "speaker_complete", data: JSON.stringify({ agentId: "agent-1", displayName: "Agent Alpha", phaseIndex: 0, response: "Task done" }) }; + yield { type: "group_complete", data: JSON.stringify({ synthesizedAnswer: "All done" }) }; + } + + mockStreamGroupDiscussion.mockReturnValue(mockEvents()); + + const { result } = renderHook(() => useGroupDiscussionStream()); + + await act(async () => { + await result.current.startStream("group-1", "Complete tasks"); + }); + + expect(result.current.streamState.tasksInProgress.size).toBe(0); + expect(result.current.streamState.tasksCompleted.has("t1")).toBe(true); + }); + + it("resets task state on new stream", async () => { + // First stream with task events + async function* mockEvents1() { + yield { type: "group_start", data: JSON.stringify({ groupConversationId: "conv-204", question: "First stream" }) }; + yield { + type: "task_plan_created", + data: JSON.stringify({ + tasks: [{ id: "t1", subject: "Research", assignedTo: "Agent Alpha", priority: 0 }], + }), + }; + yield { type: "phase_start", data: JSON.stringify({ phaseIndex: 0, phaseName: "Verification", phaseType: "VERIFY" }) }; + yield { type: "task_verified", data: JSON.stringify({ taskId: "t1", passed: true, feedback: "Good" }) }; + yield { type: "group_complete", data: JSON.stringify({ synthesizedAnswer: "Done" }) }; + } + + // Second stream — no task events + async function* mockEvents2() { + yield { type: "group_start", data: JSON.stringify({ groupConversationId: "conv-205", question: "Second stream" }) }; + yield { type: "group_complete", data: JSON.stringify({ synthesizedAnswer: "Done again" }) }; + } + + mockStreamGroupDiscussion.mockReturnValueOnce(mockEvents1()); + + const { result } = renderHook(() => useGroupDiscussionStream()); + + await act(async () => { + await result.current.startStream("group-1", "First stream"); + }); + + // Confirm task state was populated from first stream + expect(result.current.streamState.taskPlan).not.toBeNull(); + expect(result.current.streamState.taskVerifications.size).toBe(1); + + // Start a new stream + mockStreamGroupDiscussion.mockReturnValueOnce(mockEvents2()); + + await act(async () => { + await result.current.startStream("group-1", "Second stream"); + }); + + // All task state should be reset + expect(result.current.streamState.taskPlan).toBeNull(); + expect(result.current.streamState.taskVerifications.size).toBe(0); + expect(result.current.streamState.tasksInProgress.size).toBe(0); + expect(result.current.streamState.tasksCompleted.size).toBe(0); + }); + + it("task tracking does not trigger outside EXECUTE phase", async () => { + async function* mockEvents() { + yield { type: "group_start", data: JSON.stringify({ groupConversationId: "conv-206", question: "Opinion phase" }) }; + yield { + type: "task_plan_created", + data: JSON.stringify({ + tasks: [{ id: "t1", subject: "Research", assignedTo: "Agent Alpha", priority: 0 }], + }), + }; + yield { type: "phase_start", data: JSON.stringify({ phaseIndex: 0, phaseName: "Opinion Gathering", phaseType: "OPINION" }) }; + yield { type: "speaker_start", data: JSON.stringify({ agentId: "agent-1", displayName: "Agent Alpha", phaseIndex: 0, phaseName: "Opinion Gathering" }) }; + yield { type: "speaker_complete", data: JSON.stringify({ agentId: "agent-1", displayName: "Agent Alpha", phaseIndex: 0, response: "My opinion" }) }; + yield { type: "group_complete", data: JSON.stringify({ synthesizedAnswer: "Opinions gathered" }) }; + } + + mockStreamGroupDiscussion.mockReturnValue(mockEvents()); + + const { result } = renderHook(() => useGroupDiscussionStream()); + + await act(async () => { + await result.current.startStream("group-1", "Opinion phase"); + }); + + expect(result.current.streamState.tasksInProgress.size).toBe(0); + expect(result.current.streamState.tasksCompleted.size).toBe(0); + }); }); diff --git a/src/hooks/use-group-discussion-stream.ts b/src/hooks/use-group-discussion-stream.ts index 91b6f921..a7e126d7 100644 --- a/src/hooks/use-group-discussion-stream.ts +++ b/src/hooks/use-group-discussion-stream.ts @@ -10,6 +10,8 @@ import { type SpeakerStartPayload, type SpeakerCompletePayload, type GroupCompletePayload, + type TaskPlanCreatedPayload, + type TaskVerifiedPayload, } from "@/lib/api/groups"; // ─── Streaming State ──────────────────────────────────────────── @@ -33,6 +35,14 @@ export interface GroupStreamState { error: string | null; /** Timestamp when the stream was started (stable, not recalculated per render) */ startedAt: string | null; + /** Task plan received from task_plan_created SSE event */ + taskPlan: { id: string; subject: string; assignedTo: string; assignedAgentId?: string; priority: number }[] | null; + /** Task verification results from task_verified SSE events */ + taskVerifications: Map; + /** Set of task IDs currently being executed (inferred from speaker events during EXECUTE phase) */ + tasksInProgress: Set; + /** Set of task IDs completed (inferred from speaker events during EXECUTE phase) */ + tasksCompleted: Set; } const initialState: GroupStreamState = { @@ -45,6 +55,10 @@ const initialState: GroupStreamState = { synthesizedAnswer: null, error: null, startedAt: null, + taskPlan: null, + taskVerifications: new Map(), + tasksInProgress: new Set(), + tasksCompleted: new Set(), }; // ─── Hook ─────────────────────────────────────────────────────── @@ -67,12 +81,16 @@ export function useGroupDiscussionStream() { const abort = new AbortController(); abortRef.current = abort; - // Reset state + // Reset state with fresh collection instances (don't reuse shared refs from initialState) setStreamState({ ...initialState, isStreaming: true, state: "IN_PROGRESS", startedAt: new Date().toISOString(), + activeSpeakers: new Set(), + tasksInProgress: new Set(), + tasksCompleted: new Set(), + taskVerifications: new Map(), }); try { @@ -136,7 +154,7 @@ function handleSSEEvent( const payload: GroupStartPayload = JSON.parse(event.data); setState((s) => ({ ...s, - conversationId: payload.conversationId, + conversationId: payload.groupConversationId ?? payload.conversationId, state: "IN_PROGRESS", // Add the original question as the first transcript entry transcript: [ @@ -153,8 +171,8 @@ function handleSSEEvent( }, ], })); - } catch { - // ignore parse error + } catch (e) { + console.warn('[SSE] Failed to parse group_start event:', e); } return false; } @@ -170,8 +188,8 @@ function handleSSEEvent( type: payload.phaseType, }, })); - } catch { - // ignore + } catch (e) { + console.warn('[SSE] Failed to parse phase_start event:', e); } return false; } @@ -182,9 +200,29 @@ function handleSSEEvent( setState((s) => { const newSpeakers = new Set(s.activeSpeakers); newSpeakers.add(payload.agentId); + + // Track task execution during EXECUTE phase + let newTasksInProgress = s.tasksInProgress; + if (s.currentPhase?.type === "EXECUTE" && s.taskPlan) { + newTasksInProgress = new Set(s.tasksInProgress); + // Find the next pending task for this agent — prefer agentId, fall back to displayName + const agentTask = s.taskPlan.find( + (t) => + (t.assignedAgentId + ? t.assignedAgentId === payload.agentId + : t.assignedTo === payload.displayName) && + !s.tasksCompleted.has(t.id) && + !s.tasksInProgress.has(t.id) + ); + if (agentTask) { + newTasksInProgress.add(agentTask.id); + } + } + return { ...s, activeSpeakers: newSpeakers, + tasksInProgress: newTasksInProgress, // Add a placeholder entry for the active speaker (typing indicator) transcript: [ ...s.transcript, @@ -202,8 +240,8 @@ function handleSSEEvent( ], }; }); - } catch { - // ignore + } catch (e) { + console.warn('[SSE] Failed to parse speaker_start event:', e); } return false; } @@ -252,14 +290,66 @@ function handleSSEEvent( }); } + // Track task completion during EXECUTE phase + let newTasksInProgress2 = s.tasksInProgress; + let newTasksCompleted = s.tasksCompleted; + if (s.currentPhase?.type === "EXECUTE" && s.taskPlan) { + // Prefer agentId matching, fall back to displayName + const agentTask = s.taskPlan.find( + (t) => + (t.assignedAgentId + ? t.assignedAgentId === payload.agentId + : t.assignedTo === payload.displayName) && + s.tasksInProgress.has(t.id) + ); + if (agentTask) { + newTasksInProgress2 = new Set(s.tasksInProgress); + newTasksInProgress2.delete(agentTask.id); + newTasksCompleted = new Set(s.tasksCompleted); + newTasksCompleted.add(agentTask.id); + } + } + return { ...s, activeSpeakers: newSpeakers, transcript, + tasksInProgress: newTasksInProgress2, + tasksCompleted: newTasksCompleted, }; }); - } catch { - // ignore + } catch (e) { + console.warn('[SSE] Failed to parse speaker_complete event:', e); + } + return false; + } + + case "task_plan_created": { + try { + const payload: TaskPlanCreatedPayload = JSON.parse(event.data); + setState((s) => ({ + ...s, + taskPlan: payload.tasks, + })); + } catch (e) { + console.warn('[SSE] Failed to parse task_plan_created event:', e); + } + return false; + } + + case "task_verified": { + try { + const payload: TaskVerifiedPayload = JSON.parse(event.data); + setState((s) => { + const newVerifications = new Map(s.taskVerifications); + newVerifications.set(payload.taskId, { + passed: payload.passed, + feedback: payload.feedback, + }); + return { ...s, taskVerifications: newVerifications }; + }); + } catch (e) { + console.warn('[SSE] Failed to parse task_verified event:', e); } return false; } @@ -271,8 +361,8 @@ function handleSSEEvent( ...s, activeSpeakers: new Set(), })); - } catch { - // ignore + } catch (e) { + console.warn('[SSE] Failed to parse phase_complete event:', e); } return false; } @@ -295,7 +385,8 @@ function handleSSEEvent( synthesizedAnswer: payload.synthesizedAnswer, activeSpeakers: new Set(), })); - } catch { + } catch (e) { + console.warn('[SSE] Failed to parse group_complete event:', e); setState((s) => ({ ...s, isStreaming: false, @@ -311,7 +402,8 @@ function handleSSEEvent( try { const payload = JSON.parse(event.data); errorMsg = payload.error || payload.message || errorMsg; - } catch { + } catch (e) { + console.warn('[SSE] Failed to parse group_error event:', e); errorMsg = event.data || errorMsg; } setState((s) => ({ @@ -350,6 +442,12 @@ function mapPhaseToEntryType(phaseType?: string): TranscriptEntryType { return "REBUTTAL"; case "SYNTHESIS": return "SYNTHESIS"; + case "PLAN": + return "PLAN"; + case "EXECUTE": + return "TASK_RESULT"; + case "VERIFY": + return "VERIFICATION"; default: return "OPINION"; } diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index 871e35fc..43483147 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -1386,10 +1386,101 @@ "confirmDelete": "حذف هذه المجموعة؟", "confirmDeleteDesc": "سيتم حذف تكوين المجموعة نهائيًا.", "duplicateSuccess": "تم تكرار المجموعة", - "noDescription": "No description", - "members": "members", - "discussionStyle": "Discussion Style", - "maxRounds": "Max Rounds" + "noDescription": "بدون وصف", + "members": "أعضاء", + "discussionStyle": "أسلوب النقاش", + "maxRounds": "الجولات القصوى", + "styleTaskForce": "فريق العمل", + "stateAwaitingApproval": "بانتظار الموافقة", + "phasePlan": "التخطيط", + "phaseExecute": "التنفيذ", + "phaseVerify": "التحقق", + "preConfiguredTasks": "مهام مُعدّة مسبقاً", + "dynamicAgents": "وكلاء ديناميكيون", + "dynamicCreation": "الإنشاء", + "dynamicRecruitment": "التوظيف", + "dynamicDelegation": "التفويض", + "lifecyclePolicy": "دورة الحياة", + "allowedProviders": "المزودون", + "liveIndicator": "● LIVE", + "htmlToggle": "HTML", + "memberTypeGroup": "مجموعة", + "moderatorBadge": "⭐ مشرف", + "styleColumn": "النمط", + "membersColumn": "الأعضاء", + "memberOverflow": "+{{count}} آخرون", + "defaultLabel": "مجموعة", + "liveDiscussion": "مباشر", + "state": { + "CREATED": "تم الإنشاء", + "IN_PROGRESS": "قيد التنفيذ", + "SYNTHESIZING": "جارٍ التجميع", + "COMPLETED": "مكتمل", + "FAILED": "فشل", + "ERROR": "خطأ", + "AWAITING_APPROVAL": "بانتظار الموافقة" + }, + "preConfiguredTasksCount": "مهام مُعدّة مسبقاً ({{count}})", + "entryType": { + "QUESTION": "سؤال", + "OPINION": "رأي", + "CRITIQUE": "نقد", + "REVISION": "مراجعة", + "CHALLENGE": "اعتراض", + "DEFENSE": "دفاع", + "ARGUMENT": "حجة", + "REBUTTAL": "دحض", + "SYNTHESIS": "تركيب", + "ERROR": "خطأ", + "SKIPPED": "تم التخطي", + "PLAN": "خطة", + "TASK_RESULT": "نتيجة المهمة", + "VERIFICATION": "تحقق" + }, + "flow": { + "Opinion": "رأي", + "Discussion": "نقاش", + "Synthesis": "تركيب", + "Critique": "نقد", + "Revision": "مراجعة", + "Challenge": "اعتراض", + "Defense": "دفاع", + "Independent": "مستقل", + "AnonymousSharing": "مشاركة مجهولة", + "Revised": "مُراجَع", + "ProOpening": "افتتاح مؤيد", + "ConOpening": "افتتاح معارض", + "Rebuttals": "ردود", + "Judgment": "حكم", + "Plan": "التخطيط", + "Execute": "التنفيذ", + "Verify": "التحقق", + "Synthesize": "التلخيص" + }, + "lifecycle": { + "DISCUSSION_SCOPED": "محدد بالنقاش", + "PERSISTENT": "دائم", + "EPHEMERAL": "مؤقت" + }, + "dynamicMax": "✓ (max {{count}})", + "dynamicMaxPerTask": "✓ (الحد {{count}}/مهمة)" + }, + "taskBoard": { + "title": "لوحة المهام", + "pending": "قيد الانتظار", + "inProgress": "قيد التنفيذ", + "completed": "مكتمل", + "verified": "تم التحقق", + "failed": "فشل", + "assignedTo": "مُعيَّن إلى {{name}}", + "dependsOn": "يعتمد على: {{tasks}}", + "emptyTitle": "في انتظار خطة المهام", + "emptyDescription": "سيقوم المشرف بإنشاء خطة مهام بمجرد بدء النقاش.", + "emptyState": "ستظهر خطة المهام هنا عندما يقوم المشرف بإنشائها", + "unassigned": "غير مُعيَّن", + "progress": "{{done}} من {{total}} مهام مكتملة", + "verificationPassed": "ناجح", + "verificationFailed": "فاشل" }, "memories": { "title": "ذاكرة المستخدم", @@ -1644,6 +1735,8 @@ "forecastingDesc": "Delphi-style anonymous deliberation for unbiased estimates.", "proCon": "Pro/Con Debate", "proConDesc": "Formal debate with pro and con teams arguing their positions.", + "taskForce": "فريق العمل", + "taskForceDesc": "تنسيق المهام الموجه نحو الأهداف حيث يخطط الوكلاء وينفذون المهام بالتوازي ويتحققون من النتائج ويجمعون النتائج.", "roles": { "marketingExpert": "Marketing Expert", "techLead": "Tech Lead", @@ -1663,7 +1756,11 @@ "proAdvocate1": "Pro Advocate 1", "proAdvocate2": "Pro Advocate 2", "conAdvocate1": "Con Advocate 1", - "conAdvocate2": "Con Advocate 2" + "conAdvocate2": "Con Advocate 2", + "projectLead": "قائد المشروع", + "researcher": "باحث", + "implementer": "منفذ", + "qualityAssurance": "ضمان الجودة" } }, "channels": { diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 02baa906..7ae67297 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -1389,7 +1389,98 @@ "duplicateSuccess": "Gruppe dupliziert", "members": "Mitglieder", "discussionStyle": "Diskussionsstil", - "maxRounds": "Max. Runden" + "maxRounds": "Max. Runden", + "styleTaskForce": "Task Force", + "stateAwaitingApproval": "Genehmigung ausstehend", + "phasePlan": "Planung", + "phaseExecute": "Ausführung", + "phaseVerify": "Überprüfung", + "preConfiguredTasks": "Vorkonfigurierte Aufgaben", + "dynamicAgents": "Dynamische Agenten", + "dynamicCreation": "Erstellung", + "dynamicRecruitment": "Rekrutierung", + "dynamicDelegation": "Delegierung", + "lifecyclePolicy": "Lebenszyklus", + "allowedProviders": "Anbieter", + "liveIndicator": "● LIVE", + "htmlToggle": "HTML", + "memberTypeGroup": "Gruppe", + "moderatorBadge": "⭐ Mod", + "styleColumn": "Stil", + "membersColumn": "Mitglieder", + "memberOverflow": "+{{count}} weitere", + "defaultLabel": "Gruppe", + "liveDiscussion": "Live", + "state": { + "CREATED": "Erstellt", + "IN_PROGRESS": "In Bearbeitung", + "SYNTHESIZING": "Wird zusammengefasst", + "COMPLETED": "Abgeschlossen", + "FAILED": "Fehlgeschlagen", + "ERROR": "Fehler", + "AWAITING_APPROVAL": "Wartet auf Genehmigung" + }, + "preConfiguredTasksCount": "Vorkonfigurierte Aufgaben ({{count}})", + "entryType": { + "QUESTION": "Frage", + "OPINION": "Meinung", + "CRITIQUE": "Kritik", + "REVISION": "Überarbeitung", + "CHALLENGE": "Einwand", + "DEFENSE": "Verteidigung", + "ARGUMENT": "Argument", + "REBUTTAL": "Erwiderung", + "SYNTHESIS": "Synthese", + "ERROR": "Fehler", + "SKIPPED": "Übersprungen", + "PLAN": "Plan", + "TASK_RESULT": "Aufgabenergebnis", + "VERIFICATION": "Überprüfung" + }, + "flow": { + "Opinion": "Meinung", + "Discussion": "Diskussion", + "Synthesis": "Synthese", + "Critique": "Kritik", + "Revision": "Überarbeitung", + "Challenge": "Einwand", + "Defense": "Verteidigung", + "Independent": "Unabhängig", + "AnonymousSharing": "Anonymes Teilen", + "Revised": "Überarbeitet", + "ProOpening": "Pro-Eröffnung", + "ConOpening": "Kontra-Eröffnung", + "Rebuttals": "Erwiderungen", + "Judgment": "Urteil", + "Plan": "Planung", + "Execute": "Ausführung", + "Verify": "Überprüfung", + "Synthesize": "Zusammenfassung" + }, + "lifecycle": { + "DISCUSSION_SCOPED": "diskussionsbezogen", + "PERSISTENT": "persistent", + "EPHEMERAL": "kurzlebig" + }, + "dynamicMax": "✓ (max {{count}})", + "dynamicMaxPerTask": "✓ (max {{count}}/Aufgabe)" + }, + "taskBoard": { + "title": "Aufgabenübersicht", + "pending": "Ausstehend", + "inProgress": "In Bearbeitung", + "completed": "Abgeschlossen", + "verified": "Verifiziert", + "failed": "Fehlgeschlagen", + "assignedTo": "Zugewiesen an {{name}}", + "dependsOn": "Abhängig von: {{tasks}}", + "emptyTitle": "Warte auf Aufgabenplan", + "emptyDescription": "Der Moderator erstellt einen Aufgabenplan, sobald die Diskussion beginnt.", + "emptyState": "Der Aufgabenplan erscheint hier, wenn der Moderator ihn erstellt", + "unassigned": "Nicht zugewiesen", + "progress": "{{done}} von {{total}} Aufgaben erledigt", + "verificationPassed": "Bestanden", + "verificationFailed": "Fehlgeschlagen" }, "groupWizard": { "title": "Gruppen-Assistent", @@ -1460,6 +1551,8 @@ "forecastingDesc": "Anonyme Delphi-Beratung zur Vermeidung von Gruppendenken und für unvoreingenommene Einschätzungen.", "proCon": "Pro/Contra-Debatte", "proConDesc": "Formale Debatte mit Pro- und Contra-Teams, gefolgt von Erwiderungen und einem Urteil.", + "taskForce": "Einsatzgruppe", + "taskForceDesc": "Zielorientierte Aufgabenorchestrierung, bei der Agenten planen, Aufgaben parallel ausführen, Ergebnisse überprüfen und Erkenntnisse zusammenfassen.", "roles": { "marketingExpert": "Marketing-Experte", "techLead": "Tech-Lead", @@ -1479,7 +1572,11 @@ "proAdvocate1": "Pro-Vertreter 1", "proAdvocate2": "Pro-Vertreter 2", "conAdvocate1": "Contra-Vertreter 1", - "conAdvocate2": "Contra-Vertreter 2" + "conAdvocate2": "Contra-Vertreter 2", + "projectLead": "Projektleiter", + "researcher": "Forscher", + "implementer": "Umsetzer", + "qualityAssurance": "Qualitätssicherung" } }, "memories": { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 1f75d075..9b4e1e78 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1431,7 +1431,98 @@ "duplicateSuccess": "Group duplicated", "members": "members", "discussionStyle": "Discussion Style", - "maxRounds": "Max Rounds" + "maxRounds": "Max Rounds", + "styleTaskForce": "Task Force", + "stateAwaitingApproval": "Awaiting Approval", + "phasePlan": "Plan", + "phaseExecute": "Execute", + "phaseVerify": "Verify", + "preConfiguredTasks": "Pre-configured Tasks", + "preConfiguredTasksCount": "Pre-configured Tasks ({{count}})", + "dynamicAgents": "Dynamic Agents", + "dynamicCreation": "Creation", + "dynamicRecruitment": "Recruitment", + "dynamicDelegation": "Delegation", + "dynamicMax": "✓ (max {{count}})", + "dynamicMaxPerTask": "✓ (max {{count}}/task)", + "lifecyclePolicy": "Lifecycle", + "allowedProviders": "Providers", + "liveIndicator": "● LIVE", + "htmlToggle": "HTML", + "memberTypeGroup": "Group", + "moderatorBadge": "⭐ Mod", + "styleColumn": "Style", + "membersColumn": "Members", + "memberOverflow": "+{{count}} more", + "defaultLabel": "Group", + "liveDiscussion": "Live", + "state": { + "CREATED": "Created", + "IN_PROGRESS": "In Progress", + "SYNTHESIZING": "Synthesizing", + "COMPLETED": "Completed", + "FAILED": "Failed", + "ERROR": "Error", + "AWAITING_APPROVAL": "Awaiting Approval" + }, + "entryType": { + "QUESTION": "Question", + "OPINION": "Opinion", + "CRITIQUE": "Critique", + "REVISION": "Revision", + "CHALLENGE": "Challenge", + "DEFENSE": "Defense", + "ARGUMENT": "Argument", + "REBUTTAL": "Rebuttal", + "SYNTHESIS": "Synthesis", + "ERROR": "Error", + "SKIPPED": "Skipped", + "PLAN": "Plan", + "TASK_RESULT": "Task Result", + "VERIFICATION": "Verification" + }, + "flow": { + "Opinion": "Opinion", + "Discussion": "Discussion", + "Synthesis": "Synthesis", + "Critique": "Critique", + "Revision": "Revision", + "Challenge": "Challenge", + "Defense": "Defense", + "Independent": "Independent", + "AnonymousSharing": "Anonymous Sharing", + "Revised": "Revised", + "ProOpening": "Pro Opening", + "ConOpening": "Con Opening", + "Rebuttals": "Rebuttals", + "Judgment": "Judgment", + "Plan": "Plan", + "Execute": "Execute", + "Verify": "Verify", + "Synthesize": "Synthesize" + }, + "lifecycle": { + "DISCUSSION_SCOPED": "discussion scoped", + "PERSISTENT": "persistent", + "EPHEMERAL": "ephemeral" + } + }, + "taskBoard": { + "title": "Task Board", + "pending": "Pending", + "inProgress": "In Progress", + "completed": "Completed", + "verified": "Verified", + "failed": "Failed", + "assignedTo": "Assigned to {{name}}", + "dependsOn": "Depends on: {{tasks}}", + "emptyTitle": "Waiting for task plan", + "emptyDescription": "The moderator will create a task plan once the discussion begins.", + "emptyState": "Task plan will appear here when the moderator creates it", + "unassigned": "Unassigned", + "progress": "{{done}} of {{total}} tasks done", + "verificationPassed": "Passed", + "verificationFailed": "Failed" }, "groupWizard": { "title": "Group Setup Wizard", @@ -1502,6 +1593,8 @@ "forecastingDesc": "Delphi-style anonymous deliberation for reducing groupthink and getting unbiased estimates.", "proCon": "Pro/Con Debate", "proConDesc": "Formal debate with pro and con teams arguing their positions, followed by rebuttals and a verdict.", + "taskForce": "Task Force", + "taskForceDesc": "Goal-oriented task orchestration where agents plan, execute tasks in parallel, verify results, and synthesize findings.", "roles": { "marketingExpert": "Marketing Expert", "techLead": "Tech Lead", @@ -1521,7 +1614,11 @@ "proAdvocate1": "Pro Advocate 1", "proAdvocate2": "Pro Advocate 2", "conAdvocate1": "Con Advocate 1", - "conAdvocate2": "Con Advocate 2" + "conAdvocate2": "Con Advocate 2", + "projectLead": "Project Lead", + "researcher": "Researcher", + "implementer": "Implementer", + "qualityAssurance": "Quality Assurance" } }, "memories": { diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index f5cdc277..271abb19 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -1389,7 +1389,98 @@ "noDescription": "Sin descripción", "members": "miembros", "discussionStyle": "Estilo de discusión", - "maxRounds": "Rondas máx." + "maxRounds": "Rondas máx.", + "styleTaskForce": "Task Force", + "stateAwaitingApproval": "Pendiente de aprobación", + "phasePlan": "Planificación", + "phaseExecute": "Ejecución", + "phaseVerify": "Verificación", + "preConfiguredTasks": "Tareas preconfiguradas", + "dynamicAgents": "Agentes dinámicos", + "dynamicCreation": "Creación", + "dynamicRecruitment": "Reclutamiento", + "dynamicDelegation": "Delegación", + "lifecyclePolicy": "Ciclo de vida", + "allowedProviders": "Proveedores", + "liveIndicator": "● LIVE", + "htmlToggle": "HTML", + "memberTypeGroup": "Grupo", + "moderatorBadge": "⭐ Mod", + "styleColumn": "Estilo", + "membersColumn": "Miembros", + "memberOverflow": "+{{count}} más", + "defaultLabel": "Grupo", + "liveDiscussion": "En vivo", + "state": { + "CREATED": "Creado", + "IN_PROGRESS": "En progreso", + "SYNTHESIZING": "Sintetizando", + "COMPLETED": "Completado", + "FAILED": "Fallido", + "ERROR": "Error", + "AWAITING_APPROVAL": "Pendiente de aprobación" + }, + "preConfiguredTasksCount": "Tareas preconfiguradas ({{count}})", + "entryType": { + "QUESTION": "Pregunta", + "OPINION": "Opinión", + "CRITIQUE": "Crítica", + "REVISION": "Revisión", + "CHALLENGE": "Objeción", + "DEFENSE": "Defensa", + "ARGUMENT": "Argumento", + "REBUTTAL": "Refutación", + "SYNTHESIS": "Síntesis", + "ERROR": "Error", + "SKIPPED": "Omitido", + "PLAN": "Plan", + "TASK_RESULT": "Resultado de tarea", + "VERIFICATION": "Verificación" + }, + "flow": { + "Opinion": "Opinión", + "Discussion": "Discusión", + "Synthesis": "Síntesis", + "Critique": "Crítica", + "Revision": "Revisión", + "Challenge": "Objeción", + "Defense": "Defensa", + "Independent": "Independiente", + "AnonymousSharing": "Compartir anónimo", + "Revised": "Revisado", + "ProOpening": "Apertura a favor", + "ConOpening": "Apertura en contra", + "Rebuttals": "Refutaciones", + "Judgment": "Juicio", + "Plan": "Planificación", + "Execute": "Ejecución", + "Verify": "Verificación", + "Synthesize": "Sintetizar" + }, + "lifecycle": { + "DISCUSSION_SCOPED": "limitado a la discusión", + "PERSISTENT": "persistente", + "EPHEMERAL": "efímero" + }, + "dynamicMax": "✓ (max {{count}})", + "dynamicMaxPerTask": "✓ (máx. {{count}}/tarea)" + }, + "taskBoard": { + "title": "Tablero de tareas", + "pending": "Pendiente", + "inProgress": "En progreso", + "completed": "Completado", + "verified": "Verificado", + "failed": "Fallido", + "assignedTo": "Asignado a {{name}}", + "dependsOn": "Depende de: {{tasks}}", + "emptyTitle": "Esperando plan de tareas", + "emptyDescription": "El moderador creará un plan de tareas una vez que comience la discusión.", + "emptyState": "El plan de tareas aparecerá aquí cuando el moderador lo cree", + "unassigned": "Sin asignar", + "progress": "{{done}} de {{total}} tareas completadas", + "verificationPassed": "Aprobado", + "verificationFailed": "Fallido" }, "memories": { "title": "Memoria del usuario", @@ -1644,6 +1735,8 @@ "forecastingDesc": "Deliberación anónima estilo Delphi para estimaciones imparciales.", "proCon": "Debate a favor/en contra", "proConDesc": "Debate estructurado con equipos a favor y en contra.", + "taskForce": "Grupo de trabajo", + "taskForceDesc": "Orquestación de tareas orientada a objetivos donde los agentes planifican, ejecutan tareas en paralelo, verifican resultados y sintetizan hallazgos.", "roles": { "marketingExpert": "Experto en marketing", "techLead": "Líder técnico", @@ -1663,7 +1756,11 @@ "proAdvocate1": "Defensor a favor 1", "proAdvocate2": "Defensor a favor 2", "conAdvocate1": "Defensor en contra 1", - "conAdvocate2": "Defensor en contra 2" + "conAdvocate2": "Defensor en contra 2", + "projectLead": "Líder de proyecto", + "researcher": "Investigador", + "implementer": "Implementador", + "qualityAssurance": "Control de calidad" } }, "channels": { diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index f9f02c70..41baa2cd 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1389,7 +1389,98 @@ "noDescription": "Aucune description", "members": "membres", "discussionStyle": "Style de discussion", - "maxRounds": "Tours max." + "maxRounds": "Tours max.", + "styleTaskForce": "Task Force", + "stateAwaitingApproval": "En attente d'approbation", + "phasePlan": "Planification", + "phaseExecute": "Exécution", + "phaseVerify": "Vérification", + "preConfiguredTasks": "Tâches préconfigurées", + "dynamicAgents": "Agents dynamiques", + "dynamicCreation": "Création", + "dynamicRecruitment": "Recrutement", + "dynamicDelegation": "Délégation", + "lifecyclePolicy": "Cycle de vie", + "allowedProviders": "Fournisseurs", + "liveIndicator": "● LIVE", + "htmlToggle": "HTML", + "memberTypeGroup": "Groupe", + "moderatorBadge": "⭐ Mod", + "styleColumn": "Style", + "membersColumn": "Membres", + "memberOverflow": "+{{count}} de plus", + "defaultLabel": "Groupe", + "liveDiscussion": "En direct", + "state": { + "CREATED": "Créé", + "IN_PROGRESS": "En cours", + "SYNTHESIZING": "Synthèse en cours", + "COMPLETED": "Terminé", + "FAILED": "Échoué", + "ERROR": "Erreur", + "AWAITING_APPROVAL": "En attente d'approbation" + }, + "preConfiguredTasksCount": "Tâches préconfigurées ({{count}})", + "entryType": { + "QUESTION": "Question", + "OPINION": "Opinion", + "CRITIQUE": "Critique", + "REVISION": "Révision", + "CHALLENGE": "Objection", + "DEFENSE": "Défense", + "ARGUMENT": "Argument", + "REBUTTAL": "Réfutation", + "SYNTHESIS": "Synthèse", + "ERROR": "Erreur", + "SKIPPED": "Ignoré", + "PLAN": "Plan", + "TASK_RESULT": "Résultat de tâche", + "VERIFICATION": "Vérification" + }, + "flow": { + "Opinion": "Opinion", + "Discussion": "Discussion", + "Synthesis": "Synthèse", + "Critique": "Critique", + "Revision": "Révision", + "Challenge": "Objection", + "Defense": "Défense", + "Independent": "Indépendant", + "AnonymousSharing": "Partage anonyme", + "Revised": "Révisé", + "ProOpening": "Ouverture pour", + "ConOpening": "Ouverture contre", + "Rebuttals": "Réfutations", + "Judgment": "Jugement", + "Plan": "Planification", + "Execute": "Exécution", + "Verify": "Vérification", + "Synthesize": "Synthétiser" + }, + "lifecycle": { + "DISCUSSION_SCOPED": "limité à la discussion", + "PERSISTENT": "persistant", + "EPHEMERAL": "éphémère" + }, + "dynamicMax": "✓ (max {{count}})", + "dynamicMaxPerTask": "✓ (max {{count}}/tâche)" + }, + "taskBoard": { + "title": "Tableau des tâches", + "pending": "En attente", + "inProgress": "En cours", + "completed": "Terminé", + "verified": "Vérifié", + "failed": "Échoué", + "assignedTo": "Assigné à {{name}}", + "dependsOn": "Dépend de : {{tasks}}", + "emptyTitle": "En attente du plan de tâches", + "emptyDescription": "Le modérateur créera un plan de tâches une fois la discussion commencée.", + "emptyState": "Le plan de tâches apparaîtra ici lorsque le modérateur le créera", + "unassigned": "Non assigné", + "progress": "{{done}} sur {{total}} tâches terminées", + "verificationPassed": "Réussi", + "verificationFailed": "Échoué" }, "memories": { "title": "Mémoire utilisateur", @@ -1644,6 +1735,8 @@ "forecastingDesc": "Délibération anonyme de style Delphi pour des estimations impartiales.", "proCon": "Débat pour/contre", "proConDesc": "Débat structuré avec équipes pour et contre, suivi de réfutations.", + "taskForce": "Groupe de travail", + "taskForceDesc": "Orchestration orientée objectif où les agents planifient, exécutent des tâches en parallèle, vérifient les résultats et synthétisent les conclusions.", "roles": { "marketingExpert": "Expert marketing", "techLead": "Lead technique", @@ -1663,7 +1756,11 @@ "proAdvocate1": "Avocat pour 1", "proAdvocate2": "Avocat pour 2", "conAdvocate1": "Avocat contre 1", - "conAdvocate2": "Avocat contre 2" + "conAdvocate2": "Avocat contre 2", + "projectLead": "Chef de projet", + "researcher": "Chercheur", + "implementer": "Développeur", + "qualityAssurance": "Assurance qualité" } }, "channels": { diff --git a/src/i18n/locales/hi.json b/src/i18n/locales/hi.json index a52638ef..987b7213 100644 --- a/src/i18n/locales/hi.json +++ b/src/i18n/locales/hi.json @@ -1386,10 +1386,101 @@ "confirmDelete": "इस Group को हटाएं?", "confirmDeleteDesc": "Group कॉन्फ़िगरेशन स्थायी रूप से हटा दी जाएगी।", "duplicateSuccess": "Group डुप्लिकेट किया गया", - "noDescription": "No description", - "members": "members", - "discussionStyle": "Discussion Style", - "maxRounds": "Max Rounds" + "noDescription": "कोई विवरण नहीं", + "members": "सदस्य", + "discussionStyle": "चर्चा शैली", + "maxRounds": "अधिकतम राउंड", + "styleTaskForce": "Task Force", + "stateAwaitingApproval": "अनुमोदन की प्रतीक्षा", + "phasePlan": "योजना", + "phaseExecute": "कार्यान्वयन", + "phaseVerify": "सत्यापन", + "preConfiguredTasks": "पूर्व-कॉन्फ़िगर किए गए कार्य", + "dynamicAgents": "डायनामिक एजेंट", + "dynamicCreation": "निर्माण", + "dynamicRecruitment": "भर्ती", + "dynamicDelegation": "प्रतिनिधिमंडल", + "lifecyclePolicy": "जीवन चक्र", + "allowedProviders": "प्रदाता", + "liveIndicator": "● LIVE", + "htmlToggle": "HTML", + "memberTypeGroup": "समूह", + "moderatorBadge": "⭐ मॉडरेटर", + "styleColumn": "शैली", + "membersColumn": "सदस्य", + "memberOverflow": "+{{count}} और", + "defaultLabel": "समूह", + "liveDiscussion": "लाइव", + "state": { + "CREATED": "बनाया गया", + "IN_PROGRESS": "प्रगति में", + "SYNTHESIZING": "संश्लेषण जारी", + "COMPLETED": "पूर्ण", + "FAILED": "विफल", + "ERROR": "त्रुटि", + "AWAITING_APPROVAL": "अनुमोदन की प्रतीक्षा" + }, + "preConfiguredTasksCount": "पूर्व-कॉन्फ़िगर किए गए कार्य ({{count}})", + "entryType": { + "QUESTION": "प्रश्न", + "OPINION": "राय", + "CRITIQUE": "आलोचना", + "REVISION": "संशोधन", + "CHALLENGE": "आपत्ति", + "DEFENSE": "बचाव", + "ARGUMENT": "तर्क", + "REBUTTAL": "खंडन", + "SYNTHESIS": "संश्लेषण", + "ERROR": "त्रुटि", + "SKIPPED": "छोड़ा गया", + "PLAN": "योजना", + "TASK_RESULT": "कार्य परिणाम", + "VERIFICATION": "सत्यापन" + }, + "flow": { + "Opinion": "राय", + "Discussion": "चर्चा", + "Synthesis": "संश्लेषण", + "Critique": "आलोचना", + "Revision": "संशोधन", + "Challenge": "आपत्ति", + "Defense": "बचाव", + "Independent": "स्वतंत्र", + "AnonymousSharing": "गुमनाम साझाकरण", + "Revised": "संशोधित", + "ProOpening": "पक्ष में आरंभ", + "ConOpening": "विपक्ष में आरंभ", + "Rebuttals": "खंडन", + "Judgment": "निर्णय", + "Plan": "योजना", + "Execute": "कार्यान्वयन", + "Verify": "सत्यापन", + "Synthesize": "सारांश" + }, + "lifecycle": { + "DISCUSSION_SCOPED": "चर्चा सीमित", + "PERSISTENT": "स्थायी", + "EPHEMERAL": "अस्थायी" + }, + "dynamicMax": "✓ (max {{count}})", + "dynamicMaxPerTask": "✓ (अधिकतम {{count}}/कार्य)" + }, + "taskBoard": { + "title": "कार्य बोर्ड", + "pending": "लंबित", + "inProgress": "प्रगति में", + "completed": "पूर्ण", + "verified": "सत्यापित", + "failed": "विफल", + "assignedTo": "{{name}} को सौंपा गया", + "dependsOn": "निर्भर: {{tasks}}", + "emptyTitle": "कार्य योजना की प्रतीक्षा", + "emptyDescription": "चर्चा शुरू होने पर मॉडरेटर कार्य योजना बनाएगा।", + "emptyState": "मॉडरेटर द्वारा बनाए जाने पर कार्य योजना यहाँ दिखाई देगी", + "unassigned": "असाइन नहीं किया गया", + "progress": "{{done}} / {{total}} कार्य पूर्ण", + "verificationPassed": "उत्तीर्ण", + "verificationFailed": "विफल" }, "memories": { "title": "उपयोगकर्ता स्मृति", @@ -1644,6 +1735,8 @@ "forecastingDesc": "Delphi-style anonymous deliberation for unbiased estimates.", "proCon": "Pro/Con Debate", "proConDesc": "Formal debate with pro and con teams arguing their positions.", + "taskForce": "कार्य दल", + "taskForceDesc": "लक्ष्य-उन्मुख कार्य समन्वय जहां एजेंट योजना बनाते हैं, समानांतर में कार्य निष्पादित करते हैं, परिणाम सत्यापित करते हैं और निष्कर्ष संश्लेषित करते हैं।", "roles": { "marketingExpert": "Marketing Expert", "techLead": "Tech Lead", @@ -1663,7 +1756,11 @@ "proAdvocate1": "Pro Advocate 1", "proAdvocate2": "Pro Advocate 2", "conAdvocate1": "Con Advocate 1", - "conAdvocate2": "Con Advocate 2" + "conAdvocate2": "Con Advocate 2", + "projectLead": "परियोजना प्रमुख", + "researcher": "शोधकर्ता", + "implementer": "कार्यान्वयनकर्ता", + "qualityAssurance": "गुणवत्ता आश्वासन" } }, "channels": { diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 9c570d42..aef559a4 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1386,10 +1386,101 @@ "confirmDelete": "このグループを削除しますか?", "confirmDeleteDesc": "グループの設定が完全に削除されます。", "duplicateSuccess": "グループを複製しました", - "noDescription": "No description", - "members": "members", - "discussionStyle": "Discussion Style", - "maxRounds": "Max Rounds" + "noDescription": "説明なし", + "members": "メンバー", + "discussionStyle": "ディスカッションスタイル", + "maxRounds": "最大ラウンド数", + "styleTaskForce": "Task Force", + "stateAwaitingApproval": "承認待ち", + "phasePlan": "計画", + "phaseExecute": "実行", + "phaseVerify": "検証", + "preConfiguredTasks": "事前設定タスク", + "dynamicAgents": "動的エージェント", + "dynamicCreation": "作成", + "dynamicRecruitment": "採用", + "dynamicDelegation": "委任", + "lifecyclePolicy": "ライフサイクル", + "allowedProviders": "プロバイダー", + "liveIndicator": "● LIVE", + "htmlToggle": "HTML", + "memberTypeGroup": "グループ", + "moderatorBadge": "⭐ モデレーター", + "styleColumn": "スタイル", + "membersColumn": "メンバー", + "memberOverflow": "+{{count}} 件", + "defaultLabel": "グループ", + "liveDiscussion": "ライブ", + "state": { + "CREATED": "作成済み", + "IN_PROGRESS": "進行中", + "SYNTHESIZING": "統合中", + "COMPLETED": "完了", + "FAILED": "失敗", + "ERROR": "エラー", + "AWAITING_APPROVAL": "承認待ち" + }, + "preConfiguredTasksCount": "事前設定タスク ({{count}})", + "entryType": { + "QUESTION": "質問", + "OPINION": "意見", + "CRITIQUE": "批評", + "REVISION": "修正", + "CHALLENGE": "異議", + "DEFENSE": "弁護", + "ARGUMENT": "論拠", + "REBUTTAL": "反論", + "SYNTHESIS": "総合", + "ERROR": "エラー", + "SKIPPED": "スキップ済み", + "PLAN": "計画", + "TASK_RESULT": "タスク結果", + "VERIFICATION": "検証" + }, + "flow": { + "Opinion": "意見", + "Discussion": "討論", + "Synthesis": "総合", + "Critique": "批評", + "Revision": "修正", + "Challenge": "異議", + "Defense": "弁護", + "Independent": "独立", + "AnonymousSharing": "匿名共有", + "Revised": "修正済み", + "ProOpening": "賛成側開始", + "ConOpening": "反対側開始", + "Rebuttals": "反論", + "Judgment": "判定", + "Plan": "計画", + "Execute": "実行", + "Verify": "検証", + "Synthesize": "統合" + }, + "lifecycle": { + "DISCUSSION_SCOPED": "ディスカッション限定", + "PERSISTENT": "永続", + "EPHEMERAL": "一時的" + }, + "dynamicMax": "✓ (max {{count}})", + "dynamicMaxPerTask": "✓ (最大 {{count}}/タスク)" + }, + "taskBoard": { + "title": "タスクボード", + "pending": "保留中", + "inProgress": "進行中", + "completed": "完了", + "verified": "検証済み", + "failed": "失敗", + "assignedTo": "{{name}} に割り当て", + "dependsOn": "依存先:{{tasks}}", + "emptyTitle": "タスク計画を待機中", + "emptyDescription": "ディスカッションが開始されると、モデレーターがタスク計画を作成します。", + "emptyState": "モデレーターが作成すると、ここにタスク計画が表示されます", + "unassigned": "未割り当て", + "progress": "{{done}} / {{total}} タスク完了", + "verificationPassed": "合格", + "verificationFailed": "不合格" }, "memories": { "title": "ユーザーメモリ", @@ -1644,6 +1735,8 @@ "forecastingDesc": "Delphi-style anonymous deliberation for unbiased estimates.", "proCon": "Pro/Con Debate", "proConDesc": "Formal debate with pro and con teams arguing their positions.", + "taskForce": "タスクフォース", + "taskForceDesc": "目標指向のタスクオーケストレーション。エージェントが計画、並行実行、結果検証、発見の統合を行います。", "roles": { "marketingExpert": "Marketing Expert", "techLead": "Tech Lead", @@ -1663,7 +1756,11 @@ "proAdvocate1": "Pro Advocate 1", "proAdvocate2": "Pro Advocate 2", "conAdvocate1": "Con Advocate 1", - "conAdvocate2": "Con Advocate 2" + "conAdvocate2": "Con Advocate 2", + "projectLead": "プロジェクトリーダー", + "researcher": "研究者", + "implementer": "実装者", + "qualityAssurance": "品質保証" } }, "channels": { diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index 0315171b..92c8aa0b 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -1386,10 +1386,101 @@ "confirmDelete": "이 그룹을 삭제하시겠습니까?", "confirmDeleteDesc": "그룹 구성이 영구적으로 삭제됩니다.", "duplicateSuccess": "그룹이 복제되었습니다", - "noDescription": "No description", - "members": "members", - "discussionStyle": "Discussion Style", - "maxRounds": "Max Rounds" + "noDescription": "설명 없음", + "members": "멤버", + "discussionStyle": "토론 스타일", + "maxRounds": "최대 라운드", + "styleTaskForce": "Task Force", + "stateAwaitingApproval": "승인 대기 중", + "phasePlan": "계획", + "phaseExecute": "실행", + "phaseVerify": "검증", + "preConfiguredTasks": "사전 구성된 작업", + "dynamicAgents": "동적 에이전트", + "dynamicCreation": "생성", + "dynamicRecruitment": "모집", + "dynamicDelegation": "위임", + "lifecyclePolicy": "수명 주기", + "allowedProviders": "제공자", + "liveIndicator": "● LIVE", + "htmlToggle": "HTML", + "memberTypeGroup": "그룹", + "moderatorBadge": "⭐ 관리자", + "styleColumn": "스타일", + "membersColumn": "멤버", + "memberOverflow": "+{{count}} 더 보기", + "defaultLabel": "그룹", + "liveDiscussion": "라이브", + "state": { + "CREATED": "생성됨", + "IN_PROGRESS": "진행 중", + "SYNTHESIZING": "합성 중", + "COMPLETED": "완료", + "FAILED": "실패", + "ERROR": "오류", + "AWAITING_APPROVAL": "승인 대기 중" + }, + "preConfiguredTasksCount": "사전 구성된 작업 ({{count}})", + "entryType": { + "QUESTION": "질문", + "OPINION": "의견", + "CRITIQUE": "비평", + "REVISION": "수정", + "CHALLENGE": "이의", + "DEFENSE": "변호", + "ARGUMENT": "논거", + "REBUTTAL": "반박", + "SYNTHESIS": "종합", + "ERROR": "오류", + "SKIPPED": "건너뜀", + "PLAN": "계획", + "TASK_RESULT": "작업 결과", + "VERIFICATION": "검증" + }, + "flow": { + "Opinion": "의견", + "Discussion": "토론", + "Synthesis": "종합", + "Critique": "비평", + "Revision": "수정", + "Challenge": "이의", + "Defense": "변호", + "Independent": "독립", + "AnonymousSharing": "익명 공유", + "Revised": "수정됨", + "ProOpening": "찬성 개시", + "ConOpening": "반대 개시", + "Rebuttals": "반박", + "Judgment": "판정", + "Plan": "계획", + "Execute": "실행", + "Verify": "검증", + "Synthesize": "종합 정리" + }, + "lifecycle": { + "DISCUSSION_SCOPED": "토론 한정", + "PERSISTENT": "영구", + "EPHEMERAL": "일시적" + }, + "dynamicMax": "✓ (max {{count}})", + "dynamicMaxPerTask": "✓ (최대 {{count}}/작업)" + }, + "taskBoard": { + "title": "작업 보드", + "pending": "대기 중", + "inProgress": "진행 중", + "completed": "완료", + "verified": "검증됨", + "failed": "실패", + "assignedTo": "{{name}}에게 할당됨", + "dependsOn": "의존 대상: {{tasks}}", + "emptyTitle": "작업 계획 대기 중", + "emptyDescription": "토론이 시작되면 관리자가 작업 계획을 생성합니다.", + "emptyState": "관리자가 작업 계획을 생성하면 여기에 표시됩니다", + "unassigned": "미할당", + "progress": "{{done}} / {{total}} 작업 완료", + "verificationPassed": "통과", + "verificationFailed": "실패" }, "memories": { "title": "사용자 메모리", @@ -1644,6 +1735,8 @@ "forecastingDesc": "Delphi-style anonymous deliberation for unbiased estimates.", "proCon": "Pro/Con Debate", "proConDesc": "Formal debate with pro and con teams arguing their positions.", + "taskForce": "태스크 포스", + "taskForceDesc": "목표 지향적 작업 조율로 에이전트가 계획, 병렬 실행, 결과 검증 및 결과 종합을 수행합니다.", "roles": { "marketingExpert": "Marketing Expert", "techLead": "Tech Lead", @@ -1663,7 +1756,11 @@ "proAdvocate1": "Pro Advocate 1", "proAdvocate2": "Pro Advocate 2", "conAdvocate1": "Con Advocate 1", - "conAdvocate2": "Con Advocate 2" + "conAdvocate2": "Con Advocate 2", + "projectLead": "프로젝트 리더", + "researcher": "연구원", + "implementer": "구현자", + "qualityAssurance": "품질 보증" } }, "channels": { diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 9e3709ce..aff295f0 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -1386,10 +1386,101 @@ "confirmDelete": "Excluir este grupo?", "confirmDeleteDesc": "A configuração do grupo será excluída permanentemente.", "duplicateSuccess": "Grupo duplicado", - "noDescription": "No description", - "members": "members", - "discussionStyle": "Discussion Style", - "maxRounds": "Max Rounds" + "noDescription": "Sem descrição", + "members": "membros", + "discussionStyle": "Estilo de discussão", + "maxRounds": "Rodadas máx.", + "styleTaskForce": "Task Force", + "stateAwaitingApproval": "Aguardando aprovação", + "phasePlan": "Planejamento", + "phaseExecute": "Execução", + "phaseVerify": "Verificação", + "preConfiguredTasks": "Tarefas pré-configuradas", + "dynamicAgents": "Agentes dinâmicos", + "dynamicCreation": "Criação", + "dynamicRecruitment": "Recrutamento", + "dynamicDelegation": "Delegação", + "lifecyclePolicy": "Ciclo de vida", + "allowedProviders": "Provedores", + "liveIndicator": "● LIVE", + "htmlToggle": "HTML", + "memberTypeGroup": "Grupo", + "moderatorBadge": "⭐ Mod", + "styleColumn": "Estilo", + "membersColumn": "Membros", + "memberOverflow": "+{{count}} mais", + "defaultLabel": "Grupo", + "liveDiscussion": "Ao vivo", + "state": { + "CREATED": "Criado", + "IN_PROGRESS": "Em andamento", + "SYNTHESIZING": "Sintetizando", + "COMPLETED": "Concluído", + "FAILED": "Falhou", + "ERROR": "Erro", + "AWAITING_APPROVAL": "Aguardando aprovação" + }, + "preConfiguredTasksCount": "Tarefas pré-configuradas ({{count}})", + "entryType": { + "QUESTION": "Pergunta", + "OPINION": "Opinião", + "CRITIQUE": "Crítica", + "REVISION": "Revisão", + "CHALLENGE": "Contestação", + "DEFENSE": "Defesa", + "ARGUMENT": "Argumento", + "REBUTTAL": "Refutação", + "SYNTHESIS": "Síntese", + "ERROR": "Erro", + "SKIPPED": "Ignorado", + "PLAN": "Plano", + "TASK_RESULT": "Resultado da tarefa", + "VERIFICATION": "Verificação" + }, + "flow": { + "Opinion": "Opinião", + "Discussion": "Discussão", + "Synthesis": "Síntese", + "Critique": "Crítica", + "Revision": "Revisão", + "Challenge": "Contestação", + "Defense": "Defesa", + "Independent": "Independente", + "AnonymousSharing": "Compartilhamento anônimo", + "Revised": "Revisado", + "ProOpening": "Abertura pró", + "ConOpening": "Abertura contra", + "Rebuttals": "Refutações", + "Judgment": "Julgamento", + "Plan": "Planejamento", + "Execute": "Execução", + "Verify": "Verificação", + "Synthesize": "Sintetizar" + }, + "lifecycle": { + "DISCUSSION_SCOPED": "limitado à discussão", + "PERSISTENT": "persistente", + "EPHEMERAL": "efêmero" + }, + "dynamicMax": "✓ (max {{count}})", + "dynamicMaxPerTask": "✓ (máx. {{count}}/tarefa)" + }, + "taskBoard": { + "title": "Quadro de tarefas", + "pending": "Pendente", + "inProgress": "Em andamento", + "completed": "Concluído", + "verified": "Verificado", + "failed": "Falhou", + "assignedTo": "Atribuído a {{name}}", + "dependsOn": "Depende de: {{tasks}}", + "emptyTitle": "Aguardando plano de tarefas", + "emptyDescription": "O moderador criará um plano de tarefas assim que a discussão começar.", + "emptyState": "O plano de tarefas aparecerá aqui quando o moderador criá-lo", + "unassigned": "Não atribuído", + "progress": "{{done}} de {{total}} tarefas concluídas", + "verificationPassed": "Aprovado", + "verificationFailed": "Reprovado" }, "memories": { "title": "Memória do utilizador", @@ -1644,6 +1735,8 @@ "forecastingDesc": "Delphi-style anonymous deliberation for unbiased estimates.", "proCon": "Pro/Con Debate", "proConDesc": "Formal debate with pro and con teams arguing their positions.", + "taskForce": "Força-Tarefa", + "taskForceDesc": "Orquestração de tarefas orientada a objetivos onde agentes planejam, executam tarefas em paralelo, verificam resultados e sintetizam descobertas.", "roles": { "marketingExpert": "Marketing Expert", "techLead": "Tech Lead", @@ -1663,7 +1756,11 @@ "proAdvocate1": "Pro Advocate 1", "proAdvocate2": "Pro Advocate 2", "conAdvocate1": "Con Advocate 1", - "conAdvocate2": "Con Advocate 2" + "conAdvocate2": "Con Advocate 2", + "projectLead": "Líder de Projeto", + "researcher": "Pesquisador", + "implementer": "Implementador", + "qualityAssurance": "Garantia de Qualidade" } }, "channels": { diff --git a/src/i18n/locales/th.json b/src/i18n/locales/th.json index a924b822..ff674f94 100644 --- a/src/i18n/locales/th.json +++ b/src/i18n/locales/th.json @@ -1386,10 +1386,101 @@ "confirmDelete": "ลบกลุ่มนี้?", "confirmDeleteDesc": "การกำหนดค่ากลุ่มจะถูกลบอย่างถาวร", "duplicateSuccess": "Group ถูกทำซ้ำแล้ว", - "noDescription": "No description", - "members": "members", - "discussionStyle": "Discussion Style", - "maxRounds": "Max Rounds" + "noDescription": "ไม่มีคำอธิบาย", + "members": "สมาชิก", + "discussionStyle": "รูปแบบการสนทนา", + "maxRounds": "รอบสูงสุด", + "styleTaskForce": "Task Force", + "stateAwaitingApproval": "รอการอนุมัติ", + "phasePlan": "วางแผน", + "phaseExecute": "ดำเนินการ", + "phaseVerify": "ตรวจสอบ", + "preConfiguredTasks": "งานที่ตั้งค่าไว้ล่วงหน้า", + "dynamicAgents": "เอเจนต์แบบไดนามิก", + "dynamicCreation": "การสร้าง", + "dynamicRecruitment": "การสรรหา", + "dynamicDelegation": "การมอบหมาย", + "lifecyclePolicy": "วงจรชีวิต", + "allowedProviders": "ผู้ให้บริการ", + "liveIndicator": "● LIVE", + "htmlToggle": "HTML", + "memberTypeGroup": "กลุ่ม", + "moderatorBadge": "⭐ ผู้ดูแล", + "styleColumn": "สไตล์", + "membersColumn": "สมาชิก", + "memberOverflow": "+{{count}} เพิ่มเติม", + "defaultLabel": "กลุ่ม", + "liveDiscussion": "สด", + "state": { + "CREATED": "สร้างแล้ว", + "IN_PROGRESS": "กำลังดำเนินการ", + "SYNTHESIZING": "กำลังสังเคราะห์", + "COMPLETED": "เสร็จสิ้น", + "FAILED": "ล้มเหลว", + "ERROR": "ข้อผิดพลาด", + "AWAITING_APPROVAL": "รอการอนุมัติ" + }, + "preConfiguredTasksCount": "งานที่ตั้งค่าไว้ล่วงหน้า ({{count}})", + "entryType": { + "QUESTION": "คำถาม", + "OPINION": "ความคิดเห็น", + "CRITIQUE": "การวิจารณ์", + "REVISION": "การแก้ไข", + "CHALLENGE": "การคัดค้าน", + "DEFENSE": "การปกป้อง", + "ARGUMENT": "ข้อโต้แย้ง", + "REBUTTAL": "การหักล้าง", + "SYNTHESIS": "การสังเคราะห์", + "ERROR": "ข้อผิดพลาด", + "SKIPPED": "ข้ามแล้ว", + "PLAN": "แผน", + "TASK_RESULT": "ผลลัพธ์ของงาน", + "VERIFICATION": "การตรวจสอบ" + }, + "flow": { + "Opinion": "ความคิดเห็น", + "Discussion": "การอภิปราย", + "Synthesis": "การสังเคราะห์", + "Critique": "การวิจารณ์", + "Revision": "การแก้ไข", + "Challenge": "การคัดค้าน", + "Defense": "การปกป้อง", + "Independent": "อิสระ", + "AnonymousSharing": "การแบ่งปันแบบไม่ระบุชื่อ", + "Revised": "แก้ไขแล้ว", + "ProOpening": "เปิดฝ่ายสนับสนุน", + "ConOpening": "เปิดฝ่ายคัดค้าน", + "Rebuttals": "การหักล้าง", + "Judgment": "การตัดสิน", + "Plan": "วางแผน", + "Execute": "ดำเนินการ", + "Verify": "ตรวจสอบ", + "Synthesize": "สรุปรวม" + }, + "lifecycle": { + "DISCUSSION_SCOPED": "จำกัดเฉพาะการสนทนา", + "PERSISTENT": "ถาวร", + "EPHEMERAL": "ชั่วคราว" + }, + "dynamicMax": "✓ (max {{count}})", + "dynamicMaxPerTask": "✓ (สูงสุด {{count}}/งาน)" + }, + "taskBoard": { + "title": "บอร์ดงาน", + "pending": "รอดำเนินการ", + "inProgress": "กำลังดำเนินการ", + "completed": "เสร็จสิ้น", + "verified": "ตรวจสอบแล้ว", + "failed": "ล้มเหลว", + "assignedTo": "มอบหมายให้ {{name}}", + "dependsOn": "ขึ้นอยู่กับ: {{tasks}}", + "emptyTitle": "รอแผนงาน", + "emptyDescription": "ผู้ดูแลจะสร้างแผนงานเมื่อการสนทนาเริ่มต้น", + "emptyState": "แผนงานจะปรากฏที่นี่เมื่อผู้ดูแลสร้างขึ้น", + "unassigned": "ยังไม่มอบหมาย", + "progress": "{{done}} จาก {{total}} งานเสร็จแล้ว", + "verificationPassed": "ผ่าน", + "verificationFailed": "ไม่ผ่าน" }, "memories": { "title": "หน่วยความจำผู้ใช้", @@ -1644,6 +1735,8 @@ "forecastingDesc": "Delphi-style anonymous deliberation for unbiased estimates.", "proCon": "Pro/Con Debate", "proConDesc": "Formal debate with pro and con teams arguing their positions.", + "taskForce": "ทีมงาน", + "taskForceDesc": "การจัดการงานที่มุ่งเป้าหมาย โดยตัวแทนวางแผน ดำเนินงานแบบขนาน ตรวจสอบผลลัพธ์ และสรุปข้อค้นพบ", "roles": { "marketingExpert": "Marketing Expert", "techLead": "Tech Lead", @@ -1663,7 +1756,11 @@ "proAdvocate1": "Pro Advocate 1", "proAdvocate2": "Pro Advocate 2", "conAdvocate1": "Con Advocate 1", - "conAdvocate2": "Con Advocate 2" + "conAdvocate2": "Con Advocate 2", + "projectLead": "หัวหน้าโครงการ", + "researcher": "นักวิจัย", + "implementer": "ผู้ดำเนินการ", + "qualityAssurance": "การประกันคุณภาพ" } }, "channels": { diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index b9ff55d1..2cebc595 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1386,10 +1386,101 @@ "confirmDelete": "删除此群组?", "confirmDeleteDesc": "群组配置将被永久删除。", "duplicateSuccess": "群组已复制", - "noDescription": "No description", - "members": "members", - "discussionStyle": "Discussion Style", - "maxRounds": "Max Rounds" + "noDescription": "暂无描述", + "members": "成员", + "discussionStyle": "讨论风格", + "maxRounds": "最大轮次", + "styleTaskForce": "Task Force", + "stateAwaitingApproval": "等待审批", + "phasePlan": "规划", + "phaseExecute": "执行", + "phaseVerify": "验证", + "preConfiguredTasks": "预配置任务", + "dynamicAgents": "动态代理", + "dynamicCreation": "创建", + "dynamicRecruitment": "招募", + "dynamicDelegation": "委派", + "lifecyclePolicy": "生命周期", + "allowedProviders": "供应商", + "liveIndicator": "● LIVE", + "htmlToggle": "HTML", + "memberTypeGroup": "群组", + "moderatorBadge": "⭐ 主持人", + "styleColumn": "风格", + "membersColumn": "成员", + "memberOverflow": "+{{count}} 更多", + "defaultLabel": "群组", + "liveDiscussion": "实时", + "state": { + "CREATED": "已创建", + "IN_PROGRESS": "进行中", + "SYNTHESIZING": "正在合成", + "COMPLETED": "已完成", + "FAILED": "已失败", + "ERROR": "错误", + "AWAITING_APPROVAL": "等待审批" + }, + "preConfiguredTasksCount": "预配置任务 ({{count}})", + "entryType": { + "QUESTION": "提问", + "OPINION": "观点", + "CRITIQUE": "批评", + "REVISION": "修订", + "CHALLENGE": "质疑", + "DEFENSE": "辩护", + "ARGUMENT": "论点", + "REBUTTAL": "反驳", + "SYNTHESIS": "综合", + "ERROR": "错误", + "SKIPPED": "已跳过", + "PLAN": "计划", + "TASK_RESULT": "任务结果", + "VERIFICATION": "验证" + }, + "flow": { + "Opinion": "观点", + "Discussion": "讨论", + "Synthesis": "综合", + "Critique": "批评", + "Revision": "修订", + "Challenge": "质疑", + "Defense": "辩护", + "Independent": "独立", + "AnonymousSharing": "匿名分享", + "Revised": "已修订", + "ProOpening": "正方开场", + "ConOpening": "反方开场", + "Rebuttals": "反驳", + "Judgment": "裁决", + "Plan": "规划", + "Execute": "执行", + "Verify": "验证", + "Synthesize": "总结" + }, + "lifecycle": { + "DISCUSSION_SCOPED": "讨论范围内", + "PERSISTENT": "持久", + "EPHEMERAL": "临时" + }, + "dynamicMax": "✓ (max {{count}})", + "dynamicMaxPerTask": "✓ (最多 {{count}}/任务)" + }, + "taskBoard": { + "title": "任务看板", + "pending": "待处理", + "inProgress": "进行中", + "completed": "已完成", + "verified": "已验证", + "failed": "失败", + "assignedTo": "分配给 {{name}}", + "dependsOn": "依赖于:{{tasks}}", + "emptyTitle": "等待任务计划", + "emptyDescription": "主持人将在讨论开始后创建任务计划。", + "emptyState": "任务计划将在主持人创建后显示在此处", + "unassigned": "未分配", + "progress": "{{done}} / {{total}} 个任务已完成", + "verificationPassed": "通过", + "verificationFailed": "未通过" }, "memories": { "title": "用户记忆", @@ -1644,6 +1735,8 @@ "forecastingDesc": "Delphi-style anonymous deliberation for unbiased estimates.", "proCon": "Pro/Con Debate", "proConDesc": "Formal debate with pro and con teams arguing their positions.", + "taskForce": "任务小组", + "taskForceDesc": "面向目标的任务编排,代理进行规划、并行执行任务、验证结果并综合发现。", "roles": { "marketingExpert": "Marketing Expert", "techLead": "Tech Lead", @@ -1663,7 +1756,11 @@ "proAdvocate1": "Pro Advocate 1", "proAdvocate2": "Pro Advocate 2", "conAdvocate1": "Con Advocate 1", - "conAdvocate2": "Con Advocate 2" + "conAdvocate2": "Con Advocate 2", + "projectLead": "项目负责人", + "researcher": "研究员", + "implementer": "执行者", + "qualityAssurance": "质量保证" } }, "channels": { diff --git a/src/index.css b/src/index.css index dbf25648..4332a986 100644 --- a/src/index.css +++ b/src/index.css @@ -244,4 +244,10 @@ grid-template-columns: repeat(4, 1fr); } } + + /* ── Task Board active card pulse ─────────── */ + @keyframes pulse-border { + 0%, 100% { border-color: var(--color-primary); } + 50% { border-color: color-mix(in oklch, var(--color-primary) 40%, transparent); } + } } diff --git a/src/lib/api/groups.ts b/src/lib/api/groups.ts index 5f2f82b1..7f315824 100644 --- a/src/lib/api/groups.ts +++ b/src/lib/api/groups.ts @@ -9,6 +9,7 @@ export const DISCUSSION_STYLES = [ "DEVIL_ADVOCATE", "DELPHI", "DEBATE", + "TASK_FORCE", "CUSTOM", ] as const; export type DiscussionStyle = (typeof DISCUSSION_STYLES)[number]; @@ -22,6 +23,9 @@ export const PHASE_TYPES = [ "ARGUE", "REBUTTAL", "SYNTHESIS", + "PLAN", + "EXECUTE", + "VERIFY", ] as const; export type PhaseType = (typeof PHASE_TYPES)[number]; @@ -32,7 +36,9 @@ export type ContextScope = | "FULL" | "LAST_PHASE" | "ANONYMOUS" - | "OWN_FEEDBACK"; + | "OWN_FEEDBACK" + | "TASK_ONLY" + | "TASK_WITH_DEPS"; export type MemberType = "AGENT" | "GROUP"; @@ -44,7 +50,8 @@ export type GroupConversationState = | "IN_PROGRESS" | "SYNTHESIZING" | "COMPLETED" - | "FAILED"; + | "FAILED" + | "AWAITING_APPROVAL"; export type TranscriptEntryType = | "QUESTION" @@ -57,7 +64,10 @@ export type TranscriptEntryType = | "REBUTTAL" | "SYNTHESIS" | "ERROR" - | "SKIPPED"; + | "SKIPPED" + | "PLAN" + | "TASK_RESULT" + | "VERIFICATION"; // ─── Data Models ───────────────────────────────────────────────── @@ -85,6 +95,67 @@ export interface ProtocolConfig { onAgentFailure: MemberFailurePolicy; maxRetries: number; onMemberUnavailable: MemberUnavailablePolicy; + maxTurns?: number; +} + +// ─── Task Models ──────────────────────────────────────────────── + +export type TaskStatus = + | "PENDING" + | "ASSIGNED" + | "IN_PROGRESS" + | "COMPLETED" + | "VERIFIED" + | "FAILED" + | "BLOCKED" + | "AWAITING_APPROVAL"; + +export interface TaskItem { + id: string; + subject: string; + description: string; + status: TaskStatus; + assignedAgentId: string | null; + assignedDisplayName: string | null; + dependsOnIds: string[]; + result: string | null; + verificationNote: string | null; + verified: boolean; + priority: number; + createdAt: string; + completedAt: string | null; +} + +export interface SharedTaskList { + tasks: TaskItem[]; +} + +export interface TaskDefinition { + subject: string; + description: string; + assignToRole: string; + dependsOn: string[] | null; + priority: number; +} + +export type LifecyclePolicy = + | "EPHEMERAL" + | "KEEP_DEPLOYED" + | "UNDEPLOY_ONLY" + | "AGENT_DECIDES"; + +export interface DynamicAgentConfig { + enabled: boolean; + allowCreation: boolean; + allowRecruitment: boolean; + allowDelegation: boolean; + maxCreatedAgentsPerDiscussion: number; + maxRecruitedAgentsPerDiscussion: number; + maxDelegationsPerTask: number; + allowedProviders: string[]; + allowedModels: Record; + inheritParentModel: boolean; + lifecyclePolicy: LifecyclePolicy; } export interface AgentGroupConfiguration { @@ -96,6 +167,10 @@ export interface AgentGroupConfiguration { maxRounds: number; phases: DiscussionPhase[] | null; protocol: ProtocolConfig | null; + /** Pre-configured tasks for TASK_FORCE style (skips PLAN phase) */ + tasks?: TaskDefinition[]; + /** Dynamic agent creation and recruitment configuration */ + dynamicAgents?: DynamicAgentConfig; } export interface TranscriptEntry { @@ -122,6 +197,14 @@ export interface GroupConversation { currentPhaseName: string | null; synthesizedAnswer: string | null; depth: number; + /** Task list for TASK_FORCE style discussions */ + taskList: SharedTaskList | null; + /** Agents dynamically added during the discussion */ + dynamicMembers: GroupMember[]; + /** Agent IDs created during this discussion (for lifecycle cleanup) */ + createdAgentIds: string[]; + /** Agent IDs retained by creators (agent-decides policy) */ + retainedAgentIds: string[]; created: string; lastModified: string; } @@ -257,21 +340,26 @@ export type GroupSSEEventType = | "phase_complete" | "synthesis_start" | "group_complete" - | "group_error"; + | "group_error" + | "task_plan_created" + | "task_verified"; export interface GroupSSEEvent { type: GroupSSEEventType; data: string; } -/** Parsed event payloads for convenience */ +/** Parsed event payloads for convenience. + * Field names match the backend's GroupStartEvent Java record. */ export interface GroupStartPayload { - conversationId: string; + groupConversationId: string; + /** @deprecated Use groupConversationId — kept for backwards compatibility */ + conversationId?: string; groupId: string; question: string; style: string; - phaseCount: number; - agentIds: string[]; + totalPhases: number; + memberAgentIds: string[]; } export interface PhaseStartPayload { @@ -297,6 +385,9 @@ export interface SpeakerCompletePayload { content?: string; phaseIndex: number; phaseName: string; + /** Peer-targeted phase: the agent this response was aimed at */ + targetAgentId?: string; + targetDisplayName?: string; } export interface PhaseCompletePayload { @@ -317,6 +408,18 @@ export interface GroupErrorPayload { error: string; } +export interface TaskPlanCreatedPayload { + tasks: { id: string; subject: string; assignedTo: string; assignedAgentId?: string; priority: number }[]; + preConfigured: boolean; +} + +export interface TaskVerifiedPayload { + taskId: string; + taskSubject: string; + passed: boolean; + feedback: string; +} + /** * Start a group discussion via SSE streaming. * Returns an async generator yielding SSE events as they arrive. @@ -357,7 +460,8 @@ export async function* streamGroupDiscussion( const { done, value } = await reader.read(); if (done) break; - buffer += decoder.decode(value, { stream: true }); + // Normalise CRLF → LF so the split works regardless of server line-ending style + buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, "\n"); // Parse SSE lines: "event: \ndata: \n\n" const parts = buffer.split("\n\n"); @@ -365,7 +469,7 @@ export async function* streamGroupDiscussion( for (const part of parts) { if (!part.trim()) continue; - let eventType: GroupSSEEventType = "group_start"; + let eventType: GroupSSEEventType | null = null; let eventData = ""; for (const line of part.split("\n")) { @@ -377,7 +481,8 @@ export async function* streamGroupDiscussion( } } - if (eventData || eventType) { + // Only yield events with an explicit event: type (skip bare data-only chunks) + if (eventType) { yield { type: eventType, data: eventData }; } } @@ -455,6 +560,7 @@ export type EnrichedGroupDescriptor = GroupDescriptor & { version: number; memberCount: number; style?: DiscussionStyle; + members: { displayName: string; memberType?: MemberType }[]; }; /** @@ -482,11 +588,16 @@ export async function getEnrichedGroupDescriptors( description: config.description || g.description, memberCount: config.members?.length ?? 0, style: config.style, + members: (config.members ?? []).map((m) => ({ + displayName: m.displayName, + memberType: m.memberType, + })), } satisfies EnrichedGroupDescriptor; } catch { return { ...g, memberCount: 0, + members: [], } satisfies EnrichedGroupDescriptor; } }) @@ -525,6 +636,11 @@ export const STYLE_INFO: Record< flow: "Pro Opening → Con Opening → Rebuttals → Judgment", icon: "⚖️", }, + TASK_FORCE: { + label: "Task Force", + flow: "Plan → Execute → Verify → Synthesize", + icon: "🎯", + }, CUSTOM: { label: "Custom", flow: "User-defined phases", @@ -548,6 +664,9 @@ export const ENTRY_TYPE_INFO: Record< SYNTHESIS: { label: "Synthesis", color: "gold" }, ERROR: { label: "Error", color: "destructive" }, SKIPPED: { label: "Skipped", color: "muted" }, + PLAN: { label: "Plan", color: "sky" }, + TASK_RESULT: { label: "Task Result", color: "emerald" }, + VERIFICATION: { label: "Verification", color: "amber" }, }; // ─── Bulk Operations ───────────────────────────────────────────── diff --git a/src/lib/group-templates.ts b/src/lib/group-templates.ts index b99ce16b..a8a2f65d 100644 --- a/src/lib/group-templates.ts +++ b/src/lib/group-templates.ts @@ -102,6 +102,21 @@ export function getGroupTemplates(t: TFunction): GroupTemplate[] { ], moderatorSuggested: true, }, + { + key: "task-force", + name: t("groupTemplates.taskForce"), + description: t("groupTemplates.taskForceDesc"), + icon: "🎯", + style: "TASK_FORCE", + maxRounds: 1, + roles: [ + { displayName: t("groupTemplates.roles.projectLead"), role: "Lead" }, + { displayName: t("groupTemplates.roles.researcher"), role: "Research" }, + { displayName: t("groupTemplates.roles.implementer"), role: "Implementation" }, + { displayName: t("groupTemplates.roles.qualityAssurance"), role: "QA" }, + ], + moderatorSuggested: true, + }, ]; } diff --git a/src/pages/__tests__/group-detail.test.tsx b/src/pages/__tests__/group-detail.test.tsx index 57d975ed..98912af5 100644 --- a/src/pages/__tests__/group-detail.test.tsx +++ b/src/pages/__tests__/group-detail.test.tsx @@ -62,6 +62,9 @@ describe("GroupDetailPage", () => { }); it("shows no discussions message when empty", async () => { + server.use( + http.get("*/groups/:groupId/conversations", () => HttpResponse.json([])) + ); renderGroupDetail(); await waitFor(() => { @@ -72,6 +75,9 @@ describe("GroupDetailPage", () => { }); it("shows ask below hint when no discussions", async () => { + server.use( + http.get("*/groups/:groupId/conversations", () => HttpResponse.json([])) + ); renderGroupDetail(); await waitFor(() => { @@ -204,7 +210,7 @@ describe("GroupDetailPage", () => { renderGroupDetail(); await waitFor(() => { - expect(screen.getByText("COMPLETED")).toBeInTheDocument(); + expect(screen.getByText("Completed")).toBeInTheDocument(); }); }); @@ -302,4 +308,81 @@ describe("GroupDetailPage", () => { const heading = screen.getByRole("heading", { level: 1 }); expect(heading).toHaveTextContent("Simple Group"); }); + + // ─── Conversation state display ──────────────────────────────────── + + it("conversation state shows translated label instead of raw state", async () => { + server.use( + http.get("*/groups/:groupId/conversations", () => { + return HttpResponse.json([ + { + id: "conv-state-1", + originalQuestion: "Test translated state", + state: "IN_PROGRESS", + created: Date.now(), + }, + ]); + }) + ); + + renderGroupDetail(); + + await waitFor(() => { + // Should show "In Progress" (translated label) not "IN_PROGRESS" (raw state) + expect(screen.getByText("In Progress")).toBeInTheDocument(); + expect(screen.queryByText("IN_PROGRESS")).not.toBeInTheDocument(); + }); + }); + + it("conversation state has colored dot indicator", async () => { + server.use( + http.get("*/groups/:groupId/conversations", () => { + return HttpResponse.json([ + { + id: "conv-dot-1", + originalQuestion: "Test dot indicator", + state: "COMPLETED", + created: Date.now(), + }, + ]); + }) + ); + + renderGroupDetail(); + + await waitFor(() => { + // The dot indicator has data-testid="state-dot-{convId}" + const dot = screen.getByTestId("state-dot-conv-dot-1"); + expect(dot).toBeInTheDocument(); + // The dot should have a rounded-full class (visual indicator) + expect(dot.className).toContain("rounded-full"); + }); + }); + + it("conversation state uses STATE_CONFIG for label and color", async () => { + server.use( + http.get("*/groups/:groupId/conversations", () => { + return HttpResponse.json([ + { + id: "conv-cfg-1", + originalQuestion: "Test STATE_CONFIG usage", + state: "FAILED", + created: Date.now(), + }, + ]); + }) + ); + + renderGroupDetail(); + + await waitFor(() => { + // STATE_CONFIG.FAILED.label is "Failed" + expect(screen.getByText("Failed")).toBeInTheDocument(); + // The state container has data-testid="discussion-state-{convId}" + const stateEl = screen.getByTestId("discussion-state-conv-cfg-1"); + expect(stateEl).toBeInTheDocument(); + // Should have the destructive color class from STATE_CONFIG.FAILED.color + expect(stateEl.className).toContain("text-destructive"); + }); + }); }); diff --git a/src/pages/__tests__/groups.test.tsx b/src/pages/__tests__/groups.test.tsx index fe8b3cdd..777d03eb 100644 --- a/src/pages/__tests__/groups.test.tsx +++ b/src/pages/__tests__/groups.test.tsx @@ -130,8 +130,8 @@ describe("GroupsPage", () => { await waitFor(() => { expect(screen.getByText("Name")).toBeInTheDocument(); - expect(screen.getByText("ID")).toBeInTheDocument(); - expect(screen.getByText("Version")).toBeInTheDocument(); + expect(screen.getByText("Style")).toBeInTheDocument(); + expect(screen.getByText("Members")).toBeInTheDocument(); expect(screen.getByText("Modified")).toBeInTheDocument(); expect(screen.getByText("Actions")).toBeInTheDocument(); }); @@ -459,4 +459,166 @@ describe("GroupsPage", () => { expect(links.length).toBeGreaterThanOrEqual(1); }); }); + + // ─── Table view columns & accessibility ───────────────────────────── + + it("table view shows Style column header", async () => { + renderPage(); + const user = userEvent.setup(); + + await waitFor( + () => { + expect( + screen.getAllByTestId(/^group-card-/).length + ).toBeGreaterThanOrEqual(1); + }, + { timeout: 10000 } + ); + + await user.click(screen.getByTestId("view-toggle-list")); + + await waitFor(() => { + expect(screen.getByText("Style")).toBeInTheDocument(); + }); + }); + + it("table view shows Members column header", async () => { + renderPage(); + const user = userEvent.setup(); + + await waitFor( + () => { + expect( + screen.getAllByTestId(/^group-card-/).length + ).toBeGreaterThanOrEqual(1); + }, + { timeout: 10000 } + ); + + await user.click(screen.getByTestId("view-toggle-list")); + + await waitFor(() => { + expect(screen.getByText("Members")).toBeInTheDocument(); + }); + }); + + it("table view shows style badge with icon for each group", async () => { + renderPage(); + const user = userEvent.setup(); + + await waitFor( + () => { + expect( + screen.getAllByTestId(/^group-card-/).length + ).toBeGreaterThanOrEqual(1); + }, + { timeout: 10000 } + ); + + await user.click(screen.getByTestId("view-toggle-list")); + + await waitFor(() => { + // Style badges have data-testid="group-style-{id}" + const styleBadges = screen.getAllByTestId(/^group-style-/); + expect(styleBadges.length).toBeGreaterThanOrEqual(1); + // Each badge should have an aria-label like "Style: Round Table" + expect(styleBadges[0]!).toHaveAttribute("aria-label", expect.stringContaining("Style")); + }); + }); + + it("table view shows member avatars with initials", async () => { + renderPage(); + const user = userEvent.setup(); + + await waitFor( + () => { + expect( + screen.getAllByTestId(/^group-card-/).length + ).toBeGreaterThanOrEqual(1); + }, + { timeout: 10000 } + ); + + await user.click(screen.getByTestId("view-toggle-list")); + + await waitFor(() => { + // Member avatar containers have data-testid="group-members-{id}" + const memberContainers = screen.getAllByTestId(/^group-members-/); + expect(memberContainers.length).toBeGreaterThanOrEqual(1); + // Each container should have role="list" with member listitems + expect(memberContainers[0]!).toHaveAttribute("role", "list"); + // Member avatars show initials (single uppercase characters) + const items = memberContainers[0]!.querySelectorAll("[role='listitem']"); + expect(items.length).toBeGreaterThanOrEqual(1); + }); + }); + + it("table rows have data-testid attributes", async () => { + renderPage(); + const user = userEvent.setup(); + + await waitFor( + () => { + expect( + screen.getAllByTestId(/^group-card-/).length + ).toBeGreaterThanOrEqual(1); + }, + { timeout: 10000 } + ); + + await user.click(screen.getByTestId("view-toggle-list")); + + await waitFor(() => { + const rows = screen.getAllByTestId(/^group-row-/); + expect(rows.length).toBeGreaterThanOrEqual(1); + }); + }); + + it("table rows have role='link' for accessibility", async () => { + renderPage(); + const user = userEvent.setup(); + + await waitFor( + () => { + expect( + screen.getAllByTestId(/^group-card-/).length + ).toBeGreaterThanOrEqual(1); + }, + { timeout: 10000 } + ); + + await user.click(screen.getByTestId("view-toggle-list")); + + await waitFor(() => { + const rows = screen.getAllByTestId(/^group-row-/); + expect(rows.length).toBeGreaterThanOrEqual(1); + for (const row of rows) { + expect(row).toHaveAttribute("role", "link"); + } + }); + }); + + it("table rows have tabIndex for keyboard accessibility", async () => { + renderPage(); + const user = userEvent.setup(); + + await waitFor( + () => { + expect( + screen.getAllByTestId(/^group-card-/).length + ).toBeGreaterThanOrEqual(1); + }, + { timeout: 10000 } + ); + + await user.click(screen.getByTestId("view-toggle-list")); + + await waitFor(() => { + const rows = screen.getAllByTestId(/^group-row-/); + expect(rows.length).toBeGreaterThanOrEqual(1); + for (const row of rows) { + expect(row).toHaveAttribute("tabindex", "0"); + } + }); + }); }); diff --git a/src/pages/group-detail.tsx b/src/pages/group-detail.tsx index c4e9f004..6a00bf60 100644 --- a/src/pages/group-detail.tsx +++ b/src/pages/group-detail.tsx @@ -28,12 +28,16 @@ import { STYLE_INFO, type DiscussionStyle, type AgentGroupConfiguration } from " import { STYLE_THEME } from "@/components/groups/discussion-transcript"; import { safeFormatDate } from "@/components/groups/group-utils"; -const STATE_COLORS: Record = { - COMPLETED: "text-emerald-500", - IN_PROGRESS: "text-amber-500", - SYNTHESIZING: "text-amber-500", - FAILED: "text-destructive", - CREATED: "text-muted-foreground", +const DEFAULT_STATE = { label: "Created", color: "text-muted-foreground", dot: "bg-muted-foreground" } as const; + +const STATE_CONFIG: Record = { + COMPLETED: { label: "Completed", color: "text-emerald-500", dot: "bg-emerald-500" }, + IN_PROGRESS: { label: "In Progress", color: "text-amber-500", dot: "bg-amber-500" }, + SYNTHESIZING: { label: "Synthesizing", color: "text-amber-500", dot: "bg-amber-500" }, + FAILED: { label: "Failed", color: "text-destructive", dot: "bg-destructive" }, + CREATED: DEFAULT_STATE, + AWAITING_APPROVAL: { label: "Awaiting Approval", color: "text-orange-500", dot: "bg-orange-500" }, + ERROR: { label: "Error", color: "text-destructive", dot: "bg-destructive" }, }; export function GroupDetailPage() { @@ -90,16 +94,21 @@ export function GroupDetailPage() { toast.success(t("groups.discussionStarted", "Discussion started — streaming live")); }, [groupId, startStream, t]); - // When streaming completes, select the new conversation and refresh the list + // Invalidate conversation list when stream starts (so the new entry appears in sidebar) + // AND when it completes (so the state updates to COMPLETED) useEffect(() => { - if (streamState.state === "COMPLETED" && streamState.conversationId && !streamState.isStreaming) { + if ( + streamState.conversationId && + groupId && + (streamState.state === "IN_PROGRESS" || streamState.state === "COMPLETED") + ) { + queryClient.invalidateQueries({ queryKey: ["groupConversations", groupId] }); + } + // Auto-select the completed conversation + if (streamState.state === "COMPLETED" && streamState.conversationId) { setSelectedConvId(streamState.conversationId); - // M2 fix: refresh conversation list so sidebar shows the new discussion - if (groupId) { - queryClient.invalidateQueries({ queryKey: ["groupConversations", groupId] }); - } } - }, [streamState.state, streamState.conversationId, streamState.isStreaming, groupId, queryClient]); + }, [streamState.state, streamState.conversationId, groupId, queryClient]); function handleDeleteConversation(convId: string) { if (!groupId) return; @@ -168,19 +177,45 @@ export function GroupDetailPage() { onClick={() => handleSelectConversation(conv.id)} className={cn( "w-full text-start rounded-lg px-3 py-2 transition-all group/item", - selectedConvId === conv.id && !isStreamActive + streamState.isStreaming && conv.id === streamState.conversationId ? "bg-primary/10 border border-primary/30" - : "hover:bg-secondary/50 border border-transparent" + : selectedConvId === conv.id && !isStreamActive + ? "bg-primary/10 border border-primary/30" + : "hover:bg-secondary/50 border border-transparent" )} + aria-current={ + (streamState.isStreaming && conv.id === streamState.conversationId) || + (selectedConvId === conv.id && !isStreamActive) + ? true + : undefined + } data-testid={`discussion-item-${conv.id}`} >

{conv.originalQuestion}

- - {conv.state} - + {(() => { + const cfg = STATE_CONFIG[conv.state] ?? DEFAULT_STATE; + const isLive = streamState.isStreaming && conv.id === streamState.conversationId; + return ( + + + {isLive + ? t("groups.liveDiscussion", "Live") + : t(`groups.state.${conv.state}`, cfg.label) + } + + ); + })()} {safeFormatDate(conv.created, "date")} @@ -191,6 +226,7 @@ export function GroupDetailPage() { }} className="ms-auto opacity-0 group-hover/item:opacity-100 rounded p-0.5 text-muted-foreground hover:text-destructive transition-all" title={t("common.delete")} + aria-label={t("common.delete")} > diff --git a/src/pages/group-wizard.tsx b/src/pages/group-wizard.tsx index 2c24a8ac..9ab0aad9 100644 --- a/src/pages/group-wizard.tsx +++ b/src/pages/group-wizard.tsx @@ -128,6 +128,7 @@ const STYLE_COLORS: Record - {t("common.id", "ID")} + {t("groups.styleColumn", "Style")} - {t("common.version", "Version")} + {t("groups.membersColumn", "Members")} {t("common.modified", "Modified")} @@ -191,26 +194,65 @@ export function GroupsPage() { {groupedGroups.map((group) => ( navigate(`/manage/groups/${group.id}?version=${group.version}`)} + onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); navigate(`/manage/groups/${group.id}?version=${group.version}`); } }} + tabIndex={0} + role="link" + data-testid={`group-row-${group.id}`} > e.stopPropagation()} > {group.name || t("groups.unnamed", "Unnamed Group")} - - {group.id.slice(0, 12)}… - + {(() => { + const info = group.style ? STYLE_INFO[group.style] : null; + return info ? ( + + + {info.label} + + ) : ( + + ); + })()} - - v{group.version} - +
m.displayName).join(", ")} + role="list" + aria-label={t("groups.membersColumn", "Members")} + data-testid={`group-members-${group.id}`} + > +
+ {(group.members ?? []).slice(0, 3).map((m, i) => ( + + {m.displayName.charAt(0).toUpperCase()} + + ))} +
+ + {group.members?.length || group.memberCount} + +
@@ -220,16 +262,18 @@ export function GroupsPage() {
diff --git a/src/test/mocks/handlers.ts b/src/test/mocks/handlers.ts index c3ce43cf..71bf398a 100644 --- a/src/test/mocks/handlers.ts +++ b/src/test/mocks/handlers.ts @@ -1864,7 +1864,7 @@ export const handlers = [ // --- Group Store Mock Handlers --- http.get("*/groupstore/groups/descriptors", () => { - return HttpResponse.json([ + const groups = [ { resource: "eddi://ai.labs.group/groupstore/groups/grp1?version=1", name: "Product Review Panel", @@ -1886,7 +1886,43 @@ export const handlers = [ createdOn: Date.now() - 259200000, lastModifiedOn: Date.now() - 7200000, }, - ]); + { + resource: "eddi://ai.labs.group/groupstore/groups/grp4?version=1", + name: "Market Forecast Panel", + description: "Delphi-style anonymous forecasting for quarterly predictions", + createdOn: Date.now() - 432000000, + lastModifiedOn: Date.now() - 14400000, + }, + { + resource: "eddi://ai.labs.group/groupstore/groups/grp5?version=3", + name: "Feature Prioritization Debate", + description: "Pro/con debate on which features to prioritize for next release", + createdOn: Date.now() - 604800000, + lastModifiedOn: Date.now() - 43200000, + }, + { + resource: "eddi://ai.labs.group/groupstore/groups/grp6?version=1", + name: "Security Audit Task Force", + description: "Coordinated task force to plan and execute a full security audit", + createdOn: Date.now() - 345600000, + lastModifiedOn: Date.now() - 1800000, + }, + { + resource: "eddi://ai.labs.group/groupstore/groups/grp7?version=2", + name: "Customer Feedback Analysis", + description: "Round table review of customer feedback themes and action items", + createdOn: Date.now() - 518400000, + lastModifiedOn: Date.now() - 86400000, + }, + { + resource: "eddi://ai.labs.group/groupstore/groups/grp8?version=1", + name: "Compliance Risk Review", + description: "Devil's advocate challenge of compliance assumptions across departments", + createdOn: Date.now() - 691200000, + lastModifiedOn: Date.now() - 172800000, + }, + ]; + return HttpResponse.json(groups); }), http.get("*/groupstore/groups/styles", () => { @@ -1896,6 +1932,7 @@ export const handlers = [ DEVIL_ADVOCATE: { label: "Devil's Advocate", phases: ["OPINION", "CHALLENGE", "DEFENSE", "SYNTHESIS"] }, DELPHI: { label: "Delphi", phases: ["OPINION", "REVISION", "SYNTHESIS"] }, DEBATE: { label: "Debate", phases: ["ARGUE", "REBUTTAL", "SYNTHESIS"] }, + TASK_FORCE: { label: "Task Force", phases: ["PLAN", "EXECUTE", "VERIFY", "SYNTHESIS"] }, CUSTOM: { label: "Custom", phases: [] }, }); }), @@ -1916,29 +1953,134 @@ export const handlers = [ }); }), - http.get("*/groupstore/groups/:id", () => { - return HttpResponse.json({ - name: "Product Review Panel", - description: "Peer-review discussion for product decisions", - members: [ - { agentId: "agent1", displayName: "Support Agent", speakingOrder: 1, role: "Reviewer", memberType: "AGENT" }, - { agentId: "agent2", displayName: "FAQ Agent", speakingOrder: 2, role: "Critic", memberType: "AGENT" }, - ], - moderatorAgentId: "agent1", - style: "PEER_REVIEW", - maxRounds: 3, - phases: [ - { name: "Initial Opinions", type: "OPINION", participants: "*", turnOrder: "SEQUENTIAL", contextScope: "NONE", targetEachPeer: false, inputTemplate: null, repeats: 1 }, - { name: "Critique", type: "CRITIQUE", participants: "*", turnOrder: "SEQUENTIAL", contextScope: "FULL", targetEachPeer: true, inputTemplate: null, repeats: 1 }, - { name: "Synthesis", type: "SYNTHESIS", participants: "moderator", turnOrder: "SEQUENTIAL", contextScope: "FULL", targetEachPeer: false, inputTemplate: null, repeats: 1 }, - ], - protocol: { - agentTimeoutSeconds: 60, - onAgentFailure: "SKIP", - maxRetries: 2, - onMemberUnavailable: "SKIP", + http.get("*/groupstore/groups/:id", ({ params }) => { + const groupConfigs: Record = { + grp1: { + name: "Product Review Panel", + description: "Peer-review discussion for product decisions", + members: [ + { agentId: "agent1", displayName: "Support Agent", speakingOrder: 1, role: "Reviewer", memberType: "AGENT" }, + { agentId: "agent2", displayName: "FAQ Agent", speakingOrder: 2, role: "Critic", memberType: "AGENT" }, + ], + moderatorAgentId: "agent1", + style: "PEER_REVIEW", + maxRounds: 3, + phases: [ + { name: "Initial Opinions", type: "OPINION", participants: "*", turnOrder: "SEQUENTIAL", contextScope: "NONE", targetEachPeer: false, inputTemplate: null, repeats: 1 }, + { name: "Critique", type: "CRITIQUE", participants: "*", turnOrder: "SEQUENTIAL", contextScope: "FULL", targetEachPeer: true, inputTemplate: null, repeats: 1 }, + { name: "Synthesis", type: "SYNTHESIS", participants: "moderator", turnOrder: "SEQUENTIAL", contextScope: "FULL", targetEachPeer: false, inputTemplate: null, repeats: 1 }, + ], + protocol: { agentTimeoutSeconds: 60, onAgentFailure: "SKIP", maxRetries: 2, onMemberUnavailable: "SKIP" }, }, - }); + grp2: { + name: "Strategy Debate", + description: "Devil's advocate debate on business strategy", + members: [ + { agentId: "agent3", displayName: "Market Analyst", speakingOrder: 1, role: "Risk", memberType: "AGENT" }, + { agentId: "agent4", displayName: "Growth Strategist", speakingOrder: 2, role: "Domain", memberType: "AGENT" }, + { agentId: "agent5", displayName: "Contrarian", speakingOrder: 3, role: "DEVIL_ADVOCATE", memberType: "AGENT" }, + ], + moderatorAgentId: "agent3", + style: "DEVIL_ADVOCATE", + maxRounds: 1, + phases: null, + protocol: { agentTimeoutSeconds: 60, onAgentFailure: "SKIP", maxRetries: 2, onMemberUnavailable: "SKIP" }, + }, + grp3: { + name: "Research Round Table", + description: "Open discussion for research synthesis", + members: [ + { agentId: "agent6", displayName: "Data Scientist", speakingOrder: 1, role: "Analysis", memberType: "AGENT" }, + { agentId: "agent7", displayName: "Domain Expert", speakingOrder: 2, role: "Context", memberType: "AGENT" }, + { agentId: "agent8", displayName: "Research Lead", speakingOrder: 3, role: "Synthesis", memberType: "AGENT" }, + { agentId: "agent9", displayName: "Peer Reviewer", speakingOrder: 4, role: "Validation", memberType: "AGENT" }, + ], + moderatorAgentId: "agent8", + style: "ROUND_TABLE", + maxRounds: 2, + phases: null, + protocol: { agentTimeoutSeconds: 60, onAgentFailure: "SKIP", maxRetries: 2, onMemberUnavailable: "SKIP" }, + }, + grp4: { + name: "Market Forecast Panel", + description: "Delphi-style anonymous forecasting for quarterly predictions", + members: [ + { agentId: "agent10", displayName: "Economist A", speakingOrder: 1, role: "Forecasting", memberType: "AGENT" }, + { agentId: "agent11", displayName: "Economist B", speakingOrder: 2, role: "Forecasting", memberType: "AGENT" }, + { agentId: "agent12", displayName: "Industry Analyst", speakingOrder: 3, role: "Forecasting", memberType: "AGENT" }, + { agentId: "agent13", displayName: "Risk Modeler", speakingOrder: 4, role: "Forecasting", memberType: "AGENT" }, + { agentId: "agent14", displayName: "Trend Spotter", speakingOrder: 5, role: "Forecasting", memberType: "AGENT" }, + ], + moderatorAgentId: "agent10", + style: "DELPHI", + maxRounds: 3, + phases: null, + protocol: { agentTimeoutSeconds: 90, onAgentFailure: "SKIP", maxRetries: 3, onMemberUnavailable: "SKIP" }, + }, + grp5: { + name: "Feature Prioritization Debate", + description: "Pro/con debate on which features to prioritize for next release", + members: [ + { agentId: "agent15", displayName: "Product Advocate", speakingOrder: 1, role: "PRO", memberType: "AGENT" }, + { agentId: "agent16", displayName: "UX Champion", speakingOrder: 2, role: "PRO", memberType: "AGENT" }, + { agentId: "agent17", displayName: "Tech Debt Guardian", speakingOrder: 3, role: "CON", memberType: "AGENT" }, + { agentId: "agent18", displayName: "Budget Hawk", speakingOrder: 4, role: "CON", memberType: "AGENT" }, + ], + moderatorAgentId: null, + style: "DEBATE", + maxRounds: 1, + phases: null, + protocol: { agentTimeoutSeconds: 60, onAgentFailure: "SKIP", maxRetries: 2, onMemberUnavailable: "SKIP" }, + }, + grp6: { + name: "Security Audit Task Force", + description: "Coordinated task force to plan and execute a full security audit", + members: [ + { agentId: "agent19", displayName: "Security Lead", speakingOrder: 1, role: "Lead", memberType: "AGENT" }, + { agentId: "agent20", displayName: "Penetration Tester", speakingOrder: 2, role: "Research", memberType: "AGENT" }, + { agentId: "agent21", displayName: "Infrastructure Eng", speakingOrder: 3, role: "Implementation", memberType: "AGENT" }, + { agentId: "agent22", displayName: "Compliance Officer", speakingOrder: 4, role: "QA", memberType: "AGENT" }, + ], + moderatorAgentId: "agent19", + style: "TASK_FORCE", + maxRounds: 1, + phases: null, + protocol: { agentTimeoutSeconds: 120, onAgentFailure: "RETRY", maxRetries: 3, onMemberUnavailable: "SKIP" }, + }, + grp7: { + name: "Customer Feedback Analysis", + description: "Round table review of customer feedback themes and action items", + members: [ + { agentId: "agent23", displayName: "CX Analyst", speakingOrder: 1, role: "Analysis", memberType: "AGENT" }, + { agentId: "agent24", displayName: "Product Manager", speakingOrder: 2, role: "Prioritization", memberType: "AGENT" }, + { agentId: "grp4", displayName: "Market Forecast Panel", speakingOrder: 3, role: "Context", memberType: "GROUP" }, + ], + moderatorAgentId: "agent24", + style: "ROUND_TABLE", + maxRounds: 2, + phases: null, + protocol: { agentTimeoutSeconds: 60, onAgentFailure: "SKIP", maxRetries: 2, onMemberUnavailable: "SKIP" }, + }, + grp8: { + name: "Compliance Risk Review", + description: "Devil's advocate challenge of compliance assumptions across departments", + members: [ + { agentId: "agent25", displayName: "Legal Advisor", speakingOrder: 1, role: "Compliance", memberType: "AGENT" }, + { agentId: "agent26", displayName: "Risk Manager", speakingOrder: 2, role: "Risk", memberType: "AGENT" }, + { agentId: "agent27", displayName: "Operations Head", speakingOrder: 3, role: "Operations", memberType: "AGENT" }, + { agentId: "agent28", displayName: "External Auditor", speakingOrder: 4, role: "DEVIL_ADVOCATE", memberType: "AGENT" }, + { agentId: "grp2", displayName: "Strategy Debate", speakingOrder: 5, role: "Strategy", memberType: "GROUP" }, + ], + moderatorAgentId: "agent25", + style: "DEVIL_ADVOCATE", + maxRounds: 2, + phases: null, + protocol: { agentTimeoutSeconds: 60, onAgentFailure: "SKIP", maxRetries: 2, onMemberUnavailable: "SKIP" }, + }, + }; + const id = params.id as string; + const config = groupConfigs[id] ?? groupConfigs.grp1; + return HttpResponse.json(config); }), http.post("*/groupstore/groups", () => { @@ -1967,8 +2109,46 @@ export const handlers = [ }), // Group conversations - http.get("*/groups/:groupId/conversations", () => { - return HttpResponse.json([]); + http.get("*/groups/:groupId/conversations", ({ params }) => { + const now = Date.now(); + const groupConversations: Record = { + grp1: [ + { id: "gc-1a", groupId: "grp1", userId: "admin", state: "COMPLETED", originalQuestion: "Review the new onboarding flow — is it production-ready?", created: new Date(now - 86400000).toISOString(), lastModified: new Date(now - 82800000).toISOString() }, + { id: "gc-1b", groupId: "grp1", userId: "admin", state: "COMPLETED", originalQuestion: "Assess the mobile checkout redesign for UX consistency", created: new Date(now - 604800000).toISOString(), lastModified: new Date(now - 601200000).toISOString() }, + { id: "gc-1c", groupId: "grp1", userId: "admin", state: "FAILED", originalQuestion: "Evaluate the new API rate limiting strategy", created: new Date(now - 172800000).toISOString(), lastModified: new Date(now - 169200000).toISOString() }, + ], + grp2: [ + { id: "gc-2a", groupId: "grp2", userId: "admin", state: "COMPLETED", originalQuestion: "Should we pivot to a product-led growth strategy?", created: new Date(now - 259200000).toISOString(), lastModified: new Date(now - 255600000).toISOString() }, + { id: "gc-2b", groupId: "grp2", userId: "admin", state: "COMPLETED", originalQuestion: "Is expanding into the enterprise segment worth the R&D cost?", created: new Date(now - 432000000).toISOString(), lastModified: new Date(now - 428400000).toISOString() }, + ], + grp3: [ + { id: "gc-3a", groupId: "grp3", userId: "admin", state: "COMPLETED", originalQuestion: "What are the key findings from last quarter's user research?", created: new Date(now - 345600000).toISOString(), lastModified: new Date(now - 342000000).toISOString() }, + { id: "gc-3b", groupId: "grp3", userId: "admin", state: "IN_PROGRESS", originalQuestion: "Synthesize competitor analysis data from the 3 latest reports", created: new Date(now - 3600000).toISOString(), lastModified: new Date(now - 1800000).toISOString() }, + ], + grp4: [ + { id: "gc-4a", groupId: "grp4", userId: "admin", state: "COMPLETED", originalQuestion: "Forecast Q3 revenue across our three business lines", created: new Date(now - 518400000).toISOString(), lastModified: new Date(now - 514800000).toISOString() }, + { id: "gc-4b", groupId: "grp4", userId: "admin", state: "COMPLETED", originalQuestion: "What is the probability of a market correction in the next 6 months?", created: new Date(now - 691200000).toISOString(), lastModified: new Date(now - 687600000).toISOString() }, + { id: "gc-4c", groupId: "grp4", userId: "admin", state: "COMPLETED", originalQuestion: "Estimate customer churn rate for enterprise tier in Q4", created: new Date(now - 864000000).toISOString(), lastModified: new Date(now - 860400000).toISOString() }, + ], + grp5: [ + { id: "gc-5a", groupId: "grp5", userId: "admin", state: "COMPLETED", originalQuestion: "Should we prioritize real-time collaboration or offline mode?", created: new Date(now - 172800000).toISOString(), lastModified: new Date(now - 169200000).toISOString() }, + ], + grp6: [ + { id: "gc-6a", groupId: "grp6", userId: "admin", state: "COMPLETED", originalQuestion: "Plan and execute a comprehensive security audit of our auth system", created: new Date(now - 259200000).toISOString(), lastModified: new Date(now - 252000000).toISOString() }, + { id: "gc-6b", groupId: "grp6", userId: "admin", state: "AWAITING_APPROVAL", originalQuestion: "Audit the new payment processing microservice for PCI compliance", created: new Date(now - 43200000).toISOString(), lastModified: new Date(now - 36000000).toISOString() }, + ], + grp7: [ + { id: "gc-7a", groupId: "grp7", userId: "admin", state: "COMPLETED", originalQuestion: "What are the top 5 customer pain points from this month's feedback?", created: new Date(now - 604800000).toISOString(), lastModified: new Date(now - 601200000).toISOString() }, + { id: "gc-7b", groupId: "grp7", userId: "admin", state: "COMPLETED", originalQuestion: "Analyze NPS trends and identify actionable improvements", created: new Date(now - 1209600000).toISOString(), lastModified: new Date(now - 1206000000).toISOString() }, + ], + grp8: [ + { id: "gc-8a", groupId: "grp8", userId: "admin", state: "COMPLETED", originalQuestion: "Challenge our GDPR compliance assumptions for the new analytics pipeline", created: new Date(now - 432000000).toISOString(), lastModified: new Date(now - 428400000).toISOString() }, + { id: "gc-8b", groupId: "grp8", userId: "admin", state: "COMPLETED", originalQuestion: "Review SOC 2 readiness across all departments", created: new Date(now - 864000000).toISOString(), lastModified: new Date(now - 860400000).toISOString() }, + { id: "gc-8c", groupId: "grp8", userId: "admin", state: "CREATED", originalQuestion: "Assess data retention policy compliance with new EU regulations", created: new Date(now - 7200000).toISOString(), lastModified: new Date(now - 7200000).toISOString() }, + ], + }; + const id = params.groupId as string; + return HttpResponse.json(groupConversations[id] ?? []); }), http.post("*/groups/:groupId/conversations", ({ params }) => { @@ -1984,6 +2164,10 @@ export const handlers = [ currentPhaseName: "Initial Opinions", synthesizedAnswer: null, depth: 0, + taskList: null, + dynamicMembers: [], + createdAgentIds: [], + retainedAgentIds: [], created: new Date().toISOString(), lastModified: new Date().toISOString(), }); @@ -3410,6 +3594,10 @@ export const scheduleHandlers = [ currentPhaseName: "Synthesis", synthesizedAnswer: "After careful consideration, the panel recommends a phased European market expansion starting in Q3.", depth: 0, + taskList: null, + dynamicMembers: [], + createdAgentIds: [], + retainedAgentIds: [], created: new Date(Date.now() - 3600000).toISOString(), lastModified: new Date(Date.now() - 1800000).toISOString(), }, @@ -3441,6 +3629,10 @@ export const scheduleHandlers = [ currentPhaseName: "Synthesis", synthesizedAnswer: "After careful consideration, the panel recommends a phased European market expansion starting in Q3, with Germany and France as initial targets. Key prerequisites: (1) GDPR compliance and DPO appointment — start immediately, (2) Product localization — 3 month engineering sprint, (3) EU infrastructure provisioning — $50k/month incremental, (4) Local legal counsel engagement. The consensus is to proceed, but not to rush. A well-prepared Q3 soft launch balances opportunity with risk management.", depth: 0, + taskList: null, + dynamicMembers: [], + createdAgentIds: [], + retainedAgentIds: [], created: new Date(now.getTime() - 600000).toISOString(), lastModified: new Date(now.getTime() - 120000).toISOString(), }); @@ -3460,6 +3652,10 @@ export const scheduleHandlers = [ currentPhaseName: "Initial Opinions", synthesizedAnswer: null, depth: 0, + taskList: null, + dynamicMembers: [], + createdAgentIds: [], + retainedAgentIds: [], created: new Date().toISOString(), lastModified: new Date().toISOString(), }, { status: 201 });