From 17576597547311eddaf6602351b5e44b6243a284 Mon Sep 17 00:00:00 2001 From: Renzo F <45401804+rennf93@users.noreply.github.com> Date: Tue, 16 Jun 2026 08:40:51 +0200 Subject: [PATCH] [27208d92] Consolidate Cockpit/Goals/Secretary/Pitches into a Business page (#184) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [0c66b856] Frontend: Build tabbed Business page consolidating Goals/Secretary/Pitches (#183) * [c9f00d0d] feat(business): add /business tabbed page consolidating Goals, Secretary, Pitches (#182) - Create src/app/(dashboard)/business/page.tsx with URL-driven Tabs (goals|secretary|pitches), reading ?tab= via useSearchParams; defaults to 'goals' - Create src/components/business/goals-tab.tsx: key-introspected form fields for objectives items and operating_policy (no raw JSON textareas), updated_at/updated_by metadata, skeleton loading, OfflineState on error - Create src/components/business/secretary-tab.tsx: ReactMarkdown (GFM) chat bubbles, structured directive cards with labeled key-value rows, RequiredNotesDialog for reject, skeleton loading, OfflineState on error - Create src/components/business/pitches-tab.tsx: sub-header Refresh button, PitchCard skeleton loading, OfflineState on error (not empty-state text), RequiredNotesDialog for both Approve and Reject - Create src/components/ui/required-notes-dialog.tsx: Submit disabled on empty/whitespace, Cancel closes without action, state resets on each open via key pattern - Update sidebar.tsx: remove Cockpit/Company Goals/Secretary/Pitches entries, add single Business entry (Building2 icon, /business) - Replace company-goals/page.tsx, secretary/page.tsx, pitches/page.tsx with server-side redirect() to /business?tab=X - Replace cockpit/page.tsx with notFound() (404) - All tabs: shadcn Card + Skeleton, sonner toast for success/error Co-authored-by: Frontend Developer 1 * [e3e5ff9b] feat(dashboard): add StrategySignalsPanel next to CeoApprovalQueue in a 2-column grid layout (#181) Co-authored-by: Frontend Developer 2 --------- Co-authored-by: Frontend Developer 1 Co-authored-by: Frontend Developer 2 * refactor(panel): delete the consolidated old routes instead of stubbing them cockpit/company-goals/secretary/pitches are fully consolidated into /business, so the old route pages are dead code. Remove the four page.tsx files outright rather than keep redirect/404 stubs — the clean move is to delete, not add. The sidebar already points only at /business; no internal links reference the old routes (the remaining /company-goals|/secretary|/pitches|/cockpit strings are backend API paths the API clients call, unaffected). Old bookmarks now resolve to Next's default 404, which is correct for a removed route. * perf(cockpit): light /cockpit/signals endpoint for the Dashboard panel The relocated Strategy Signals panel was calling /cockpit/summary, which runs the whole fan-out (company goals + usage/spend + task-counts + pitches + strategy assess) just to read the signals. Add CockpitService.signals() + GET /api/cockpit/signals (CockpitSignals schema, same _COCKPIT_ROLES gate) that runs only StrategyEngine.assess(), and repoint the panel (+ cockpitApi.signals() client method, CockpitSignal type). Now the Dashboard fetches only what it shows. Backend gated: ruff + full mypy + 6 cockpit tests green (live DB). --------- Co-authored-by: Frontend Developer 1 Co-authored-by: Frontend Developer 2 Co-authored-by: Renn F --- panel/src/app/(dashboard)/business/page.tsx | 94 +++++ panel/src/app/(dashboard)/cockpit/page.tsx | 127 ------ .../app/(dashboard)/company-goals/page.tsx | 18 - panel/src/app/(dashboard)/pitches/page.tsx | 138 ------- panel/src/app/(dashboard)/secretary/page.tsx | 216 ---------- panel/src/components/business/goals-tab.tsx | 374 ++++++++++++++++++ panel/src/components/business/pitches-tab.tsx | 256 ++++++++++++ .../src/components/business/secretary-tab.tsx | 324 +++++++++++++++ .../components/dashboard/command-center.tsx | 8 +- panel/src/components/dashboard/index.ts | 1 + .../dashboard/strategy-signals-panel.tsx | 76 ++++ panel/src/components/layout/sidebar.tsx | 10 +- .../components/ui/required-notes-dialog.tsx | 111 ++++++ panel/src/lib/api/cockpit.ts | 17 +- roboco/api/routes/cockpit.py | 14 +- roboco/api/schemas/cockpit.py | 7 + roboco/services/cockpit.py | 12 + tests/unit/services/test_cockpit.py | 32 ++ 18 files changed, 1323 insertions(+), 512 deletions(-) create mode 100644 panel/src/app/(dashboard)/business/page.tsx delete mode 100644 panel/src/app/(dashboard)/cockpit/page.tsx delete mode 100644 panel/src/app/(dashboard)/company-goals/page.tsx delete mode 100644 panel/src/app/(dashboard)/pitches/page.tsx delete mode 100644 panel/src/app/(dashboard)/secretary/page.tsx create mode 100644 panel/src/components/business/goals-tab.tsx create mode 100644 panel/src/components/business/pitches-tab.tsx create mode 100644 panel/src/components/business/secretary-tab.tsx create mode 100644 panel/src/components/dashboard/strategy-signals-panel.tsx create mode 100644 panel/src/components/ui/required-notes-dialog.tsx diff --git a/panel/src/app/(dashboard)/business/page.tsx b/panel/src/app/(dashboard)/business/page.tsx new file mode 100644 index 00000000..b559d604 --- /dev/null +++ b/panel/src/app/(dashboard)/business/page.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { Suspense } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Skeleton } from "@/components/ui/skeleton"; +import { GoalsTab } from "@/components/business/goals-tab"; +import { SecretaryTab } from "@/components/business/secretary-tab"; +import { PitchesTab } from "@/components/business/pitches-tab"; + +// --------------------------------------------------------------------------- +// Valid tab values +// --------------------------------------------------------------------------- + +const TAB_VALUES = ["goals", "secretary", "pitches"] as const; +type TabValue = (typeof TAB_VALUES)[number]; + +function isValidTab(value: string | null): value is TabValue { + return TAB_VALUES.includes(value as TabValue); +} + +// --------------------------------------------------------------------------- +// Inner component that reads URL params +// --------------------------------------------------------------------------- + +function BusinessPageContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + + const rawTab = searchParams.get("tab"); + const activeTab: TabValue = isValidTab(rawTab) ? rawTab : "goals"; + + const handleTabChange = (value: string) => { + const params = new URLSearchParams(searchParams.toString()); + params.set("tab", value); + router.replace(`/business?${params.toString()}`); + }; + + return ( +
+ {/* Page header */} +
+

Business

+

+ Company goals, your chief-of-staff Secretary, and Board pitches — all in + one place. +

+
+ + + + Goals + Secretary + Pitches + + + + + + + + + + + + + + +
+ ); +} + +// --------------------------------------------------------------------------- +// Page export — wraps in Suspense for useSearchParams +// --------------------------------------------------------------------------- + +export default function BusinessPage() { + return ( + +
+ + +
+ + + + } + > + +
+ ); +} diff --git a/panel/src/app/(dashboard)/cockpit/page.tsx b/panel/src/app/(dashboard)/cockpit/page.tsx deleted file mode 100644 index 0359a8a8..00000000 --- a/panel/src/app/(dashboard)/cockpit/page.tsx +++ /dev/null @@ -1,127 +0,0 @@ -"use client"; - -import { useQuery } from "@tanstack/react-query"; -import { AlertTriangle, Loader2 } from "lucide-react"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Badge } from "@/components/ui/badge"; -import { cockpitApi } from "@/lib/api/cockpit"; - -function Stat({ label, value }: { label: string; value: string | number }) { - return ( -
-

{label}

-

{value}

-
- ); -} - -export default function CockpitPage() { - const { data, isLoading } = useQuery({ - queryKey: ["cockpit", "summary"], - queryFn: () => cockpitApi.summary(), - refetchInterval: 30000, - }); - - if (isLoading || !data) { - return ( -
- Loading the cockpit… -
- ); - } - - const cap = data.spend.monthly_budget_cap_usd; - - return ( -
-
-
-

Cockpit

-

- Is the business winning, what's happening, what needs you. -

-
- - basis: {data.basis} - -
- - - - North star - - -

- {data.north_star || "No north star set yet — define it in Company Goals."} -

- {data.objectives.length > 0 && ( -
    - {data.objectives.map((o, i) => ( -
  • {JSON.stringify(o)}
  • - ))} -
- )} -
-
- -
- - - -
- - - - Spend (30 days) - - -
- - ${data.spend.spend_30d_usd.toFixed(2)} - - {cap != null && ( - - / ${cap.toFixed(2)} cap - - )} - {data.spend.over_budget && ( - - over budget - - )} -
- {data.spend.projected_monthly_usd != null && ( -

- Projected this month: ${data.spend.projected_monthly_usd.toFixed(2)} -

- )} -
-
- - - - Needs your attention - - - {data.pending_pitches > 0 && ( -

- {data.pending_pitches} pitch(es) awaiting your approval. -

- )} - {data.signals.length === 0 && data.pending_pitches === 0 ? ( -

- Nothing needs you right now. -

- ) : ( - data.signals.map((s, i) => ( -
-

{s.summary}

-

{s.detail}

-
- )) - )} -
-
-
- ); -} diff --git a/panel/src/app/(dashboard)/company-goals/page.tsx b/panel/src/app/(dashboard)/company-goals/page.tsx deleted file mode 100644 index 587cf189..00000000 --- a/panel/src/app/(dashboard)/company-goals/page.tsx +++ /dev/null @@ -1,18 +0,0 @@ -"use client"; - -import { CompanyGoalsCard } from "@/components/company-goals/company-goals-card"; - -export default function CompanyGoalsPage() { - return ( -
-
-

Company Goals

-

- The organization's charter — north star, objectives, constraints, - and operating policy that steer every agent's work. -

-
- -
- ); -} diff --git a/panel/src/app/(dashboard)/pitches/page.tsx b/panel/src/app/(dashboard)/pitches/page.tsx deleted file mode 100644 index f3bd56dc..00000000 --- a/panel/src/app/(dashboard)/pitches/page.tsx +++ /dev/null @@ -1,138 +0,0 @@ -"use client"; - -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { toast } from "sonner"; -import { Check, Loader2, X } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Badge } from "@/components/ui/badge"; -import { getErrorMessage } from "@/lib/api/client"; -import { pitchesApi, type Pitch } from "@/lib/api/pitches"; - -function PitchCard({ - pitch, - onApprove, - onReject, - busy, -}: { - pitch: Pitch; - onApprove: (id: string) => void; - onReject: (id: string) => void; - busy: boolean; -}) { - const proposed = pitch.status === "proposed"; - return ( - - -
- {pitch.title} - {pitch.status} -
-
- {pitch.target_cells.map((c) => ( - - {c} - - ))} -
-
- -
-

Problem

-

{pitch.problem}

-
-
-

- Proposed solution -

-

{pitch.proposed_solution}

-
- {proposed ? ( -
- - -
- ) : ( - pitch.decision_notes && ( -

- Decision: {pitch.decision_notes} -

- ) - )} -
-
- ); -} - -export default function PitchesPage() { - const qc = useQueryClient(); - - const { data: pitches = [], isLoading } = useQuery({ - queryKey: ["pitches"], - queryFn: () => pitchesApi.list(), - refetchInterval: 30000, - }); - - const approveMutation = useMutation({ - mutationFn: (id: string) => pitchesApi.approve(id), - onSuccess: () => { - toast.success("Pitch approved — provisioning started"); - void qc.invalidateQueries({ queryKey: ["pitches"] }); - }, - onError: (e) => toast.error(getErrorMessage(e)), - }); - - const rejectMutation = useMutation({ - mutationFn: (id: string) => pitchesApi.reject(id, "Rejected by CEO"), - onSuccess: () => { - toast.success("Pitch rejected"); - void qc.invalidateQueries({ queryKey: ["pitches"] }); - }, - onError: (e) => toast.error(getErrorMessage(e)), - }); - - const busy = approveMutation.isPending || rejectMutation.isPending; - - return ( -
-
-

Pitches

-

- Board proposals. Approving a pitch provisions a repository per target - cell, registers the projects, and seeds the first task to Main PM. -

-
- {isLoading ? ( -
- Loading… -
- ) : pitches.length === 0 ? ( -

- No pitches yet. The Board authors them; they appear here for your - approval. -

- ) : ( -
- {pitches.map((p) => ( - approveMutation.mutate(id)} - onReject={(id) => rejectMutation.mutate(id)} - /> - ))} -
- )} -
- ); -} diff --git a/panel/src/app/(dashboard)/secretary/page.tsx b/panel/src/app/(dashboard)/secretary/page.tsx deleted file mode 100644 index 633c09e1..00000000 --- a/panel/src/app/(dashboard)/secretary/page.tsx +++ /dev/null @@ -1,216 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { toast } from "sonner"; -import { Check, Loader2, Send, X } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Textarea } from "@/components/ui/textarea"; -import { getErrorMessage } from "@/lib/api/client"; -import { secretaryApi, type SecretaryDirective } from "@/lib/api/secretary"; -import { useSecretary } from "@/hooks/use-secretary"; - -function DirectiveCard({ - directive, - onConfirm, - onReject, - busy, -}: { - directive: SecretaryDirective; - onConfirm: (id: string) => void; - onReject: (id: string) => void; - busy: boolean; -}) { - return ( -
-
- {directive.kind} - {directive.status} -
-
-        {JSON.stringify(directive.payload, null, 2)}
-      
-
- - -
-
- ); -} - -export default function SecretaryPage() { - const qc = useQueryClient(); - const { sessionId, messages, streaming, start, send, stop } = useSecretary(); - const [input, setInput] = useState(""); - const [starting, setStarting] = useState(false); - - const { data: pending = [] } = useQuery({ - queryKey: ["secretary", "directives", "pending"], - queryFn: () => secretaryApi.listDirectives("pending"), - refetchInterval: 15000, - }); - - const confirmMutation = useMutation({ - mutationFn: (id: string) => secretaryApi.confirmDirective(id), - onSuccess: (d) => { - toast.success(`Directive ${d.kind}: ${d.status}`); - void qc.invalidateQueries({ queryKey: ["secretary", "directives"] }); - }, - onError: (e) => toast.error(getErrorMessage(e)), - }); - - const rejectMutation = useMutation({ - mutationFn: (id: string) => secretaryApi.rejectDirective(id), - onSuccess: () => { - toast.success("Directive rejected"); - void qc.invalidateQueries({ queryKey: ["secretary", "directives"] }); - }, - onError: (e) => toast.error(getErrorMessage(e)), - }); - - const busy = confirmMutation.isPending || rejectMutation.isPending; - - const handleStart = async () => { - setStarting(true); - try { - await start(input.trim() || undefined); - setInput(""); - } catch (e) { - toast.error(getErrorMessage(e)); - } finally { - setStarting(false); - } - }; - - const handleSend = async () => { - const text = input.trim(); - if (!text) return; - setInput(""); - try { - await send(text); - } catch (e) { - toast.error(getErrorMessage(e)); - } - }; - - return ( -
-
-
-

Secretary

-

- Your chief-of-staff. It acts only on your command; high-impact - actions wait for your confirmation on the right. -

-
- {sessionId && ( - - )} -
- -
- - - Chat - - -
- {messages.length === 0 && ( -

- {sessionId - ? "Say something to your Secretary…" - : "Start a session to talk to your Secretary."} -

- )} - {messages.map((m, i) => ( -
- {m.text} -
- ))} - {streaming && ( -
- thinking… -
- )} -
-
-