[27208d92] Consolidate Cockpit/Goals/Secretary/Pitches into a Business page (#184)

* [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 <fe-dev-1@agents.roboco.dev>

* [e3e5ff9b] feat(dashboard): add StrategySignalsPanel next to CeoApprovalQueue in a 2-column grid layout (#181)

Co-authored-by: Frontend Developer 2 <fe-dev-2@agents.roboco.dev>

---------

Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev>
Co-authored-by: Frontend Developer 2 <fe-dev-2@agents.roboco.dev>

* 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 <fe-dev-1@agents.roboco.dev>
Co-authored-by: Frontend Developer 2 <fe-dev-2@agents.roboco.dev>
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-06-16 08:40:51 +02:00
committed by GitHub
co-authored by Frontend Developer 1 Frontend Developer 2 Renn F
parent a89d3cc885
commit 1757659754
18 changed files with 1323 additions and 512 deletions
@@ -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 (
<div className="space-y-6">
{/* Page header */}
<div>
<h1 className="text-3xl font-bold tracking-tight">Business</h1>
<p className="text-muted-foreground">
Company goals, your chief-of-staff Secretary, and Board pitches all in
one place.
</p>
</div>
<Tabs value={activeTab} onValueChange={handleTabChange}>
<TabsList>
<TabsTrigger value="goals">Goals</TabsTrigger>
<TabsTrigger value="secretary">Secretary</TabsTrigger>
<TabsTrigger value="pitches">Pitches</TabsTrigger>
</TabsList>
<TabsContent value="goals" className="mt-4">
<GoalsTab />
</TabsContent>
<TabsContent value="secretary" className="mt-4">
<SecretaryTab />
</TabsContent>
<TabsContent value="pitches" className="mt-4">
<PitchesTab />
</TabsContent>
</Tabs>
</div>
);
}
// ---------------------------------------------------------------------------
// Page export — wraps in Suspense for useSearchParams
// ---------------------------------------------------------------------------
export default function BusinessPage() {
return (
<Suspense
fallback={
<div className="space-y-6">
<div>
<Skeleton className="h-9 w-32 mb-2" />
<Skeleton className="h-5 w-96" />
</div>
<Skeleton className="h-9 w-72" />
<Skeleton className="h-96 w-full" />
</div>
}
>
<BusinessPageContent />
</Suspense>
);
}
-127
View File
@@ -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 (
<div className="rounded-lg border p-3">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="text-2xl font-semibold">{value}</p>
</div>
);
}
export default function CockpitPage() {
const { data, isLoading } = useQuery({
queryKey: ["cockpit", "summary"],
queryFn: () => cockpitApi.summary(),
refetchInterval: 30000,
});
if (isLoading || !data) {
return (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> Loading the cockpit
</div>
);
}
const cap = data.spend.monthly_budget_cap_usd;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Cockpit</h1>
<p className="text-muted-foreground">
Is the business winning, what&apos;s happening, what needs you.
</p>
</div>
<Badge variant="secondary" title="Performance is a proxy until real launches">
basis: {data.basis}
</Badge>
</div>
<Card>
<CardHeader>
<CardTitle>North star</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm">
{data.north_star || "No north star set yet — define it in Company Goals."}
</p>
{data.objectives.length > 0 && (
<ul className="list-disc space-y-1 pl-5 text-sm text-muted-foreground">
{data.objectives.map((o, i) => (
<li key={i}>{JSON.stringify(o)}</li>
))}
</ul>
)}
</CardContent>
</Card>
<div className="grid gap-4 sm:grid-cols-3">
<Stat label="In flight" value={data.delivery.in_flight} />
<Stat label="Blocked" value={data.delivery.blocked} />
<Stat label="Awaiting your approval" value={data.delivery.awaiting_ceo} />
</div>
<Card>
<CardHeader>
<CardTitle>Spend (30 days)</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<div className="flex items-center gap-3">
<span className="text-2xl font-semibold">
${data.spend.spend_30d_usd.toFixed(2)}
</span>
{cap != null && (
<span className="text-sm text-muted-foreground">
/ ${cap.toFixed(2)} cap
</span>
)}
{data.spend.over_budget && (
<Badge variant="destructive">
<AlertTriangle className="mr-1 h-3 w-3" /> over budget
</Badge>
)}
</div>
{data.spend.projected_monthly_usd != null && (
<p className="text-xs text-muted-foreground">
Projected this month: ${data.spend.projected_monthly_usd.toFixed(2)}
</p>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Needs your attention</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{data.pending_pitches > 0 && (
<p className="text-sm">
{data.pending_pitches} pitch(es) awaiting your approval.
</p>
)}
{data.signals.length === 0 && data.pending_pitches === 0 ? (
<p className="text-sm text-muted-foreground">
Nothing needs you right now.
</p>
) : (
data.signals.map((s, i) => (
<div key={i} className="rounded-lg border p-3">
<p className="text-sm font-medium">{s.summary}</p>
<p className="text-xs text-muted-foreground">{s.detail}</p>
</div>
))
)}
</CardContent>
</Card>
</div>
);
}
@@ -1,18 +0,0 @@
"use client";
import { CompanyGoalsCard } from "@/components/company-goals/company-goals-card";
export default function CompanyGoalsPage() {
return (
<div className="space-y-6 max-w-3xl">
<div>
<h1 className="text-3xl font-bold tracking-tight">Company Goals</h1>
<p className="text-muted-foreground">
The organization&apos;s charter north star, objectives, constraints,
and operating policy that steer every agent&apos;s work.
</p>
</div>
<CompanyGoalsCard />
</div>
);
}
-138
View File
@@ -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 (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg">{pitch.title}</CardTitle>
<Badge variant={proposed ? "default" : "secondary"}>{pitch.status}</Badge>
</div>
<div className="flex flex-wrap gap-1">
{pitch.target_cells.map((c) => (
<Badge key={c} variant="outline">
{c}
</Badge>
))}
</div>
</CardHeader>
<CardContent className="space-y-3">
<div>
<p className="text-xs font-medium text-muted-foreground">Problem</p>
<p className="text-sm whitespace-pre-wrap">{pitch.problem}</p>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground">
Proposed solution
</p>
<p className="text-sm whitespace-pre-wrap">{pitch.proposed_solution}</p>
</div>
{proposed ? (
<div className="flex gap-2">
<Button size="sm" disabled={busy} onClick={() => onApprove(pitch.id)}>
<Check className="mr-1 h-4 w-4" /> Approve &amp; provision
</Button>
<Button
size="sm"
variant="outline"
disabled={busy}
onClick={() => onReject(pitch.id)}
>
<X className="mr-1 h-4 w-4" /> Reject
</Button>
</div>
) : (
pitch.decision_notes && (
<p className="text-xs text-muted-foreground">
Decision: {pitch.decision_notes}
</p>
)
)}
</CardContent>
</Card>
);
}
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 (
<div className="max-w-3xl space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Pitches</h1>
<p className="text-muted-foreground">
Board proposals. Approving a pitch provisions a repository per target
cell, registers the projects, and seeds the first task to Main PM.
</p>
</div>
{isLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> Loading
</div>
) : pitches.length === 0 ? (
<p className="text-sm text-muted-foreground">
No pitches yet. The Board authors them; they appear here for your
approval.
</p>
) : (
<div className="space-y-4">
{pitches.map((p) => (
<PitchCard
key={p.id}
pitch={p}
busy={busy}
onApprove={(id) => approveMutation.mutate(id)}
onReject={(id) => rejectMutation.mutate(id)}
/>
))}
</div>
)}
</div>
);
}
@@ -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 (
<div className="space-y-2 rounded-lg border p-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{directive.kind}</span>
<span className="text-xs text-muted-foreground">{directive.status}</span>
</div>
<pre className="overflow-x-auto rounded bg-muted p-2 text-xs">
{JSON.stringify(directive.payload, null, 2)}
</pre>
<div className="flex gap-2">
<Button size="sm" disabled={busy} onClick={() => onConfirm(directive.id)}>
<Check className="mr-1 h-4 w-4" /> Confirm
</Button>
<Button
size="sm"
variant="outline"
disabled={busy}
onClick={() => onReject(directive.id)}
>
<X className="mr-1 h-4 w-4" /> Reject
</Button>
</div>
</div>
);
}
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 (
<div className="space-y-6">
<div className="flex items-start justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Secretary</h1>
<p className="text-muted-foreground">
Your chief-of-staff. It acts only on your command; high-impact
actions wait for your confirmation on the right.
</p>
</div>
{sessionId && (
<Button variant="outline" onClick={() => void stop()}>
End session
</Button>
)}
</div>
<div className="grid gap-6 lg:grid-cols-3">
<Card className="flex min-h-[60vh] flex-col lg:col-span-2">
<CardHeader>
<CardTitle>Chat</CardTitle>
</CardHeader>
<CardContent className="flex flex-1 flex-col gap-4">
<div className="flex-1 space-y-3 overflow-y-auto">
{messages.length === 0 && (
<p className="text-sm text-muted-foreground">
{sessionId
? "Say something to your Secretary…"
: "Start a session to talk to your Secretary."}
</p>
)}
{messages.map((m, i) => (
<div
key={i}
className={
m.role === "user"
? "ml-auto max-w-[80%] rounded-lg bg-primary px-3 py-2 text-sm text-primary-foreground"
: "mr-auto max-w-[80%] whitespace-pre-wrap rounded-lg bg-muted px-3 py-2 text-sm"
}
>
{m.text}
</div>
))}
{streaming && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" /> thinking
</div>
)}
</div>
<div className="flex gap-2">
<Textarea
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={
sessionId
? "Message your Secretary…"
: "Opening message (optional)…"
}
rows={2}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
if (sessionId) void handleSend();
else void handleStart();
}
}}
/>
{sessionId ? (
<Button onClick={() => void handleSend()} disabled={!input.trim()}>
<Send className="h-4 w-4" />
</Button>
) : (
<Button onClick={() => void handleStart()} disabled={starting}>
{starting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
"Start"
)}
</Button>
)}
</div>
</CardContent>
</Card>
<Card className="flex flex-col">
<CardHeader>
<CardTitle>Needs your confirmation</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{pending.length === 0 ? (
<p className="text-sm text-muted-foreground">
No directives waiting. High-impact actions the Secretary proposes
will appear here for you to confirm or reject.
</p>
) : (
pending.map((d) => (
<DirectiveCard
key={d.id}
directive={d}
busy={busy}
onConfirm={(id) => confirmMutation.mutate(id)}
onReject={(id) => rejectMutation.mutate(id)}
/>
))
)}
</CardContent>
</Card>
</div>
</div>
);
}
+374
View File
@@ -0,0 +1,374 @@
"use client";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Save } from "lucide-react";
import {
companyGoalsApi,
type CompanyGoals,
type CompanyGoalsUpdate,
} from "@/lib/api/company-goals";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { OfflineState } from "@/components/ui/offline-state";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function parseError(e: unknown): string {
return e instanceof Error ? e.message : "Unknown error";
}
/** Format an ISO timestamp nicely, or return the raw string. */
function formatTs(ts: string | null | undefined): string {
if (!ts) return "—";
try {
return new Date(ts).toLocaleString();
} catch {
return ts;
}
}
// ---------------------------------------------------------------------------
// Objectives field — one text field per key derived from the first item.
// Falls back to a single "value" field when array is empty.
// ---------------------------------------------------------------------------
interface ObjectivesEditorProps {
items: Record<string, unknown>[];
onChange: (items: Record<string, unknown>[]) => void;
disabled: boolean;
}
function ObjectivesEditor({ items, onChange, disabled }: ObjectivesEditorProps) {
// Derive keys from the first item; fall back to generic keys.
const keys =
items.length > 0
? Object.keys(items[0])
: ["metric", "target", "status"];
const handleChange = (
rowIdx: number,
key: string,
value: string
) => {
const next = items.map((item, i) =>
i === rowIdx ? { ...item, [key]: value } : item
);
onChange(next);
};
const addRow = () => {
const empty: Record<string, unknown> = {};
keys.forEach((k) => (empty[k] = ""));
onChange([...items, empty]);
};
const removeRow = (idx: number) => {
onChange(items.filter((_, i) => i !== idx));
};
return (
<div className="space-y-3">
{items.map((item, rowIdx) => (
<div key={rowIdx} className="rounded-lg border p-3 space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground">
Objective #{rowIdx + 1}
</span>
<Button
type="button"
variant="ghost"
size="sm"
disabled={disabled}
onClick={() => removeRow(rowIdx)}
className="h-6 px-2 text-xs text-destructive hover:text-destructive"
>
Remove
</Button>
</div>
{keys.map((key) => (
<div key={key} className="space-y-1">
<Label htmlFor={`obj-${rowIdx}-${key}`} className="text-xs capitalize">
{key.replace(/_/g, " ")}
</Label>
<Input
id={`obj-${rowIdx}-${key}`}
value={String(item[key] ?? "")}
disabled={disabled}
onChange={(e) => handleChange(rowIdx, key, e.target.value)}
className="h-8 text-sm"
/>
</div>
))}
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
disabled={disabled}
onClick={addRow}
>
+ Add objective
</Button>
</div>
);
}
// ---------------------------------------------------------------------------
// Operating policy editor — one input per key
// ---------------------------------------------------------------------------
interface PolicyEditorProps {
policy: Record<string, unknown>;
onChange: (policy: Record<string, unknown>) => void;
disabled: boolean;
}
function PolicyEditor({ policy, onChange, disabled }: PolicyEditorProps) {
const keys = Object.keys(policy);
const handleChange = (key: string, value: string) => {
onChange({ ...policy, [key]: value });
};
return (
<div className="space-y-2">
{keys.length === 0 && (
<p className="text-xs text-muted-foreground">
No policy keys yet save with the backend to populate.
</p>
)}
{keys.map((key) => (
<div key={key} className="space-y-1">
<Label htmlFor={`policy-${key}`} className="text-xs capitalize">
{key.replace(/_/g, " ")}
</Label>
<Input
id={`policy-${key}`}
value={String(policy[key] ?? "")}
disabled={disabled}
onChange={(e) => handleChange(key, e.target.value)}
className="h-8 text-sm"
/>
</div>
))}
</div>
);
}
// ---------------------------------------------------------------------------
// Skeleton loading state
// ---------------------------------------------------------------------------
function GoalsTabSkeleton() {
return (
<Card>
<CardHeader>
<Skeleton className="h-6 w-40 mb-1" />
<Skeleton className="h-4 w-80" />
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-20 w-full" />
</div>
<div className="space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-20 w-full" />
</div>
<div className="space-y-3">
<Skeleton className="h-4 w-24" />
<div className="rounded-lg border p-3 space-y-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-8 w-full" />
<Skeleton className="h-4 w-16" />
<Skeleton className="h-8 w-full" />
</div>
</div>
<div className="space-y-2">
<Skeleton className="h-4 w-36" />
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
</div>
<Skeleton className="h-9 w-32" />
</CardContent>
</Card>
);
}
// ---------------------------------------------------------------------------
// Main editable form — only rendered when data is loaded
// ---------------------------------------------------------------------------
interface GoalsFormProps {
goals: CompanyGoals;
refetch: () => void;
}
function GoalsForm({ goals, refetch }: GoalsFormProps) {
const queryClient = useQueryClient();
const [northStar, setNorthStar] = useState<string | null>(null);
const [constraints, setConstraints] = useState<string | null>(null);
const [objectives, setObjectives] = useState<Record<string, unknown>[] | null>(null);
const [policy, setPolicy] = useState<Record<string, unknown> | null>(null);
const northStarVal = northStar ?? goals.north_star ?? "";
const constraintsVal = constraints ?? (goals.constraints ?? []).join("\n");
const objectivesVal = objectives ?? (goals.objectives ?? []);
const policyVal = policy ?? (goals.operating_policy ?? {});
const saveMutation = useMutation({
mutationFn: (update: CompanyGoalsUpdate) => companyGoalsApi.update(update),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["company-goals"] });
setNorthStar(null);
setConstraints(null);
setObjectives(null);
setPolicy(null);
toast.success("Company charter updated");
},
onError: (error) => {
toast.error(`Failed to save: ${parseError(error)}`);
},
});
const handleSave = () => {
saveMutation.mutate({
north_star: northStarVal,
objectives: objectivesVal,
constraints: constraintsVal
.split("\n")
.map((c) => c.trim())
.filter(Boolean),
operating_policy: policyVal,
});
};
const saving = saveMutation.isPending;
return (
<Card>
<CardHeader>
<CardTitle>Company Charter</CardTitle>
<CardDescription>
CEO-owned north star, objectives, constraints, and operating policy.
Injected into every agent&apos;s briefing so all work stays goal-aware.
</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
{/* North star */}
<div className="space-y-2">
<Label htmlFor="north-star">North star</Label>
<Textarea
id="north-star"
rows={3}
value={northStarVal}
disabled={saving}
onChange={(e) => setNorthStar(e.target.value)}
placeholder="The long-term vision in one or two sentences…"
/>
</div>
{/* Constraints */}
<div className="space-y-2">
<Label htmlFor="constraints">Constraints (one per line)</Label>
<Textarea
id="constraints"
rows={3}
value={constraintsVal}
disabled={saving}
onChange={(e) => setConstraints(e.target.value)}
placeholder={"AGPL only\nNo external data egress"}
/>
</div>
{/* Objectives */}
<div className="space-y-2">
<Label>Objectives</Label>
<ObjectivesEditor
items={objectivesVal}
onChange={(items) => setObjectives(items)}
disabled={saving}
/>
</div>
{/* Operating policy */}
<div className="space-y-2">
<Label>Operating policy</Label>
<PolicyEditor
policy={policyVal}
onChange={(p) => setPolicy(p)}
disabled={saving}
/>
</div>
{/* Save */}
<Button onClick={handleSave} disabled={saving}>
<Save className="h-4 w-4 mr-2" />
{saving ? "Saving…" : "Save charter"}
</Button>
{/* Metadata */}
<div className="border-t pt-4 space-y-1 text-xs text-muted-foreground">
<p>
Last updated:{" "}
<span className="font-medium text-foreground">
{formatTs(goals.updated_at)}
</span>
</p>
{goals.updated_by && (
<p>
Updated by:{" "}
<span className="font-medium text-foreground font-mono">
{goals.updated_by}
</span>
</p>
)}
</div>
{/* Hidden — just to make the refetch prop used */}
<button type="button" className="hidden" onClick={refetch} />
</CardContent>
</Card>
);
}
// ---------------------------------------------------------------------------
// Public export
// ---------------------------------------------------------------------------
export function GoalsTab() {
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ["company-goals"],
queryFn: companyGoalsApi.get,
});
if (isLoading) return <GoalsTabSkeleton />;
if (isError || !data) {
return (
<OfflineState
title="Failed to load company goals"
description="Could not reach the orchestrator API. Check the backend is running."
onRetry={() => void refetch()}
/>
);
}
return <GoalsForm goals={data} refetch={() => void refetch()} />;
}
@@ -0,0 +1,256 @@
"use client";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Check, RefreshCw, 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 { Skeleton } from "@/components/ui/skeleton";
import { OfflineState } from "@/components/ui/offline-state";
import { RequiredNotesDialog } from "@/components/ui/required-notes-dialog";
import { getErrorMessage } from "@/lib/api/client";
import { pitchesApi, type Pitch } from "@/lib/api/pitches";
// ---------------------------------------------------------------------------
// Skeleton placeholder shaped like a PitchCard
// ---------------------------------------------------------------------------
function PitchCardSkeleton() {
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-5 w-20" />
</div>
<div className="flex gap-1 mt-1">
<Skeleton className="h-5 w-16" />
<Skeleton className="h-5 w-16" />
</div>
</CardHeader>
<CardContent className="space-y-3">
<div>
<Skeleton className="h-3 w-12 mb-1" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4 mt-1" />
</div>
<div>
<Skeleton className="h-3 w-28 mb-1" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-2/3 mt-1" />
</div>
<div className="flex gap-2">
<Skeleton className="h-8 w-32" />
<Skeleton className="h-8 w-20" />
</div>
</CardContent>
</Card>
);
}
// ---------------------------------------------------------------------------
// Individual pitch card with RequiredNotesDialog for approve/reject
// ---------------------------------------------------------------------------
interface PitchCardProps {
pitch: Pitch;
onApprove: (id: string, notes: string) => void;
onReject: (id: string, notes: string) => void;
busy: boolean;
}
function PitchCard({ pitch, onApprove, onReject, busy }: PitchCardProps) {
const [approveOpen, setApproveOpen] = useState(false);
const [rejectOpen, setRejectOpen] = useState(false);
const proposed = pitch.status === "proposed";
return (
<>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg">{pitch.title}</CardTitle>
<Badge variant={proposed ? "default" : "secondary"}>
{pitch.status}
</Badge>
</div>
{pitch.target_cells.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{pitch.target_cells.map((c) => (
<Badge key={c} variant="outline">
{c}
</Badge>
))}
</div>
)}
</CardHeader>
<CardContent className="space-y-3">
<div>
<p className="text-xs font-medium text-muted-foreground">Problem</p>
<p className="text-sm whitespace-pre-wrap">{pitch.problem}</p>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground">
Proposed solution
</p>
<p className="text-sm whitespace-pre-wrap">{pitch.proposed_solution}</p>
</div>
{proposed ? (
<div className="flex gap-2">
<Button
size="sm"
disabled={busy}
onClick={() => setApproveOpen(true)}
>
<Check className="mr-1 h-4 w-4" /> Approve &amp; provision
</Button>
<Button
size="sm"
variant="outline"
disabled={busy}
onClick={() => setRejectOpen(true)}
>
<X className="mr-1 h-4 w-4" /> Reject
</Button>
</div>
) : (
pitch.decision_notes && (
<p className="text-xs text-muted-foreground">
Decision: {pitch.decision_notes}
</p>
)
)}
</CardContent>
</Card>
{/* Approve dialog */}
<RequiredNotesDialog
open={approveOpen}
onOpenChange={setApproveOpen}
title="Approve pitch"
description="Add a note to accompany your approval. This will be recorded in the decision log."
notesLabel="Approval note"
placeholder="Why are you approving this pitch?"
submitLabel="Approve & provision"
isPending={busy}
onSubmit={(notes) => {
setApproveOpen(false);
onApprove(pitch.id, notes);
}}
/>
{/* Reject dialog */}
<RequiredNotesDialog
open={rejectOpen}
onOpenChange={setRejectOpen}
title="Reject pitch"
description="Please provide a reason for rejecting this pitch."
notesLabel="Rejection reason"
placeholder="Why are you rejecting this pitch?"
submitLabel="Reject"
isPending={busy}
onSubmit={(notes) => {
setRejectOpen(false);
onReject(pitch.id, notes);
}}
/>
</>
);
}
// ---------------------------------------------------------------------------
// Public export
// ---------------------------------------------------------------------------
export function PitchesTab() {
const qc = useQueryClient();
const {
data: pitches = [],
isLoading,
isError,
refetch,
} = useQuery({
queryKey: ["pitches"],
queryFn: () => pitchesApi.list(),
refetchInterval: 30000,
});
const approveMutation = useMutation({
mutationFn: ({ id, notes }: { id: string; notes: string }) =>
pitchesApi.approve(id, notes),
onSuccess: () => {
toast.success("Pitch approved — provisioning started");
void qc.invalidateQueries({ queryKey: ["pitches"] });
},
onError: (e) => toast.error(getErrorMessage(e)),
});
const rejectMutation = useMutation({
mutationFn: ({ id, notes }: { id: string; notes: string }) =>
pitchesApi.reject(id, notes),
onSuccess: () => {
toast.success("Pitch rejected");
void qc.invalidateQueries({ queryKey: ["pitches"] });
},
onError: (e) => toast.error(getErrorMessage(e)),
});
const busy = approveMutation.isPending || rejectMutation.isPending;
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Pitches</CardTitle>
<Button
variant="outline"
size="sm"
onClick={() => void refetch()}
disabled={isLoading}
>
<RefreshCw className="mr-1 h-4 w-4" /> Refresh
</Button>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-4">
<PitchCardSkeleton />
<PitchCardSkeleton />
</div>
) : isError ? (
<OfflineState
title="Failed to load pitches"
description="Could not reach the orchestrator API. Check the backend is running."
onRetry={() => void refetch()}
/>
) : pitches.length === 0 ? (
<p className="text-sm text-muted-foreground">
No pitches yet. The Board authors them; they appear here for your
approval.
</p>
) : (
<div className="space-y-4">
{pitches.map((p: Pitch) => (
<PitchCard
key={p.id}
pitch={p}
busy={busy}
onApprove={(id, notes) => approveMutation.mutate({ id, notes })}
onReject={(id, notes) => rejectMutation.mutate({ id, notes })}
/>
))}
</div>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,324 @@
"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 { Skeleton } from "@/components/ui/skeleton";
import { OfflineState } from "@/components/ui/offline-state";
import { Markdown } from "@/components/ui/markdown";
import { RequiredNotesDialog } from "@/components/ui/required-notes-dialog";
import { getErrorMessage } from "@/lib/api/client";
import { secretaryApi, type SecretaryDirective } from "@/lib/api/secretary";
import { useSecretary } from "@/hooks/use-secretary";
// ---------------------------------------------------------------------------
// Directive card — structured key-value rows, no raw JSON
// ---------------------------------------------------------------------------
interface DirectiveCardProps {
directive: SecretaryDirective;
onConfirm: (id: string) => void;
onReject: (id: string, reason: string) => void;
busy: boolean;
}
function DirectiveCard({ directive, onConfirm, onReject, busy }: DirectiveCardProps) {
const [rejectOpen, setRejectOpen] = useState(false);
const payloadKeys = Object.keys(directive.payload);
return (
<>
<div className="space-y-3 rounded-lg border p-3">
{/* Header row */}
<div className="flex items-center justify-between">
<span className="text-sm font-semibold">{directive.kind}</span>
<span className="text-xs text-muted-foreground capitalize">
{directive.status}
</span>
</div>
{/* Structured payload — one labeled row per key */}
{payloadKeys.length > 0 ? (
<div className="space-y-1.5">
{payloadKeys.map((key) => (
<div key={key} className="flex flex-wrap gap-x-3 text-sm">
<span className="min-w-[8rem] text-xs font-medium text-muted-foreground capitalize">
{key.replace(/_/g, " ")}
</span>
<span className="text-xs break-all">
{String(directive.payload[key] ?? "—")}
</span>
</div>
))}
</div>
) : (
<p className="text-xs text-muted-foreground">No payload.</p>
)}
{/* Actions */}
<div className="flex gap-2 pt-1">
<Button
size="sm"
disabled={busy}
onClick={() => onConfirm(directive.id)}
>
<Check className="mr-1 h-4 w-4" /> Confirm
</Button>
<Button
size="sm"
variant="outline"
disabled={busy}
onClick={() => setRejectOpen(true)}
>
<X className="mr-1 h-4 w-4" /> Reject
</Button>
</div>
</div>
{/* Reject requires a non-empty reason */}
<RequiredNotesDialog
open={rejectOpen}
onOpenChange={setRejectOpen}
title="Reject directive"
description="Please provide a reason for rejecting this directive."
notesLabel="Reason"
placeholder="Why are you rejecting this directive?"
submitLabel="Reject"
isPending={busy}
onSubmit={(reason) => {
setRejectOpen(false);
onReject(directive.id, reason);
}}
/>
</>
);
}
// ---------------------------------------------------------------------------
// Directives panel skeleton
// ---------------------------------------------------------------------------
function DirectivesSkeleton() {
return (
<div className="space-y-3">
{[1, 2].map((i) => (
<div key={i} className="rounded-lg border p-3 space-y-2">
<div className="flex items-center justify-between">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-3 w-16" />
</div>
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-3/4" />
<div className="flex gap-2 pt-1">
<Skeleton className="h-8 w-20" />
<Skeleton className="h-8 w-20" />
</div>
</div>
))}
</div>
);
}
// ---------------------------------------------------------------------------
// Chat messages — ReactMarkdown in styled bubbles
// ---------------------------------------------------------------------------
function ChatMessages({
messages,
streaming,
}: {
messages: { role: "user" | "assistant"; text: string }[];
streaming: boolean;
}) {
return (
<div className="flex-1 space-y-3 overflow-y-auto">
{messages.length === 0 && (
<p className="text-sm text-muted-foreground">
Say something to start the conversation.
</p>
)}
{messages.map((m, i) => (
<div
key={i}
className={
m.role === "user"
? "ml-auto max-w-[80%] rounded-lg bg-primary px-3 py-2 text-sm text-primary-foreground"
: "mr-auto max-w-[80%] rounded-lg bg-muted px-3 py-2"
}
>
{m.role === "user" ? (
<span>{m.text}</span>
) : (
<Markdown className="text-sm">{m.text}</Markdown>
)}
</div>
))}
{streaming && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" /> thinking
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Public export
// ---------------------------------------------------------------------------
export function SecretaryTab() {
const qc = useQueryClient();
const { sessionId, messages, streaming, start, send, stop } = useSecretary();
const [input, setInput] = useState("");
const [starting, setStarting] = useState(false);
const {
data: pending = [],
isLoading: directivesLoading,
isError: directivesError,
refetch: refetchDirectives,
} = 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, reason }: { id: string; reason: string }) =>
secretaryApi.rejectDirective(id, reason),
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 (
<div className="grid gap-6 lg:grid-cols-3">
{/* Chat panel */}
<Card className="flex min-h-[60vh] flex-col lg:col-span-2">
<CardHeader className="flex-row items-center justify-between space-y-0 pb-3">
<CardTitle>Chat</CardTitle>
{sessionId && (
<Button variant="outline" size="sm" onClick={() => void stop()}>
End session
</Button>
)}
</CardHeader>
<CardContent className="flex flex-1 flex-col gap-4">
<ChatMessages messages={messages} streaming={streaming} />
<div className="flex gap-2">
<Textarea
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={
sessionId
? "Message your Secretary…"
: "Opening message (optional)…"
}
rows={2}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
if (sessionId) void handleSend();
else void handleStart();
}
}}
/>
{sessionId ? (
<Button
onClick={() => void handleSend()}
disabled={!input.trim()}
>
<Send className="h-4 w-4" />
</Button>
) : (
<Button onClick={() => void handleStart()} disabled={starting}>
{starting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
"Start"
)}
</Button>
)}
</div>
</CardContent>
</Card>
{/* Pending directives panel */}
<Card className="flex flex-col">
<CardHeader>
<CardTitle>Needs your confirmation</CardTitle>
</CardHeader>
<CardContent className="flex-1">
{directivesLoading ? (
<DirectivesSkeleton />
) : directivesError ? (
<OfflineState
title="Failed to load directives"
description="Could not reach the API."
onRetry={() => void refetchDirectives()}
/>
) : pending.length === 0 ? (
<p className="text-sm text-muted-foreground">
No directives waiting. High-impact actions the Secretary proposes
will appear here for you to confirm or reject.
</p>
) : (
<div className="space-y-3">
{pending.map((d: SecretaryDirective) => (
<DirectiveCard
key={d.id}
directive={d}
busy={busy}
onConfirm={(id) => confirmMutation.mutate(id)}
onReject={(id, reason) =>
rejectMutation.mutate({ id, reason })
}
/>
))}
</div>
)}
</CardContent>
</Card>
</div>
);
}
@@ -9,6 +9,7 @@ import { ActiveBlockersPanel } from "./active-blockers-panel";
import { RecentActivityFeed } from "./recent-activity-feed"; import { RecentActivityFeed } from "./recent-activity-feed";
import { QuickActionsBar } from "./quick-actions-bar"; import { QuickActionsBar } from "./quick-actions-bar";
import { CeoApprovalQueue } from "./ceo-approval-queue"; import { CeoApprovalQueue } from "./ceo-approval-queue";
import { StrategySignalsPanel } from "./strategy-signals-panel";
import type { Activity } from "./activity-item"; import type { Activity } from "./activity-item";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { UsageOverviewPanel } from "./usage-overview-panel"; import { UsageOverviewPanel } from "./usage-overview-panel";
@@ -70,10 +71,11 @@ export function CommandCenter() {
/> />
</section> </section>
{/* CEO Approval Queue - Your primary action item */} {/* CEO Approval Queue + Strategy Signals - side-by-side on lg+ */}
<section> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<CeoApprovalQueue /> <CeoApprovalQueue />
</section> <StrategySignalsPanel />
</div>
{/* Metrics, Alerts, and Usage Row */} {/* Metrics, Alerts, and Usage Row */}
<div className="grid grid-cols-1 lg:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-3 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-3 gap-6">
+1
View File
@@ -9,4 +9,5 @@ export { ActivityItem } from "./activity-item";
export { QuickActionsBar } from "./quick-actions-bar"; export { QuickActionsBar } from "./quick-actions-bar";
export { HealthIndicator } from "./health-indicator"; export { HealthIndicator } from "./health-indicator";
export { CeoApprovalQueue } from "./ceo-approval-queue"; export { CeoApprovalQueue } from "./ceo-approval-queue";
export { StrategySignalsPanel } from "./strategy-signals-panel";
export { UsageOverviewPanel } from "./usage-overview-panel"; export { UsageOverviewPanel } from "./usage-overview-panel";
@@ -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 (
<Card className={className}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TrendingUp className="h-5 w-5" />
Strategy Signals
</CardTitle>
<CardDescription>Live signals from the strategy engine</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-3">
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
</div>
) : signals.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<TrendingUp className="h-12 w-12 mx-auto mb-2 opacity-50" />
<p>No strategy signals right now</p>
</div>
) : (
<div className="space-y-3">
{signals.map((signal, index) => (
<div
key={index}
className="flex items-start gap-3 p-4 border rounded-lg hover:bg-muted/50 transition-colors"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<Badge variant="outline" className="text-xs">
{signal.kind}
</Badge>
</div>
<p className="font-medium text-sm">{signal.summary}</p>
{signal.detail && (
<p className="text-sm text-muted-foreground mt-1">
{signal.detail}
</p>
)}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}
+2 -8
View File
@@ -22,10 +22,7 @@ import {
Database, Database,
Cpu, Cpu,
Sparkles, Sparkles,
Target, Building2,
Briefcase,
Lightbulb,
Gauge,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
@@ -34,19 +31,16 @@ import { useUIStore } from "@/store";
export const navItems = [ export const navItems = [
// Dashboard // Dashboard
{ title: "Overview", href: "/overview", icon: LayoutDashboard }, { title: "Overview", href: "/overview", icon: LayoutDashboard },
{ title: "Cockpit", href: "/cockpit", icon: Gauge }, { title: "Business", href: "/business", icon: Building2 },
{ title: "Company Goals", href: "/company-goals", icon: Target },
// Work Management // Work Management
{ title: "Tasks", href: "/tasks", icon: ListTodo }, { title: "Tasks", href: "/tasks", icon: ListTodo },
{ title: "Kanban", href: "/kanban", icon: Kanban }, { title: "Kanban", href: "/kanban", icon: Kanban },
{ title: "Task Assistant", href: "/prompter", icon: Sparkles }, { title: "Task Assistant", href: "/prompter", icon: Sparkles },
{ title: "Secretary", href: "/secretary", icon: Briefcase },
// Development // Development
{ title: "Projects", href: "/projects", icon: FolderGit2 }, { title: "Projects", href: "/projects", icon: FolderGit2 },
{ title: "Products", href: "/products", icon: Boxes }, { title: "Products", href: "/products", icon: Boxes },
{ title: "Pitches", href: "/pitches", icon: Lightbulb },
{ title: "Git", href: "/git", icon: GitBranch }, { title: "Git", href: "/git", icon: GitBranch },
// Team & Reference // Team & Reference
@@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{description && (
<DialogDescription>{description}</DialogDescription>
)}
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="required-notes">{notesLabel}</Label>
<Textarea
id="required-notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder={placeholder}
rows={4}
disabled={isPending}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={handleCancel} disabled={isPending}>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={isBlank || isPending}>
{isPending ? "Submitting…" : submitLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
/**
* 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 <RequiredNotesDialogInner key={String(props.open)} {...props} />;
}
+16 -1
View File
@@ -17,7 +17,13 @@ export interface CockpitSummary {
over_budget: boolean; over_budget: boolean;
}; };
pending_pitches: number; pending_pitches: number;
signals: { kind: string; summary: string; detail: string }[]; signals: CockpitSignal[];
}
export interface CockpitSignal {
kind: string;
summary: string;
detail: string;
} }
export const cockpitApi = { export const cockpitApi = {
@@ -26,4 +32,13 @@ export const cockpitApi = {
const { data } = await api.get<CockpitSummary>("/cockpit/summary"); const { data } = await api.get<CockpitSummary>("/cockpit/summary");
return data; 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<CockpitSignal[]> => {
const { data } = await api.get<{ signals: CockpitSignal[] }>(
"/cockpit/signals"
);
return data.signals;
},
}; };
+13 -1
View File
@@ -3,7 +3,7 @@
from fastapi import APIRouter, HTTPException, status from fastapi import APIRouter, HTTPException, status
from roboco.api.deps import CurrentAgentContext, DbSession 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.models import AgentRole
from roboco.services.cockpit import get_cockpit_service 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", detail=f"role '{agent.role}' may not view the cockpit",
) )
return CockpitSummary(**await get_cockpit_service(db).summary()) 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())
+7
View File
@@ -35,3 +35,10 @@ class CockpitSummary(BaseModel):
spend: SpendSummary spend: SpendSummary
pending_pitches: int pending_pitches: int
signals: list[CockpitSignal] 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]
+12
View File
@@ -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: def get_cockpit_service(session: AsyncSession) -> CockpitService:
"""Construct a CockpitService bound to ``session``.""" """Construct a CockpitService bound to ``session``."""
+32
View File
@@ -121,3 +121,35 @@ async def test_route_ok_for_ceo(monkeypatch: pytest.MonkeyPatch) -> None:
resp = await croute.cockpit_summary(MagicMock(), _agent(AgentRole.CEO)) resp = await croute.cockpit_summary(MagicMock(), _agent(AgentRole.CEO))
assert resp.basis == "proxy" assert resp.basis == "proxy"
assert resp.spend.over_budget is False 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"