mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
fix(panel): V6 review gaps — honest errors, safe secretary start, real tests
CEO A2A mutations now invalidate the Mine-list query key so the list refreshes without the socket; the tg Metrics tab renders explicit error notes instead of confident zero stats when a section's fetch fails; the tg Secretary chat checks a new registry-backed /secretary/live/active route before auto-starting, showing a Take Over button instead of silently killing a session live on another device; the AI-routing card surfaces roster fetch errors instead of an empty grid; the shared acceptance-criteria editor caps at the backend's 7-item limit; the board-tab comment no longer calls the task sheet read-only. Tests: task-sheet approve/reject interactions (not just visibility), a non-demo metrics error-state test, secretary take-over branches, and dashboard-router auth-gate coverage (the e2e harness now mounts the dashboard router so the gate is actually exercised).
This commit is contained in:
@@ -413,6 +413,27 @@ describe("AIRoutingCard", () => {
|
||||
expect(mixSection.querySelector(".animate-pulse")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows an error note (not a silently empty grid) when the roster fetch fails", async () => {
|
||||
useAgentDefinitions.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
});
|
||||
render(withQueryClient(<AIRoutingCard />));
|
||||
await screen.findByText("Per-agent override (mix mode)");
|
||||
|
||||
expect(
|
||||
screen.getByText(/Couldn.t load the agent roster/i),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("heading", { level: 4, name: "Board" }),
|
||||
).not.toBeInTheDocument();
|
||||
const mixSection = screen
|
||||
.getByText("Per-agent override (mix mode)")
|
||||
.closest("section")!;
|
||||
expect(mixSection.querySelector(".animate-pulse")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("tooltip-wraps the Grok/Ollama key labels and status badges, not the raw Switch", async () => {
|
||||
render(withQueryClient(<AIRoutingCard />));
|
||||
await screen.findByText("Grok (xAI) API key");
|
||||
|
||||
@@ -131,7 +131,11 @@ export function AIRoutingCard() {
|
||||
const { data: keyStatus } = useOllamaKey();
|
||||
const { data: snapshot } = useRoutingMode();
|
||||
const { data: selfHostedModels = [] } = useSelfHostedModels();
|
||||
const { data: agentDefs, isLoading: agentsLoading } = useAgentDefinitions();
|
||||
const {
|
||||
data: agentDefs,
|
||||
isLoading: agentsLoading,
|
||||
isError: agentsError,
|
||||
} = useAgentDefinitions();
|
||||
|
||||
const agentGroups = useMemo(
|
||||
() =>
|
||||
@@ -662,7 +666,13 @@ export function AIRoutingCard() {
|
||||
Leave a row blank to inherit from the global mode. Saving overwrites
|
||||
all per-agent overrides with what's picked here.
|
||||
</p>
|
||||
{agentsLoading ? (
|
||||
{agentsError ? (
|
||||
<p className="flex items-center gap-1 rounded-md border p-4 text-xs text-amber-600">
|
||||
<AlertTriangle className="h-3 w-3 shrink-0" />
|
||||
Couldn't load the agent roster — per-agent overrides are
|
||||
unavailable until this reloads.
|
||||
</p>
|
||||
) : agentsLoading ? (
|
||||
<div className="divide-y rounded-md border">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="p-4">
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { AcceptanceCriteriaEditor } from "../acceptance-criteria-editor";
|
||||
|
||||
const sevenCriteria = Array.from({ length: 7 }, (_, i) => `Criterion ${i + 1}`);
|
||||
|
||||
describe("AcceptanceCriteriaEditor — max-7 guard", () => {
|
||||
it("allows adding while under the cap", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<AcceptanceCriteriaEditor
|
||||
criteria={["Criterion 1"]}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
await userEvent.type(
|
||||
screen.getByPlaceholderText(/enter acceptance criterion/i),
|
||||
"Criterion 2",
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: /add/i }));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(["Criterion 1", "Criterion 2"]);
|
||||
// `criteria` is controlled by the parent — unchanged in this render since
|
||||
// the test doesn't re-render with the mutation applied.
|
||||
expect(screen.getByText("1/7 item")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("disables the add control and shows the cap hint at 7 criteria", () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<AcceptanceCriteriaEditor criteria={sevenCriteria} onChange={onChange} />,
|
||||
);
|
||||
|
||||
expect(screen.getByText("7/7 items")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /add/i })).toBeDisabled();
|
||||
expect(
|
||||
screen.getByPlaceholderText(/maximum of 7 criteria reached/i),
|
||||
).toBeDisabled();
|
||||
expect(
|
||||
screen.getAllByText(/maximum of 7 acceptance criteria reached/i).length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("never calls onChange for an 8th criterion even via Enter", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<AcceptanceCriteriaEditor criteria={sevenCriteria} onChange={onChange} />,
|
||||
);
|
||||
|
||||
const input = screen.getByPlaceholderText(/maximum of 7 criteria reached/i);
|
||||
expect(input).toBeDisabled();
|
||||
// A disabled input can't be typed into or submitted — confirms the guard
|
||||
// is enforced at the control, not just the handler.
|
||||
await userEvent.type(input, "Criterion 8");
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -18,16 +18,23 @@ interface AcceptanceCriteriaEditorProps {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Mirrors the backend cap (acceptance_criteria max_length=7 — see
|
||||
// roboco/api/schemas/tasks.py's TaskUpdate and the agent-facing v1 flow
|
||||
// schema). Blocking it here means an 8th criterion never round-trips into a
|
||||
// swallowed 422 on save.
|
||||
const MAX_CRITERIA = 7;
|
||||
|
||||
export function AcceptanceCriteriaEditor({
|
||||
criteria,
|
||||
onChange,
|
||||
error,
|
||||
}: AcceptanceCriteriaEditorProps) {
|
||||
const [newCriterion, setNewCriterion] = useState("");
|
||||
const atMax = criteria.length >= MAX_CRITERIA;
|
||||
|
||||
const handleAdd = () => {
|
||||
const trimmed = newCriterion.trim();
|
||||
if (trimmed && !criteria.includes(trimmed)) {
|
||||
if (trimmed && !criteria.includes(trimmed) && !atMax) {
|
||||
onChange([...criteria, trimmed]);
|
||||
setNewCriterion("");
|
||||
}
|
||||
@@ -59,9 +66,10 @@ export function AcceptanceCriteriaEditor({
|
||||
Acceptance Criteria <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
</HelpTip>
|
||||
<HelpTip label="How many criteria are defined so far — at least one is required to submit.">
|
||||
<HelpTip label="How many criteria are defined so far — at least one is required to submit, at most 7.">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{criteria.length} item{criteria.length !== 1 ? "s" : ""}
|
||||
{criteria.length}/{MAX_CRITERIA} item
|
||||
{criteria.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</HelpTip>
|
||||
</div>
|
||||
@@ -103,25 +111,42 @@ export function AcceptanceCriteriaEditor({
|
||||
|
||||
{/* Add new criterion */}
|
||||
<div className="flex items-center gap-2">
|
||||
<HelpTip label="A specific, testable condition — Enter or Add appends it to the list above.">
|
||||
<HelpTip
|
||||
label={
|
||||
atMax
|
||||
? "Maximum of 7 acceptance criteria reached — remove one to add another."
|
||||
: "A specific, testable condition — Enter or Add appends it to the list above."
|
||||
}
|
||||
>
|
||||
<Input
|
||||
value={newCriterion}
|
||||
onChange={(e) => setNewCriterion(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Enter acceptance criterion and press Enter..."
|
||||
placeholder={
|
||||
atMax
|
||||
? "Maximum of 7 criteria reached"
|
||||
: "Enter acceptance criterion and press Enter..."
|
||||
}
|
||||
disabled={atMax}
|
||||
className="flex-1"
|
||||
/>
|
||||
</HelpTip>
|
||||
<HelpTip label="Appends the text on the left as a new criterion; disabled until you type something.">
|
||||
<HelpTip
|
||||
label={
|
||||
atMax
|
||||
? "Maximum of 7 acceptance criteria reached — remove one to add another."
|
||||
: "Appends the text on the left as a new criterion; disabled until you type something."
|
||||
}
|
||||
>
|
||||
<span
|
||||
className="inline-block"
|
||||
tabIndex={!newCriterion.trim() ? 0 : undefined}
|
||||
tabIndex={!newCriterion.trim() || atMax ? 0 : undefined}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleAdd}
|
||||
disabled={!newCriterion.trim()}
|
||||
disabled={!newCriterion.trim() || atMax}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add
|
||||
@@ -132,8 +157,9 @@ export function AcceptanceCriteriaEditor({
|
||||
|
||||
{/* Helper text */}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Define at least one acceptance criterion. Each criterion should describe
|
||||
a specific, testable condition for task completion.
|
||||
{atMax
|
||||
? "Maximum of 7 acceptance criteria reached — remove one to add another."
|
||||
: "Define at least one acceptance criterion. Each criterion should describe a specific, testable condition for task completion."}
|
||||
</p>
|
||||
|
||||
{/* Error message */}
|
||||
|
||||
@@ -1,9 +1,29 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { TgChatTab } from "../tg-chat-tab";
|
||||
|
||||
const { secretaryState, startMock } = vi.hoisted(() => ({
|
||||
secretaryState: { sessionId: null as string | null },
|
||||
startMock: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/hooks/use-secretary", () => ({
|
||||
useSecretary: () => ({
|
||||
sessionId: secretaryState.sessionId,
|
||||
messages: [],
|
||||
streaming: false,
|
||||
start: startMock,
|
||||
send: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const { isActiveMock } = vi.hoisted(() => ({ isActiveMock: vi.fn() }));
|
||||
vi.mock("@/lib/api/secretary", () => ({
|
||||
secretaryApi: { isActive: isActiveMock },
|
||||
}));
|
||||
|
||||
const { mineItems, fleetItems, messages, sendMock, replyMock, markReadMock } =
|
||||
vi.hoisted(() => ({
|
||||
mineItems: { current: [] as Array<Record<string, unknown>> },
|
||||
@@ -121,6 +141,10 @@ beforeEach(() => {
|
||||
sendMock.mockReset();
|
||||
replyMock.mockReset();
|
||||
markReadMock.mockReset();
|
||||
secretaryState.sessionId = null;
|
||||
startMock.mockReset().mockResolvedValue("s1");
|
||||
isActiveMock.mockReset();
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
describe("TgChatTab — list", () => {
|
||||
@@ -212,3 +236,45 @@ describe("TgChatTab — threads", () => {
|
||||
expect(screen.queryByPlaceholderText("Message…")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TgChatTab — Secretary cross-device preemption", () => {
|
||||
it("offers Take over instead of auto-starting when a session is live elsewhere", async () => {
|
||||
isActiveMock.mockResolvedValue(true);
|
||||
renderTab();
|
||||
|
||||
await userEvent.click(screen.getByText("Secretary"));
|
||||
|
||||
expect(
|
||||
await screen.findByText(/live on another device/i),
|
||||
).toBeInTheDocument();
|
||||
expect(startMock).not.toHaveBeenCalled();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /take over/i }));
|
||||
expect(startMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("auto-starts as before when nothing is live elsewhere", async () => {
|
||||
isActiveMock.mockResolvedValue(false);
|
||||
renderTab();
|
||||
|
||||
await userEvent.click(screen.getByText("Secretary"));
|
||||
|
||||
await waitFor(() => expect(startMock).toHaveBeenCalled());
|
||||
expect(
|
||||
screen.queryByText(/live on another device/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("auto-starts without checking isActive when this device has its own persisted session", async () => {
|
||||
window.localStorage.setItem(
|
||||
"roboco:secretary:live",
|
||||
JSON.stringify({ sessionId: "old", messages: [], savedAt: Date.now() }),
|
||||
);
|
||||
renderTab();
|
||||
|
||||
await userEvent.click(screen.getByText("Secretary"));
|
||||
|
||||
await waitFor(() => expect(startMock).toHaveBeenCalled());
|
||||
expect(isActiveMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,44 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { TgMetricsTab } from "../tg-metrics-tab";
|
||||
|
||||
vi.mock("@/lib/telegram/demo", () => ({ isTgDemoMode: () => true }));
|
||||
// A mutable ref (not a bare boolean) so a later describe block can flip it to
|
||||
// false for the non-demo error-state tests without a second module factory.
|
||||
const { demoMode } = vi.hoisted(() => ({ demoMode: { current: true } }));
|
||||
vi.mock("@/lib/telegram/demo", () => ({ isTgDemoMode: () => demoMode.current }));
|
||||
// Scorecard resolution needs the roster only outside demo mode (the demo
|
||||
// scorecard fixture returns unconditionally) — an empty roster keeps this
|
||||
// hook off the network without affecting anything the tests assert on.
|
||||
vi.mock("@/hooks/use-agents", () => ({ useAgents: () => ({ data: [] }) }));
|
||||
|
||||
const usageMocks = vi.hoisted(() => ({
|
||||
getUsageSummary: vi.fn(),
|
||||
getUsageTimeSeries: vi.fn(),
|
||||
getAgentUsage: vi.fn(),
|
||||
getTeamUsage: vi.fn(),
|
||||
getModelUsage: vi.fn(),
|
||||
getCacheEfficiency: vi.fn(),
|
||||
getUsageProjection: vi.fn(),
|
||||
getSpawnWaste: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/api/usage", () => ({ usageApi: usageMocks }));
|
||||
|
||||
const observabilityMocks = vi.hoisted(() => ({
|
||||
getRework: vi.fn(),
|
||||
getCycleTime: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/api/observability", () => ({
|
||||
observabilityApi: observabilityMocks,
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
demoMode.current = true;
|
||||
for (const m of Object.values(usageMocks)) m.mockReset();
|
||||
for (const m of Object.values(observabilityMocks)) m.mockReset();
|
||||
});
|
||||
|
||||
function renderTab() {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
@@ -89,3 +118,58 @@ describe("TgMetricsTab", () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TgMetricsTab — section error states (non-demo)", () => {
|
||||
beforeEach(() => {
|
||||
demoMode.current = false;
|
||||
usageMocks.getUsageSummary.mockResolvedValue({
|
||||
tokens_input: 0,
|
||||
tokens_output: 0,
|
||||
total_tokens: 1000,
|
||||
total_cost_usd: 10,
|
||||
trend_pct: 0,
|
||||
period: "7d",
|
||||
});
|
||||
usageMocks.getUsageTimeSeries.mockResolvedValue([]);
|
||||
usageMocks.getAgentUsage.mockResolvedValue([]);
|
||||
usageMocks.getTeamUsage.mockResolvedValue([]);
|
||||
usageMocks.getModelUsage.mockResolvedValue([]);
|
||||
usageMocks.getCacheEfficiency.mockResolvedValue({
|
||||
cache_hit_rate: 0.42,
|
||||
tokens_cache_read: 0,
|
||||
tokens_cache_write: 0,
|
||||
tokens_input: 0,
|
||||
cost_saved_by_cache_usd: 3,
|
||||
period: "7d",
|
||||
});
|
||||
usageMocks.getUsageProjection.mockResolvedValue({
|
||||
total_cost_7d: 10,
|
||||
avg_daily_cost_usd: 1,
|
||||
projected_monthly_cost_usd: 30,
|
||||
basis_days: 7,
|
||||
});
|
||||
usageMocks.getSpawnWaste.mockResolvedValue({
|
||||
total_spawns: 10,
|
||||
unproductive_spawns: 1,
|
||||
unproductive_pct: 10,
|
||||
by_role: [],
|
||||
respawn_strikes: [],
|
||||
period: "7d",
|
||||
});
|
||||
// The Delivery section's own fetch fails while everything else succeeds.
|
||||
observabilityMocks.getRework.mockRejectedValue(new Error("network"));
|
||||
observabilityMocks.getCycleTime.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("shows an inline error note for Delivery instead of a 0%-backed rework tile", async () => {
|
||||
renderTab();
|
||||
|
||||
expect(await screen.findByText("$10.00")).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText(/Couldn.t load delivery metrics/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText("Rework rate")).not.toBeInTheDocument();
|
||||
// Efficiency loaded fine — its own tiles still render real numbers.
|
||||
expect(screen.getByText("42%")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,19 +1,30 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render as rtlRender, screen } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render as rtlRender, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { TgTaskSheet } from "../tg-task-sheet";
|
||||
import type { Task } from "@/types";
|
||||
import type { TaskFindingsResponse } from "@/lib/api/tasks";
|
||||
|
||||
const { findings } = vi.hoisted(() => ({
|
||||
const { findings, ceoApprove, ceoReject, unblock } = vi.hoisted(() => ({
|
||||
findings: vi.fn<() => { data: TaskFindingsResponse | undefined }>(() => ({
|
||||
data: undefined,
|
||||
})),
|
||||
ceoApprove: vi.fn(),
|
||||
ceoReject: vi.fn(),
|
||||
unblock: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/hooks/use-tasks", () => ({
|
||||
useTaskFindings: findings,
|
||||
taskKeys: { all: ["tasks"] },
|
||||
}));
|
||||
vi.mock("@/lib/api/tasks", () => ({
|
||||
tasksApi: { ceoApprove, ceoReject, unblock },
|
||||
}));
|
||||
vi.mock("sonner", () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
// The sheet's CEO action block mutates through react-query.
|
||||
function render(ui: React.ReactElement) {
|
||||
@@ -71,6 +82,14 @@ function task(overrides: Partial<Task> = {}): Task {
|
||||
} as Task;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
ceoApprove.mockReset();
|
||||
ceoReject.mockReset();
|
||||
unblock.mockReset();
|
||||
vi.mocked(toast.success).mockClear();
|
||||
vi.mocked(toast.error).mockClear();
|
||||
});
|
||||
|
||||
describe("TgTaskSheet", () => {
|
||||
it("renders nothing without a task", () => {
|
||||
render(<TgTaskSheet task={null} onClose={vi.fn()} />);
|
||||
@@ -173,3 +192,70 @@ describe("TgTaskSheet", () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TgTaskSheet — CEO action interactions", () => {
|
||||
it("clicking Approve calls ceoApprove with the task id and toasts success", async () => {
|
||||
ceoApprove.mockResolvedValue({});
|
||||
render(<TgTaskSheet task={task()} onClose={vi.fn()} />);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Approve" }));
|
||||
|
||||
expect(ceoApprove).toHaveBeenCalledWith("t1");
|
||||
await waitFor(() =>
|
||||
expect(toast.success).toHaveBeenCalledWith("Approved"),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the error toast when approve's promise rejects", async () => {
|
||||
ceoApprove.mockRejectedValue(new Error("Approve failed"));
|
||||
render(<TgTaskSheet task={task()} onClose={vi.fn()} />);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Approve" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(toast.error).toHaveBeenCalledWith("Approve failed"),
|
||||
);
|
||||
expect(ceoReject).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps Send back for revision disabled under the 10-char reason floor", async () => {
|
||||
render(<TgTaskSheet task={task()} onClose={vi.fn()} />);
|
||||
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "Request changes" }),
|
||||
);
|
||||
const textarea = screen.getByPlaceholderText(/at least 10 characters/i);
|
||||
const sendBack = screen.getByRole("button", {
|
||||
name: "Send back for revision",
|
||||
});
|
||||
expect(sendBack).toBeDisabled();
|
||||
|
||||
await userEvent.type(textarea, "too short");
|
||||
expect(sendBack).toBeDisabled();
|
||||
expect(ceoReject).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("enables Send back at 10+ chars and calls ceoReject with the id and reason", async () => {
|
||||
ceoReject.mockResolvedValue({});
|
||||
render(<TgTaskSheet task={task()} onClose={vi.fn()} />);
|
||||
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "Request changes" }),
|
||||
);
|
||||
await userEvent.type(
|
||||
screen.getByPlaceholderText(/at least 10 characters/i),
|
||||
"Please redo the retry backoff",
|
||||
);
|
||||
const sendBack = screen.getByRole("button", {
|
||||
name: "Send back for revision",
|
||||
});
|
||||
expect(sendBack).toBeEnabled();
|
||||
|
||||
await userEvent.click(sendBack);
|
||||
|
||||
expect(ceoReject).toHaveBeenCalledWith("t1", "Please redo the retry backoff");
|
||||
await waitFor(() =>
|
||||
expect(toast.success).toHaveBeenCalledWith("Sent back for revision"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -216,8 +216,10 @@ function DoneSection({
|
||||
}
|
||||
|
||||
/** Cockpit Board tab — every task grouped by lifecycle stage, tapping any
|
||||
* row opens the read-only task sheet. Demo mode swaps in the canned
|
||||
* fixture list, lazily imported so it stays out of the prod bundle. */
|
||||
* row opens the task sheet (which carries the CEO's own decide verbs —
|
||||
* approve / request changes / unblock — when a task is waiting on them).
|
||||
* Demo mode swaps in the canned fixture list, lazily imported so it stays
|
||||
* out of the prod bundle. */
|
||||
export function TgBoardTab() {
|
||||
const [selected, setSelected] = useState<Task | null>(null);
|
||||
const [demoTasks, setDemoTasks] = useState<Task[] | undefined>(undefined);
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
} from "@/hooks/use-a2a-live";
|
||||
import { useA2ALiveStream } from "@/hooks/use-websocket";
|
||||
import { useSecretary, type ChatMessage } from "@/hooks/use-secretary";
|
||||
import { secretaryApi } from "@/lib/api/secretary";
|
||||
import { CEO_SLUG } from "@/components/a2a/a2a-utils";
|
||||
import { AgentSelector } from "@/components/agents/agent-selector";
|
||||
import { EXCLUDE_NON_DM_ROLES } from "@/components/a2a/a2a-new-dm-dialog";
|
||||
@@ -167,20 +168,69 @@ function SecretaryPinnedRow({ onOpen }: { onOpen: () => void }) {
|
||||
);
|
||||
}
|
||||
|
||||
// Mirrors useSecretary's own localStorage key (panel/src/hooks/use-secretary.ts,
|
||||
// PERSIST_KEY) — read-only here, just to tell "this device has nothing of
|
||||
// its own to restore" apart from "a restore is still resolving" before this
|
||||
// view decides whether it's safe to auto-start.
|
||||
const SECRETARY_PERSIST_KEY = "roboco:secretary:live";
|
||||
|
||||
function hasPersistedSecretarySession(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
try {
|
||||
return window.localStorage.getItem(SECRETARY_PERSIST_KEY) !== null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function SecretaryView({ onBack }: { onBack: () => void }) {
|
||||
const demo = isTgDemoMode();
|
||||
const { sessionId, messages, streaming, start, send, stop } = useSecretary();
|
||||
const shown = demo ? DEMO_SECRETARY : messages;
|
||||
|
||||
// One live session per view — start() restores a persisted one when the
|
||||
// hook finds it, so re-entering the chat resumes rather than respawns.
|
||||
// The Secretary is a backend singleton (one container at a time) — a
|
||||
// device with nothing of its own to restore auto-starting anyway would
|
||||
// silently preempt a live session on another device. So this view only
|
||||
// auto-starts unconditionally when it has a session of its own to resume
|
||||
// (unchanged from before); a genuinely fresh device checks the singleton
|
||||
// first and offers an explicit take-over instead of blind-starting.
|
||||
const [activeElsewhere, setActiveElsewhere] = useState(false);
|
||||
const startedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (demo || startedRef.current || sessionId) return;
|
||||
startedRef.current = true;
|
||||
start().catch((err) => toast.error(getErrorMessage(err)));
|
||||
if (hasPersistedSecretarySession()) {
|
||||
startedRef.current = true;
|
||||
start().catch((err) => toast.error(getErrorMessage(err)));
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const goAhead = () => {
|
||||
if (cancelled || startedRef.current) return;
|
||||
startedRef.current = true;
|
||||
void start().catch((err) => toast.error(getErrorMessage(err)));
|
||||
};
|
||||
secretaryApi
|
||||
.isActive()
|
||||
.then((active) => {
|
||||
if (cancelled) return;
|
||||
if (active) setActiveElsewhere(true);
|
||||
else goAhead();
|
||||
})
|
||||
// A failed status check shouldn't strand a fresh device with neither
|
||||
// a chat nor a takeover button — fall back to the prior behavior.
|
||||
.catch(goAhead);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [demo, sessionId, start]);
|
||||
|
||||
const takeOver = () => {
|
||||
haptics.tap();
|
||||
setActiveElsewhere(false);
|
||||
startedRef.current = true;
|
||||
void start().catch((err) => toast.error(getErrorMessage(err)));
|
||||
};
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const count = shown.length;
|
||||
useEffect(() => {
|
||||
@@ -211,70 +261,92 @@ function SecretaryView({ onBack }: { onBack: () => void }) {
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="max-h-[58dvh] space-y-1.5 overflow-y-auto pb-1"
|
||||
>
|
||||
{shown.length === 0 && (
|
||||
<p className="py-10 text-center text-sm text-muted-foreground">
|
||||
{demo || sessionId
|
||||
? "Ask anything: company state, queues, directives."
|
||||
: "Waking the Secretary…"}
|
||||
{activeElsewhere && !demo && !sessionId ? (
|
||||
<div className="flex flex-col items-center gap-3 py-10 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
A Secretary session is live on another device.
|
||||
</p>
|
||||
)}
|
||||
{shown.map((m, i) => (
|
||||
<div
|
||||
key={i}
|
||||
<button
|
||||
type="button"
|
||||
onClick={takeOver}
|
||||
className={cn(
|
||||
"flex",
|
||||
m.role === "user" ? "justify-end" : "justify-start",
|
||||
"rounded-full bg-primary px-4 py-2 text-[15px] font-semibold text-primary-foreground",
|
||||
TG_PRESS,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-[82%] rounded-2xl px-3.5 py-2 text-[15px] leading-relaxed",
|
||||
m.role === "user"
|
||||
? "rounded-br-md bg-primary text-primary-foreground"
|
||||
: "rounded-bl-md bg-card",
|
||||
)}
|
||||
>
|
||||
{m.role === "user" ? (
|
||||
<p className="whitespace-pre-wrap break-words">{m.text}</p>
|
||||
) : (
|
||||
<Markdown
|
||||
compact
|
||||
className="prose prose-sm prose-invert max-w-none [&_p]:my-1 first:[&_p]:mt-0 last:[&_p]:mb-0"
|
||||
Take over
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="max-h-[58dvh] space-y-1.5 overflow-y-auto pb-1"
|
||||
>
|
||||
{shown.length === 0 && (
|
||||
<p className="py-10 text-center text-sm text-muted-foreground">
|
||||
{demo || sessionId
|
||||
? "Ask anything: company state, queues, directives."
|
||||
: "Waking the Secretary…"}
|
||||
</p>
|
||||
)}
|
||||
{shown.map((m, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
"flex",
|
||||
m.role === "user" ? "justify-end" : "justify-start",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-[82%] rounded-2xl px-3.5 py-2 text-[15px] leading-relaxed",
|
||||
m.role === "user"
|
||||
? "rounded-br-md bg-primary text-primary-foreground"
|
||||
: "rounded-bl-md bg-card",
|
||||
)}
|
||||
>
|
||||
{m.text || "…"}
|
||||
</Markdown>
|
||||
)}
|
||||
</div>
|
||||
{m.role === "user" ? (
|
||||
<p className="whitespace-pre-wrap break-words">
|
||||
{m.text}
|
||||
</p>
|
||||
) : (
|
||||
<Markdown
|
||||
compact
|
||||
className="prose prose-sm prose-invert max-w-none [&_p]:my-1 first:[&_p]:mt-0 last:[&_p]:mb-0"
|
||||
>
|
||||
{m.text || "…"}
|
||||
</Markdown>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{streaming && shown[shown.length - 1]?.role === "user" && (
|
||||
<div className="flex justify-start">
|
||||
<div className="rounded-2xl rounded-bl-md bg-card px-3.5 py-2.5">
|
||||
<span className="inline-flex gap-1">
|
||||
{[0, 1, 2].map((d) => (
|
||||
<span
|
||||
key={d}
|
||||
className="h-1.5 w-1.5 animate-pulse rounded-full bg-muted-foreground/60"
|
||||
style={{ animationDelay: `${d * 150}ms` }}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{streaming && shown[shown.length - 1]?.role === "user" && (
|
||||
<div className="flex justify-start">
|
||||
<div className="rounded-2xl rounded-bl-md bg-card px-3.5 py-2.5">
|
||||
<span className="inline-flex gap-1">
|
||||
{[0, 1, 2].map((d) => (
|
||||
<span
|
||||
key={d}
|
||||
className="h-1.5 w-1.5 animate-pulse rounded-full bg-muted-foreground/60"
|
||||
style={{ animationDelay: `${d * 150}ms` }}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Composer
|
||||
placeholder={demo ? "Demo mode, sends disabled" : "Message…"}
|
||||
pending={streaming}
|
||||
disabled={demo || (!demo && !sessionId)}
|
||||
onSend={(text) => {
|
||||
void send(text).catch((err) => toast.error(getErrorMessage(err)));
|
||||
}}
|
||||
/>
|
||||
<Composer
|
||||
placeholder={demo ? "Demo mode, sends disabled" : "Message…"}
|
||||
pending={streaming}
|
||||
disabled={demo || (!demo && !sessionId)}
|
||||
onSend={(text) => {
|
||||
void send(text).catch((err) => toast.error(getErrorMessage(err)));
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</TgSubPage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -219,6 +219,19 @@ function pctOrDash(v: number | null): string {
|
||||
// SHARED SUBCOMPONENTS
|
||||
// =============================================================================
|
||||
|
||||
/** Inline note for a section whose own query failed while the rest of the
|
||||
* hub loaded fine — without this, a failed agentsQ/teamsQ/modelsQ/deliveryQ/
|
||||
* efficiencyQ silently rendered its zero-backed empty state (e.g. "0% Rework
|
||||
* rate"), indistinguishable from a real zero. */
|
||||
function SectionErrorNote({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 py-2 text-xs text-muted-foreground">
|
||||
<Warning className="h-3.5 w-3.5 shrink-0 text-amber-500" />
|
||||
<span>Couldn't load {label}.</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorCard({ onRetry }: { onRetry: () => void }) {
|
||||
return (
|
||||
<div
|
||||
@@ -383,7 +396,9 @@ function Hub({
|
||||
/>
|
||||
|
||||
<TgSection title="By agent">
|
||||
{topAgents.length === 0 ? (
|
||||
{agentsQ.isError ? (
|
||||
<SectionErrorNote label="agent spend" />
|
||||
) : topAgents.length === 0 ? (
|
||||
<p className="py-2 text-sm text-muted-foreground">
|
||||
No agent spend yet.
|
||||
</p>
|
||||
@@ -418,7 +433,9 @@ function Hub({
|
||||
</TgSection>
|
||||
|
||||
<TgSection title="By team">
|
||||
{teamRows.length === 0 ? (
|
||||
{teamsQ.isError ? (
|
||||
<SectionErrorNote label="team spend" />
|
||||
) : teamRows.length === 0 ? (
|
||||
<p className="py-2 text-sm text-muted-foreground">
|
||||
No team spend yet.
|
||||
</p>
|
||||
@@ -435,7 +452,9 @@ function Hub({
|
||||
</TgSection>
|
||||
|
||||
<TgSection title="By model">
|
||||
{modelRows.length === 0 ? (
|
||||
{modelsQ.isError ? (
|
||||
<SectionErrorNote label="model spend" />
|
||||
) : modelRows.length === 0 ? (
|
||||
<p className="py-2 text-sm text-muted-foreground">
|
||||
No model spend yet.
|
||||
</p>
|
||||
@@ -452,64 +471,79 @@ function Hub({
|
||||
</TgSection>
|
||||
|
||||
<TgSection title="Delivery">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<TgStat
|
||||
value={`${((rework?.rate ?? 0) * 100).toFixed(0)}%`}
|
||||
caption="Rework rate"
|
||||
tone={(rework?.rate ?? 0) > 0.2 ? "attention" : "default"}
|
||||
/>
|
||||
<TgStat value={rework?.total_completed ?? 0} caption="Completed" />
|
||||
<TgStat
|
||||
value={worstStage ? humanizeHours(worstStage.avg_seconds) : "-"}
|
||||
caption={
|
||||
worstStage ? humanizeStatus(worstStage.status) : "Slowest stage"
|
||||
}
|
||||
/>
|
||||
<TgStat
|
||||
value={fmtUsd(rework?.rework_cost_usd ?? 0)}
|
||||
caption="Rework cost"
|
||||
/>
|
||||
</div>
|
||||
{bounced.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
{bounced.map((a) => (
|
||||
<span
|
||||
key={a.agent_slug}
|
||||
className="rounded-full bg-muted px-2 py-1 text-xs text-muted-foreground"
|
||||
>
|
||||
{getAgentDisplayName(a.agent_slug)} · {a.bounces} bounces
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{deliveryQ.isError ? (
|
||||
<SectionErrorNote label="delivery metrics" />
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<TgStat
|
||||
value={`${((rework?.rate ?? 0) * 100).toFixed(0)}%`}
|
||||
caption="Rework rate"
|
||||
tone={(rework?.rate ?? 0) > 0.2 ? "attention" : "default"}
|
||||
/>
|
||||
<TgStat
|
||||
value={rework?.total_completed ?? 0}
|
||||
caption="Completed"
|
||||
/>
|
||||
<TgStat
|
||||
value={worstStage ? humanizeHours(worstStage.avg_seconds) : "-"}
|
||||
caption={
|
||||
worstStage
|
||||
? humanizeStatus(worstStage.status)
|
||||
: "Slowest stage"
|
||||
}
|
||||
/>
|
||||
<TgStat
|
||||
value={fmtUsd(rework?.rework_cost_usd ?? 0)}
|
||||
caption="Rework cost"
|
||||
/>
|
||||
</div>
|
||||
{bounced.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
{bounced.map((a) => (
|
||||
<span
|
||||
key={a.agent_slug}
|
||||
className="rounded-full bg-muted px-2 py-1 text-xs text-muted-foreground"
|
||||
>
|
||||
{getAgentDisplayName(a.agent_slug)} · {a.bounces} bounces
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TgSection>
|
||||
|
||||
<TgSection title="Efficiency">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<TgStat
|
||||
value={`${((efficiency?.cache.cache_hit_rate ?? 0) * 100).toFixed(0)}%`}
|
||||
caption="Cache hit rate"
|
||||
/>
|
||||
<TgStat
|
||||
value={fmtUsd(efficiency?.cache.cost_saved_by_cache_usd ?? 0)}
|
||||
caption="Saved by cache"
|
||||
/>
|
||||
<TgStat
|
||||
value={fmtUsd(
|
||||
efficiency?.projection.projected_monthly_cost_usd ?? 0,
|
||||
)}
|
||||
caption="Projected monthly"
|
||||
/>
|
||||
<TgStat
|
||||
value={`${(efficiency?.spawnWaste.unproductive_pct ?? 0).toFixed(0)}%`}
|
||||
caption="Spawn waste"
|
||||
tone={
|
||||
(efficiency?.spawnWaste.unproductive_pct ?? 0) > 25
|
||||
? "attention"
|
||||
: "default"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{efficiencyQ.isError ? (
|
||||
<SectionErrorNote label="efficiency metrics" />
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<TgStat
|
||||
value={`${((efficiency?.cache.cache_hit_rate ?? 0) * 100).toFixed(0)}%`}
|
||||
caption="Cache hit rate"
|
||||
/>
|
||||
<TgStat
|
||||
value={fmtUsd(efficiency?.cache.cost_saved_by_cache_usd ?? 0)}
|
||||
caption="Saved by cache"
|
||||
/>
|
||||
<TgStat
|
||||
value={fmtUsd(
|
||||
efficiency?.projection.projected_monthly_cost_usd ?? 0,
|
||||
)}
|
||||
caption="Projected monthly"
|
||||
/>
|
||||
<TgStat
|
||||
value={`${(efficiency?.spawnWaste.unproductive_pct ?? 0).toFixed(0)}%`}
|
||||
caption="Spawn waste"
|
||||
tone={
|
||||
(efficiency?.spawnWaste.unproductive_pct ?? 0) > 25
|
||||
? "attention"
|
||||
: "default"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</TgSection>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -18,8 +18,9 @@ export const a2aLiveKeys = {
|
||||
|
||||
// Conversation list — refreshed by WS `a2a.message` invalidation and the
|
||||
// manual Refresh button; a short staleTime keeps remounts reasonably fresh.
|
||||
// `refetchInterval` is the caller's poll fallback for when the /ws/system
|
||||
// socket is down (the desktop view gates it on the live-stream connection).
|
||||
// `refetchInterval` is an unconditional poll the caller drives at a faster
|
||||
// cadence while /ws/system is down (the desktop view: 20s connected / 8s
|
||||
// disconnected) — it never gates off entirely, only speeds up.
|
||||
export function useA2AConversations(
|
||||
limit?: number,
|
||||
enabled = true,
|
||||
@@ -73,9 +74,10 @@ export function useA2AAdminPairs() {
|
||||
|
||||
// Transcript for one conversation. WS frames for the selected conversation
|
||||
// invalidate this key; full bodies always come from REST (excerpts are capped).
|
||||
// `refetchInterval` defaults to off (the desktop A2A page relies on WS
|
||||
// invalidation instead) — the /tg Mini App chat tab has no WS wiring, so it
|
||||
// passes a ~10s interval to poll the thread it's actively viewing.
|
||||
// `refetchInterval` defaults to off; the desktop A2A page passes the same
|
||||
// unconditional poll conversations get (20s connected / 8s disconnected —
|
||||
// never gated off), while the /tg Mini App chat tab gates its own ~10s poll
|
||||
// off entirely whenever its WS is connected or demo mode is active.
|
||||
export function useA2AMessages(
|
||||
conversationId: string | null,
|
||||
options?: { refetchInterval?: number | false; enabled?: boolean },
|
||||
@@ -120,6 +122,9 @@ export function useCreateCeoConversation() {
|
||||
a2aApi.createConversation(request),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: a2aLiveKeys.conversations });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: a2aLiveKeys.ceoConversations,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -138,6 +143,9 @@ export function useSendCeoMessage() {
|
||||
a2aApi.sendCeoMessage(conversationId, content),
|
||||
onSuccess: (_sent, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: a2aLiveKeys.conversations });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: a2aLiveKeys.ceoConversations,
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: a2aLiveKeys.messages(variables.conversationId),
|
||||
});
|
||||
|
||||
@@ -77,6 +77,17 @@ export const secretaryApi = {
|
||||
return data;
|
||||
},
|
||||
|
||||
/** Is a Secretary session live right now under ANY session id — lets a
|
||||
* device with none of its own tell "live elsewhere" apart from "nothing
|
||||
* running" before it would otherwise auto-start a competing one against
|
||||
* the single-container singleton. */
|
||||
isActive: async (): Promise<boolean> => {
|
||||
const { data } = await api.get<{ active: boolean }>(
|
||||
"/secretary/live/active",
|
||||
);
|
||||
return data.active;
|
||||
},
|
||||
|
||||
/** Deliver the CEO's message to the running Secretary; the reply streams back. */
|
||||
sendMessage: async (sessionId: string, text: string): Promise<void> => {
|
||||
await api.post(`/secretary/live/${sessionId}/messages`, { text });
|
||||
|
||||
Reference in New Issue
Block a user