mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user