feat(panel): tooltip sweep — projects, products, social, KB, business, settings, notifications

All 34 feature flags get verified one-line tips; secret inputs state
the write-only contract; KB index types get canonical descriptions;
switch tips ride Labels so Radix data-state stays intact. Also fixes
the scorecard SectionLabel swallowing props, which made tooltips on it
silently inert.
This commit is contained in:
Renn F
2026-07-15 16:33:22 +02:00
parent d1b02e3747
commit c37516f640
44 changed files with 1258 additions and 482 deletions
@@ -206,6 +206,27 @@ describe("AIRoutingCard", () => {
).toHaveLength(20);
});
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");
// TooltipTrigger always stamps data-state onto its asChild target, so
// its presence is a reliable proxy for "this element is tooltip-wrapped"
// without simulating hover (Radix only portals content once open).
expect(
screen.getByText("Grok (xAI) API key").getAttribute("data-state"),
).toBe("closed");
expect(
screen.getByText("Ollama Cloud API key").getAttribute("data-state"),
).toBe("closed");
const notSetBadges = screen.getAllByText("not set");
expect(notSetBadges).toHaveLength(2);
for (const badge of notSetBadges) {
expect(badge.getAttribute("data-state")).toBe("closed");
}
});
it("saving the mix with no picks shows an error and never calls applyMode", async () => {
render(withQueryClient(<AIRoutingCard />));
await screen.findByText("Per-agent override (mix mode)");
@@ -127,4 +127,31 @@ describe("FeatureFlagsCard — M42 off-transition confirm + pending-keys Set", (
resolveQueue.current.shift()?.(undefined);
await waitFor(() => expect(beta).not.toBeDisabled());
});
// W9-5 follow-up: every real flag key gets a one-line hover tip on its
// label (FLAG_TOOLTIPS in feature-flags-card.tsx); an unmapped key (the
// "alpha"/"beta" fixtures above) renders bare per HelpTip's falsy short-
// circuit. TooltipTrigger always stamps data-state ("closed" while
// unopened) onto its asChild target, so its presence/absence is a reliable
// proxy for "is this label tooltip-wrapped" without simulating hover.
it("attaches the mapped tooltip to a real flag key and leaves unmapped keys bare", async () => {
getFeatureFlags.mockResolvedValueOnce({
flags: [
{ key: "alpha", label: "Alpha", enabled: true },
{
key: "external_pr_enabled",
label: "External PR Review",
enabled: true,
},
],
note: "Changes take effect on the next backend restart.",
});
render(withQueryClient(<FeatureFlagsCard />));
const mapped = await screen.findByText("External PR Review");
expect(mapped.getAttribute("data-state")).toBe("closed");
const unmapped = screen.getByText("Alpha");
expect(unmapped.getAttribute("data-state")).toBeNull();
});
});
@@ -37,6 +37,16 @@ describe("TelegramCredentialsForm", () => {
).toBeInTheDocument();
});
it("tooltip-wraps each field label with the write-only storage note", async () => {
render(withQueryClient(<TelegramCredentialsForm />));
await screen.findByText("No credentials configured");
expect(
screen
.getByText("Bot token (from @BotFather)")
.getAttribute("data-state"),
).toBe("closed");
});
it("disables Save until both fields are filled", async () => {
render(withQueryClient(<TelegramCredentialsForm />));
await screen.findByText("No credentials configured");
@@ -37,6 +37,14 @@ describe("TikTokCredentialsForm", () => {
).toBeInTheDocument();
});
it("tooltip-wraps each field label with the write-only storage note", async () => {
render(withQueryClient(<TikTokCredentialsForm />));
await screen.findByText("No credentials configured");
expect(screen.getByText("Client key").getAttribute("data-state")).toBe(
"closed",
);
});
it("disables Save until all 4 fields are filled", async () => {
render(withQueryClient(<TikTokCredentialsForm />));
await screen.findByText("No credentials configured");
@@ -37,6 +37,15 @@ describe("XCredentialsForm", () => {
).toBeInTheDocument();
});
it("tooltip-wraps each field label with the write-only storage note", async () => {
render(withQueryClient(<XCredentialsForm />));
await screen.findByText("No credentials configured");
// TooltipTrigger always stamps data-state onto its asChild target.
expect(screen.getByText("API key").getAttribute("data-state")).toBe(
"closed",
);
});
it("disables Save until all 4 fields are filled", async () => {
render(withQueryClient(<XCredentialsForm />));
await screen.findByText("No credentials configured");
@@ -48,6 +48,7 @@ import type { RoutingMode, SelfHostedTestResult } from "@/lib/api/providers";
import { SelfHostedSection } from "@/components/settings/self-hosted-section";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { HelpTip } from "@/components/ui/help-tip";
// Matches the roboco agents_config AGENT_ROLE_MAP / AGENT_TEAM_MAP.
// Hard-coded so Mix mode shows a stable 18-row picker without an extra
@@ -353,17 +354,23 @@ export function AIRoutingCard() {
{/* -------- Grok (xAI) key -------- */}
<section className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">
Grok (xAI) API key
</Label>
<HelpTip label="Stored encrypted server-side; never displayed once saved.">
<Label className="text-sm font-medium">
Grok (xAI) API key
</Label>
</HelpTip>
{hasGrokKey ? (
<Badge className="bg-emerald-500/10 text-emerald-600 border-0">
<KeyRound className="h-3 w-3" /> key set
</Badge>
<HelpTip label="Enables the Grok mode button and any Grok row in Mix mode below.">
<Badge className="bg-emerald-500/10 text-emerald-600 border-0">
<KeyRound className="h-3 w-3" /> key set
</Badge>
</HelpTip>
) : (
<Badge className="bg-amber-500/10 text-amber-600 border-0">
<Key className="h-3 w-3" /> not set
</Badge>
<HelpTip label="Required before any agent can route to a Grok model.">
<Badge className="bg-amber-500/10 text-amber-600 border-0">
<Key className="h-3 w-3" /> not set
</Badge>
</HelpTip>
)}
</div>
<div className="flex gap-2">
@@ -405,17 +412,23 @@ export function AIRoutingCard() {
{/* -------- Ollama key -------- */}
<section className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">
Ollama Cloud API key
</Label>
<HelpTip label="Stored encrypted server-side; never displayed once saved.">
<Label className="text-sm font-medium">
Ollama Cloud API key
</Label>
</HelpTip>
{hasOllamaKey ? (
<Badge className="bg-emerald-500/10 text-emerald-600 border-0">
<KeyRound className="h-3 w-3" /> key set
</Badge>
<HelpTip label="Enables the Ollama mode button and any Ollama row in Mix mode below.">
<Badge className="bg-emerald-500/10 text-emerald-600 border-0">
<KeyRound className="h-3 w-3" /> key set
</Badge>
</HelpTip>
) : (
<Badge className="bg-amber-500/10 text-amber-600 border-0">
<Key className="h-3 w-3" /> not set
</Badge>
<HelpTip label="Required before any agent can route to an Ollama Cloud model.">
<Badge className="bg-amber-500/10 text-amber-600 border-0">
<Key className="h-3 w-3" /> not set
</Badge>
</HelpTip>
)}
</div>
<div className="flex gap-2">
@@ -467,7 +480,9 @@ export function AIRoutingCard() {
{/* -------- Mode toggle -------- */}
<section className="space-y-3">
<Label className="text-sm font-medium">Routing mode</Label>
<HelpTip label="Anthropic / Grok / Ollama / Self-Hosted route every agent to one provider and clear all per-agent overrides below. Mix keeps whatever's picked in the table.">
<Label className="text-sm font-medium">Routing mode</Label>
</HelpTip>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-2">
<ModeButton
icon={<ShieldCheck className="h-4 w-4" />}
@@ -584,9 +599,11 @@ export function AIRoutingCard() {
<Separator />
<section className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">
Per-agent override (mix mode)
</Label>
<HelpTip label="A blank row falls back to that agent's role default, then the last global mode's model — not a separate 'mix default'.">
<Label className="text-sm font-medium">
Per-agent override (mix mode)
</Label>
</HelpTip>
<Button size="sm" onClick={saveMix} disabled={applyMode.isPending}>
{applyMode.isPending ? "Saving…" : "Save mix"}
</Button>
@@ -32,6 +32,7 @@ import {
import { XCredentialsForm } from "@/components/settings/x-credentials-card";
import { TikTokCredentialsForm } from "@/components/settings/tiktok-credentials-card";
import { TelegramCredentialsForm } from "@/components/settings/telegram-credentials-card";
import { HelpTip } from "@/components/ui/help-tip";
import { cn } from "@/lib/utils";
import { Flag, ChevronDown, ChevronRight } from "lucide-react";
import { toast } from "sonner";
@@ -98,6 +99,69 @@ const FLAG_DESCRIPTIONS: Record<string, string> = {
"Best-effort Telegram DMs to you alongside in-app notifications when a task is escalated for your approval or completes. Server-side fan-out — never blocks the in-app notification. Stays inert until you set bot-token + chat-id credentials in the Telegram card below.",
};
// Short hover tips for the label of each flag row — a terser companion to
// FLAG_DESCRIPTIONS' always-visible paragraph above. Keyed by settings-key,
// not label text, since label text alone is sometimes ambiguous.
const FLAG_TOOLTIPS: Record<string, string> = {
external_pr_enabled:
"Reviews inbound external/fork pull requests before merge.",
internal_pr_enabled: "Safety-reviews internal PRs before merge.",
research_enabled: "Lets Board and PM agents research the web for planning.",
strategy_engine_enabled: "Runs the background company strategy engine.",
self_heal_enabled:
"Watches RoboCo's own CI and flags regressions; never auto-fixes.",
self_heal_originate_enabled:
"On a CI regression, opens a fix task held for CEO approval.",
provisioning_enabled: "Auto-provisions infra for approved Board pitches.",
toolchain_match_enabled:
"Matches an agent's runtime toolchain to its project.",
conventions_enabled: "Enforces each project's architectural placement rules.",
possibilities_matrix_enabled:
"Fast-paths work that's already been done elsewhere.",
rag_auto_update_enabled:
"Keeps the RAG knowledge index automatically refreshed.",
transcript_prune_enabled:
"Prunes old agent transcripts per the retention setting.",
gateway_health_enabled:
"Recycles an agent whose tool gateway is broken but alive.",
ci_watch_enabled:
"Watches opted-in projects' CI and opens a fix task on red builds.",
dep_update_enabled:
"Weekly checks for dependency upgrades and opens a task if one applies.",
env_sync_enabled: "Cascades prod branch changes down to dev branches.",
docs_sync_enabled:
"Opens a docs-update task when a release drifts from the docs.",
release_manager_enabled:
"Assembles a release proposal for CEO approval; never auto-publishes.",
org_memory_enabled:
"Captures task learnings and re-injects them into future briefings.",
sandbox_db_enabled:
"Gives agents on-demand disposable DB/Redis sandboxes for testing.",
routing_strict:
"Fails closed instead of silently falling back on a disabled provider.",
x_engine_enabled: "Drafts X posts for CEO review; nothing auto-posts.",
x_replies_enabled: "Drafts replies to X mentions (needs a paid X API tier).",
x_feature_spotlight_enabled:
"Periodically drafts a spotlight post for an under-publicized feature.",
video_engine_enabled:
"Authors and renders motion-graphics videos for social posts.",
video_on_release: "Drafts a video whenever a release publishes.",
video_on_spotlight:
"Drafts a video whenever a feature spotlight is drafted.",
roadmap_engine_enabled:
"Weekly has the Board draft a themed roadmap for CEO approval.",
fable_mode_enabled: "Adopts the Fable/Ponytail behavioral doctrine fleet-wide.",
obsidian_vault_enabled:
"Projects tasks/journals/A2A into a human-readable Obsidian vault.",
vault_intake_enabled:
"Turns #roboco-tagged vault notes into board-review drafts.",
vault_report_enabled:
"Writes a weekly org metrics report note into the vault.",
vault_kb_enabled:
"Embeds the CEO's own vault notes into RAG for agent retrieval.",
telegram_enabled: "Sends CEO notification DMs over Telegram.",
};
export function FeatureFlagsCard() {
const queryClient = useQueryClient();
const [xCredsOpen, setXCredsOpen] = useState(false);
@@ -185,7 +249,14 @@ export function FeatureFlagsCard() {
>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<Label htmlFor={`flag-${flag.key}`}>{flag.label}</Label>
{/* HelpTip wraps the Label, not the Switch: Switch is a
Radix stateful trigger whose internal render spreads
props after its own literal data-state, so a
TooltipTrigger asChild wrapping it would clobber the
on/off data-state the Switch's own CSS depends on. */}
<HelpTip label={FLAG_TOOLTIPS[flag.key]}>
<Label htmlFor={`flag-${flag.key}`}>{flag.label}</Label>
</HelpTip>
<p className="text-sm text-muted-foreground">
{FLAG_DESCRIPTIONS[flag.key] ?? ""}
</p>
@@ -160,7 +160,9 @@ export function SelfHostedSection({
{/* Base URL input */}
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Base URL</Label>
<HelpTip label="Any OpenAI-compatible endpoint — e.g. Ollama, vLLM, LM Studio.">
<Label className="text-xs text-muted-foreground">Base URL</Label>
</HelpTip>
<div className="flex gap-2">
<Input
type="text"
@@ -178,10 +180,12 @@ export function SelfHostedSection({
{/* Auth token input with Eye toggle */}
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">
Auth token{" "}
<span className="text-muted-foreground/60">(optional)</span>
</Label>
<HelpTip label="Stored encrypted server-side; never displayed once saved.">
<Label className="text-xs text-muted-foreground">
Auth token{" "}
<span className="text-muted-foreground/60">(optional)</span>
</Label>
</HelpTip>
<div className="flex gap-2">
<div className="relative flex-1">
<Input
@@ -320,7 +324,17 @@ export function SelfHostedSection({
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">
Last refreshed:{" "}
<span className="font-medium">{relativeTime(lastRefreshed)}</span>
<HelpTip
label={
lastRefreshed
? new Date(lastRefreshed).toLocaleString()
: undefined
}
>
<span className="font-medium">
{relativeTime(lastRefreshed)}
</span>
</HelpTip>
</p>
<Button
variant="ghost"
@@ -353,9 +367,11 @@ export function SelfHostedSection({
{m.model_name}
</span>
</div>
<Badge variant="secondary" className="text-xs">
auto-discovered
</Badge>
<HelpTip label="Found by probing the endpoint's model list — not manually added.">
<Badge variant="secondary" className="text-xs">
auto-discovered
</Badge>
</HelpTip>
</div>
))}
</div>
@@ -6,6 +6,7 @@ import { telegramApi } from "@/lib/api";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { HelpTip } from "@/components/ui/help-tip";
import {
AlertDialog,
AlertDialogAction,
@@ -87,9 +88,13 @@ export function TelegramCredentialsForm() {
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{FIELDS.map((field) => (
<div key={field.key} className="space-y-2">
<Label htmlFor={`tg-cred-${field.key}`}>
{status?.has_credentials ? `Replace ${field.label}` : field.label}
</Label>
<HelpTip label="Stored encrypted server-side; never displayed again once saved.">
<Label htmlFor={`tg-cred-${field.key}`}>
{status?.has_credentials
? `Replace ${field.label}`
: field.label}
</Label>
</HelpTip>
<Input
id={`tg-cred-${field.key}`}
type="password"
@@ -6,6 +6,7 @@ import { videoApi } from "@/lib/api";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { HelpTip } from "@/components/ui/help-tip";
import {
AlertDialog,
AlertDialogAction,
@@ -106,9 +107,13 @@ export function TikTokCredentialsForm() {
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{FIELDS.map((field) => (
<div key={field.key} className="space-y-2">
<Label htmlFor={`tiktok-cred-${field.key}`}>
{status?.has_credentials ? `Replace ${field.label}` : field.label}
</Label>
<HelpTip label="Stored encrypted server-side; never displayed again once saved.">
<Label htmlFor={`tiktok-cred-${field.key}`}>
{status?.has_credentials
? `Replace ${field.label}`
: field.label}
</Label>
</HelpTip>
<Input
id={`tiktok-cred-${field.key}`}
type="password"
@@ -13,6 +13,7 @@ import {
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { HelpTip } from "@/components/ui/help-tip";
import { HardDrive, Save } from "lucide-react";
import { toast } from "sonner";
@@ -72,9 +73,11 @@ export function TranscriptRetentionCard() {
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="transcript-retention-days">
Retention window (days)
</Label>
<HelpTip label="Only takes effect while the transcript_prune_enabled feature flag is on.">
<Label htmlFor="transcript-retention-days">
Retention window (days)
</Label>
</HelpTip>
<Input
id="transcript-retention-days"
type="number"
@@ -6,6 +6,7 @@ import { xApi } from "@/lib/api";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { HelpTip } from "@/components/ui/help-tip";
import {
AlertDialog,
AlertDialogAction,
@@ -104,9 +105,13 @@ export function XCredentialsForm() {
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{FIELDS.map((field) => (
<div key={field.key} className="space-y-2">
<Label htmlFor={`x-cred-${field.key}`}>
{status?.has_credentials ? `Replace ${field.label}` : field.label}
</Label>
<HelpTip label="Stored encrypted server-side; never displayed again once saved.">
<Label htmlFor={`x-cred-${field.key}`}>
{status?.has_credentials
? `Replace ${field.label}`
: field.label}
</Label>
</HelpTip>
<Input
id={`x-cred-${field.key}`}
type="password"