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:
Renn F
2026-07-22 03:31:25 +02:00
parent 5ca8a9c4a6
commit a1233b2aeb
18 changed files with 726 additions and 144 deletions
@@ -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&apos;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&apos;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"),
);
});
});
+4 -2
View File
@@ -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);
+134 -62
View File
@@ -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>
);
}
+91 -57
View File
@@ -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&apos;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>
);
+13 -5
View File
@@ -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),
});
+11
View File
@@ -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 });