diff --git a/panel/src/components/dashboard/index.ts b/panel/src/components/dashboard/index.ts
index 2352d619..21c61137 100644
--- a/panel/src/components/dashboard/index.ts
+++ b/panel/src/components/dashboard/index.ts
@@ -9,4 +9,5 @@ export { ActivityItem } from "./activity-item";
export { QuickActionsBar } from "./quick-actions-bar";
export { HealthIndicator } from "./health-indicator";
export { CeoApprovalQueue } from "./ceo-approval-queue";
+export { StrategySignalsPanel } from "./strategy-signals-panel";
export { UsageOverviewPanel } from "./usage-overview-panel";
diff --git a/panel/src/components/dashboard/strategy-signals-panel.tsx b/panel/src/components/dashboard/strategy-signals-panel.tsx
new file mode 100644
index 00000000..7efaaa5a
--- /dev/null
+++ b/panel/src/components/dashboard/strategy-signals-panel.tsx
@@ -0,0 +1,76 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+import { cockpitApi } from "@/lib/api/cockpit";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { Skeleton } from "@/components/ui/skeleton";
+import { TrendingUp } from "lucide-react";
+
+interface StrategySignalsPanelProps {
+ className?: string;
+}
+
+export function StrategySignalsPanel({ className }: StrategySignalsPanelProps) {
+ const { data: signalsData, isLoading } = useQuery({
+ queryKey: ["cockpit", "signals"],
+ queryFn: () => cockpitApi.signals(),
+ refetchInterval: 30000,
+ });
+
+ const signals = signalsData ?? [];
+
+ return (
+
+
+
+
+ Strategy Signals
+
+ Live signals from the strategy engine
+
+
+ {isLoading ? (
+
+
+
+
+ ) : signals.length === 0 ? (
+
+
+
No strategy signals right now
+
+ ) : (
+
+ {signals.map((signal, index) => (
+
+
+
+
+ {signal.kind}
+
+
+
{signal.summary}
+ {signal.detail && (
+
+ {signal.detail}
+
+ )}
+
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/panel/src/components/layout/sidebar.tsx b/panel/src/components/layout/sidebar.tsx
index 732ad578..ddc82236 100644
--- a/panel/src/components/layout/sidebar.tsx
+++ b/panel/src/components/layout/sidebar.tsx
@@ -22,10 +22,7 @@ import {
Database,
Cpu,
Sparkles,
- Target,
- Briefcase,
- Lightbulb,
- Gauge,
+ Building2,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
@@ -34,19 +31,16 @@ import { useUIStore } from "@/store";
export const navItems = [
// Dashboard
{ title: "Overview", href: "/overview", icon: LayoutDashboard },
- { title: "Cockpit", href: "/cockpit", icon: Gauge },
- { title: "Company Goals", href: "/company-goals", icon: Target },
+ { title: "Business", href: "/business", icon: Building2 },
// Work Management
{ title: "Tasks", href: "/tasks", icon: ListTodo },
{ title: "Kanban", href: "/kanban", icon: Kanban },
{ title: "Task Assistant", href: "/prompter", icon: Sparkles },
- { title: "Secretary", href: "/secretary", icon: Briefcase },
// Development
{ title: "Projects", href: "/projects", icon: FolderGit2 },
{ title: "Products", href: "/products", icon: Boxes },
- { title: "Pitches", href: "/pitches", icon: Lightbulb },
{ title: "Git", href: "/git", icon: GitBranch },
// Team & Reference
diff --git a/panel/src/components/ui/required-notes-dialog.tsx b/panel/src/components/ui/required-notes-dialog.tsx
new file mode 100644
index 00000000..33e9c3e1
--- /dev/null
+++ b/panel/src/components/ui/required-notes-dialog.tsx
@@ -0,0 +1,111 @@
+"use client";
+
+import { useState } from "react";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Textarea } from "@/components/ui/textarea";
+import { Label } from "@/components/ui/label";
+
+interface RequiredNotesDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ /** Title shown in the dialog header */
+ title?: string;
+ /** Description shown below the title */
+ description?: string;
+ /** Label for the notes textarea */
+ notesLabel?: string;
+ /** Placeholder text for the textarea */
+ placeholder?: string;
+ /** Called with the entered notes when the user clicks Submit */
+ onSubmit: (notes: string) => void;
+ /** Whether the submit action is currently pending (disables buttons) */
+ isPending?: boolean;
+ /** Label for the submit button */
+ submitLabel?: string;
+}
+
+/**
+ * A dialog that requires the user to enter a non-empty reason / notes before
+ * confirming a destructive or significant action. The Submit button is
+ * disabled while the notes textarea is empty or whitespace-only. Cancel
+ * closes the dialog without invoking `onSubmit`.
+ *
+ * The dialog is keyed on `open` so its internal state resets cleanly on each
+ * open; this avoids a `setState-in-effect` pattern.
+ */
+function RequiredNotesDialogInner({
+ open,
+ onOpenChange,
+ title = "Add a note",
+ description = "Please provide a reason before continuing.",
+ notesLabel = "Notes",
+ placeholder = "Enter your reason…",
+ onSubmit,
+ isPending = false,
+ submitLabel = "Submit",
+}: RequiredNotesDialogProps) {
+ const [notes, setNotes] = useState("");
+
+ const isBlank = notes.trim() === "";
+
+ const handleSubmit = () => {
+ if (isBlank || isPending) return;
+ onSubmit(notes.trim());
+ };
+
+ const handleCancel = () => {
+ onOpenChange(false);
+ };
+
+ return (
+
+ );
+}
+
+/**
+ * Exported wrapper that remounts the inner component each time the dialog
+ * opens, giving us a fresh empty notes field without using setState-in-effect.
+ */
+export function RequiredNotesDialog(props: RequiredNotesDialogProps) {
+ // Using open as the key causes the inner component to remount (and reset its
+ // local state) each time the dialog transitions from closed → open.
+ return
;
+}
diff --git a/panel/src/lib/api/cockpit.ts b/panel/src/lib/api/cockpit.ts
index c4a93a21..ee4f4f7d 100644
--- a/panel/src/lib/api/cockpit.ts
+++ b/panel/src/lib/api/cockpit.ts
@@ -17,7 +17,13 @@ export interface CockpitSummary {
over_budget: boolean;
};
pending_pitches: number;
- signals: { kind: string; summary: string; detail: string }[];
+ signals: CockpitSignal[];
+}
+
+export interface CockpitSignal {
+ kind: string;
+ summary: string;
+ detail: string;
}
export const cockpitApi = {
@@ -26,4 +32,13 @@ export const cockpitApi = {
const { data } = await api.get
("/cockpit/summary");
return data;
},
+
+ // GET /api/cockpit/signals — just the strategy-engine signals (Dashboard panel);
+ // lighter than /summary, which runs the full goals/usage/counts/pitches fan-out.
+ signals: async (): Promise => {
+ const { data } = await api.get<{ signals: CockpitSignal[] }>(
+ "/cockpit/signals"
+ );
+ return data.signals;
+ },
};
diff --git a/roboco/api/routes/cockpit.py b/roboco/api/routes/cockpit.py
index e9ad5964..bf4286fc 100644
--- a/roboco/api/routes/cockpit.py
+++ b/roboco/api/routes/cockpit.py
@@ -3,7 +3,7 @@
from fastapi import APIRouter, HTTPException, status
from roboco.api.deps import CurrentAgentContext, DbSession
-from roboco.api.schemas.cockpit import CockpitSummary
+from roboco.api.schemas.cockpit import CockpitSignals, CockpitSummary
from roboco.models import AgentRole
from roboco.services.cockpit import get_cockpit_service
@@ -29,3 +29,15 @@ async def cockpit_summary(db: DbSession, agent: CurrentAgentContext) -> CockpitS
detail=f"role '{agent.role}' may not view the cockpit",
)
return CockpitSummary(**await get_cockpit_service(db).summary())
+
+
+@router.get("/signals", response_model=CockpitSignals)
+async def cockpit_signals(db: DbSession, agent: CurrentAgentContext) -> CockpitSignals:
+ """Just the strategy-engine signals — the lightweight slice the Dashboard's
+ Strategy Signals panel needs (lighter than ``/summary``, same role gate)."""
+ if agent.role not in _COCKPIT_ROLES:
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail=f"role '{agent.role}' may not view cockpit signals",
+ )
+ return CockpitSignals(**await get_cockpit_service(db).signals())
diff --git a/roboco/api/schemas/cockpit.py b/roboco/api/schemas/cockpit.py
index 307d55c6..4b9e2e95 100644
--- a/roboco/api/schemas/cockpit.py
+++ b/roboco/api/schemas/cockpit.py
@@ -35,3 +35,10 @@ class CockpitSummary(BaseModel):
spend: SpendSummary
pending_pitches: int
signals: list[CockpitSignal]
+
+
+class CockpitSignals(BaseModel):
+ """Just the strategy-engine signals — the Dashboard panel's lightweight slice
+ (avoids the full /summary fan-out: goals / usage / task-counts / pitches)."""
+
+ signals: list[CockpitSignal]
diff --git a/roboco/services/cockpit.py b/roboco/services/cockpit.py
index 884310fd..61d87ca7 100644
--- a/roboco/services/cockpit.py
+++ b/roboco/services/cockpit.py
@@ -74,6 +74,18 @@ class CockpitService(BaseService):
],
}
+ async def signals(self) -> dict[str, Any]:
+ """Just the strategy-engine signals (what needs the CEO) — the lightweight
+ slice the Dashboard's panel needs, without the full ``summary`` fan-out
+ (goals / usage / task-counts / pitches)."""
+ observations = await get_strategy_engine(self.session).assess()
+ return {
+ "signals": [
+ {"kind": o.kind, "summary": o.summary, "detail": o.detail}
+ for o in observations
+ ],
+ }
+
def get_cockpit_service(session: AsyncSession) -> CockpitService:
"""Construct a CockpitService bound to ``session``."""
diff --git a/tests/unit/services/test_cockpit.py b/tests/unit/services/test_cockpit.py
index 5f6fff85..68320e18 100644
--- a/tests/unit/services/test_cockpit.py
+++ b/tests/unit/services/test_cockpit.py
@@ -121,3 +121,35 @@ async def test_route_ok_for_ceo(monkeypatch: pytest.MonkeyPatch) -> None:
resp = await croute.cockpit_summary(MagicMock(), _agent(AgentRole.CEO))
assert resp.basis == "proxy"
assert resp.spend.over_budget is False
+
+
+@pytest.mark.asyncio
+async def test_signals_returns_only_strategy_signals(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ # The lightweight slice returns ONLY the strategy signals — none of the
+ # summary fan-out (goals / spend / counts / pitches).
+ _patch(monkeypatch)
+ out = await CockpitService(MagicMock()).signals()
+ assert list(out.keys()) == ["signals"]
+ assert out["signals"][0]["kind"] == "idle"
+ assert out["signals"][0]["summary"] == "s"
+
+
+@pytest.mark.asyncio
+async def test_signals_route_forbidden_for_developer() -> None:
+ with pytest.raises(HTTPException) as exc:
+ await croute.cockpit_signals(MagicMock(), _agent(AgentRole.DEVELOPER))
+ assert exc.value.status_code == HTTPStatus.FORBIDDEN
+
+
+@pytest.mark.asyncio
+async def test_signals_route_ok_for_ceo(monkeypatch: pytest.MonkeyPatch) -> None:
+ svc = MagicMock(
+ signals=AsyncMock(
+ return_value={"signals": [{"kind": "idle", "summary": "s", "detail": "d"}]}
+ )
+ )
+ monkeypatch.setattr(croute, "get_cockpit_service", lambda _db: svc)
+ resp = await croute.cockpit_signals(MagicMock(), _agent(AgentRole.CEO))
+ assert resp.signals[0].kind == "idle"