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
+52 -5
View File
@@ -3,6 +3,11 @@
import { Suspense } from "react"; import { Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { GoalsTab } from "@/components/business/goals-tab"; import { GoalsTab } from "@/components/business/goals-tab";
import { CompanyScorecardCard } from "@/components/business/company-scorecard-card"; import { CompanyScorecardCard } from "@/components/business/company-scorecard-card";
@@ -13,7 +18,36 @@ import { PitchesTab } from "@/components/business/pitches-tab";
// Valid tab values // Valid tab values
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const TAB_VALUES = ["goals", "scorecard", "secretary", "pitches"] as const; interface TabDef {
value: "goals" | "scorecard" | "secretary" | "pitches";
label: string;
hint: string;
}
const TAB_DEFS: TabDef[] = [
{
value: "goals",
label: "Goals",
hint: "CEO-owned charter — north star, brand voice, objectives, constraints",
},
{
value: "scorecard",
label: "Scorecard",
hint: "Live delivery, spend, and speed metrics against the charter",
},
{
value: "secretary",
label: "Secretary",
hint: "Chat with your chief-of-staff and confirm or reject pending directives",
},
{
value: "pitches",
label: "Pitches",
hint: "Board-authored product pitches awaiting your decision",
},
];
const TAB_VALUES = TAB_DEFS.map((t) => t.value);
type TabValue = (typeof TAB_VALUES)[number]; type TabValue = (typeof TAB_VALUES)[number];
function isValidTab(value: string | null): value is TabValue { function isValidTab(value: string | null): value is TabValue {
@@ -50,10 +84,23 @@ function BusinessPageContent() {
<Tabs value={activeTab} onValueChange={handleTabChange}> <Tabs value={activeTab} onValueChange={handleTabChange}>
<TabsList> <TabsList>
<TabsTrigger value="goals">Goals</TabsTrigger> {TAB_DEFS.map((tab) => (
<TabsTrigger value="scorecard">Scorecard</TabsTrigger> <Tooltip key={tab.value}>
<TabsTrigger value="secretary">Secretary</TabsTrigger> <TooltipTrigger asChild>
<TabsTrigger value="pitches">Pitches</TabsTrigger> {/* TooltipTrigger's asChild Slot merge clobbers TabsTrigger's
own data-state; re-assert the real selection state
explicitly (see task-detail/task-tabs.tsx) so the
data-[state=active] styling still fires. */}
<TabsTrigger
value={tab.value}
data-state={tab.value === activeTab ? "active" : "inactive"}
>
{tab.label}
</TabsTrigger>
</TooltipTrigger>
<TooltipContent>{tab.hint}</TooltipContent>
</Tooltip>
))}
</TabsList> </TabsList>
<TabsContent value="goals" className="mt-4"> <TabsContent value="goals" className="mt-4">
@@ -16,6 +16,7 @@ import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { OfflineState } from "@/components/ui/offline-state"; import { OfflineState } from "@/components/ui/offline-state";
import { HelpTip } from "@/components/ui/help-tip";
import { usePageRefresh } from "@/hooks"; import { usePageRefresh } from "@/hooks";
import { import {
Bell, Bell,
@@ -64,6 +65,21 @@ const typeIcons: Record<NotificationType, React.ReactNode> = {
[NotificationType.MENTION]: <AtSign className="h-4 w-4 text-indigo-500" />, [NotificationType.MENTION]: <AtSign className="h-4 w-4 text-indigo-500" />,
}; };
// The type icon is the only place a notification's category is conveyed —
// subject/body/priority don't repeat it, so it needs a decode on hover.
const typeLabels: Record<NotificationType, string> = {
[NotificationType.TASK_ASSIGNMENT]: "Task assignment",
[NotificationType.PRIORITY_CHANGE]: "Priority change",
[NotificationType.BLOCKER_ESCALATION]: "Blocker escalation",
[NotificationType.REVIEW_REQUEST]: "Review request",
[NotificationType.DOCUMENTATION_REQUEST]: "Documentation request",
[NotificationType.APPROVAL]: "Approval request",
[NotificationType.ALERT]: "Alert",
[NotificationType.BROADCAST]: "Broadcast",
[NotificationType.KNOWLEDGE_SHARE]: "Knowledge share",
[NotificationType.MENTION]: "Mention",
};
const priorityColors: Record<NotificationPriority, string> = { const priorityColors: Record<NotificationPriority, string> = {
[NotificationPriority.NORMAL]: [NotificationPriority.NORMAL]:
"bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300", "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300",
@@ -92,7 +108,9 @@ function NotificationCard({
> >
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<HelpTip label={typeLabels[notification.type]}>
<div className="mt-1">{typeIcons[notification.type]}</div> <div className="mt-1">{typeIcons[notification.type]}</div>
</HelpTip>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<span className="font-medium">{notification.subject}</span> <span className="font-medium">{notification.subject}</span>
@@ -116,12 +134,14 @@ function NotificationCard({
href={`/tasks/${notification.related_task_id}`} href={`/tasks/${notification.related_task_id}`}
prefetch={false} prefetch={false}
> >
<HelpTip label={notification.related_task_id}>
<Badge <Badge
variant="outline" variant="outline"
className="text-xs hover:bg-muted cursor-pointer" className="text-xs hover:bg-muted cursor-pointer"
> >
Task #{notification.related_task_id.slice(0, 8)} Task #{notification.related_task_id.slice(0, 8)}
</Badge> </Badge>
</HelpTip>
</Link> </Link>
)} )}
</div> </div>
@@ -130,8 +150,11 @@ function NotificationCard({
</div> </div>
<div className="flex items-center justify-between mt-3"> <div className="flex items-center justify-between mt-3">
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
From: {notification.from_agent.slice(0, 8)} {" "} From:{" "}
{formatDistanceToNow(new Date(notification.timestamp))} ago <HelpTip label={notification.from_agent}>
<span>{notification.from_agent.slice(0, 8)}</span>
</HelpTip>{" "}
{formatDistanceToNow(new Date(notification.timestamp))} ago
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{!notification.is_read && ( {!notification.is_read && (
@@ -254,9 +277,11 @@ function NotificationsPageContent() {
<div className="grid grid-cols-3 gap-2 sm:gap-4"> <div className="grid grid-cols-3 gap-2 sm:gap-4">
<Card className="py-4 sm:py-6"> <Card className="py-4 sm:py-6">
<CardHeader className="px-3 pb-2 sm:px-6"> <CardHeader className="px-3 pb-2 sm:px-6">
<HelpTip label="Scoped to the active tab's filter, not the full mailbox">
<CardTitle className="text-sm font-medium text-muted-foreground"> <CardTitle className="text-sm font-medium text-muted-foreground">
Total Total
</CardTitle> </CardTitle>
</HelpTip>
</CardHeader> </CardHeader>
<CardContent className="px-3 sm:px-6"> <CardContent className="px-3 sm:px-6">
<div className="text-2xl font-bold">{data.total}</div> <div className="text-2xl font-bold">{data.total}</div>
@@ -264,10 +289,12 @@ function NotificationsPageContent() {
</Card> </Card>
<Card className="py-4 sm:py-6"> <Card className="py-4 sm:py-6">
<CardHeader className="px-3 pb-2 sm:px-6"> <CardHeader className="px-3 pb-2 sm:px-6">
<HelpTip label="Unread count within the active tab's filter">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-1"> <CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-1">
<Mail className="h-4 w-4" /> <Mail className="h-4 w-4" />
Unread Unread
</CardTitle> </CardTitle>
</HelpTip>
</CardHeader> </CardHeader>
<CardContent className="px-3 sm:px-6"> <CardContent className="px-3 sm:px-6">
<div className="text-2xl font-bold text-blue-600"> <div className="text-2xl font-bold text-blue-600">
@@ -277,10 +304,12 @@ function NotificationsPageContent() {
</Card> </Card>
<Card className="py-4 sm:py-6"> <Card className="py-4 sm:py-6">
<CardHeader className="px-3 pb-2 sm:px-6"> <CardHeader className="px-3 pb-2 sm:px-6">
<HelpTip label="Awaiting acknowledgement within the active tab's filter — switch to All to see the full count">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-1"> <CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-1">
<Bell className="h-4 w-4" /> <Bell className="h-4 w-4" />
Pending Ack Pending Ack
</CardTitle> </CardTitle>
</HelpTip>
</CardHeader> </CardHeader>
<CardContent className="px-3 sm:px-6"> <CardContent className="px-3 sm:px-6">
<div className="text-2xl font-bold text-red-600"> <div className="text-2xl font-bold text-red-600">
@@ -116,4 +116,29 @@ describe("SettingsPage — client-only prefs (store-driven, no server round trip
fireEvent.click(soundSwitch); fireEvent.click(soundSwitch);
expect(mockStore.setSoundEnabled).not.toHaveBeenCalled(); expect(mockStore.setSoundEnabled).not.toHaveBeenCalled();
}); });
// W9-5 follow-up: the disabled Refresh Interval / Sound Alerts controls
// now carry a tooltip on their label explaining why — but only while
// actually disabled. TooltipTrigger always stamps data-state onto its
// asChild target, so its presence/absence proxies "is this label
// tooltip-wrapped" without simulating hover.
it("Refresh Interval and Sound Alerts labels carry a disabled-reason tooltip only while disabled", () => {
const { rerender } = render(<SettingsPage />); // autoRefresh: false, notificationsEnabled: true (reset default)
expect(
screen.getByText("Refresh Interval").getAttribute("data-state"),
).toBe("closed");
expect(screen.getByText("Sound Alerts").getAttribute("data-state")).toBe(
null,
);
mockStore.autoRefresh = true;
mockStore.notificationsEnabled = false;
rerender(<SettingsPage />);
expect(
screen.getByText("Refresh Interval").getAttribute("data-state"),
).toBe(null);
expect(screen.getByText("Sound Alerts").getAttribute("data-state")).toBe(
"closed",
);
});
}); });
+30 -1
View File
@@ -20,6 +20,7 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { HelpTip } from "@/components/ui/help-tip";
import { Settings, Palette, Bell, Server, User } from "lucide-react"; import { Settings, Palette, Bell, Server, User } from "lucide-react";
import { API_URL, WS_URL } from "@/lib/constants"; import { API_URL, WS_URL } from "@/lib/constants";
import { TranscriptRetentionCard } from "@/components/settings/transcript-retention-card"; import { TranscriptRetentionCard } from "@/components/settings/transcript-retention-card";
@@ -75,9 +76,11 @@ export default function SettingsPage() {
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Chief Executive Officer Chief Executive Officer
</p> </p>
<p className="text-xs text-muted-foreground mt-1"> <HelpTip label="The CEO's fixed agent id — used to attribute your notifications, notes, and approvals across the API.">
<p className="text-xs text-muted-foreground mt-1 w-fit">
Agent ID: 00000000-0000-0000-0000-000000000001 Agent ID: 00000000-0000-0000-0000-000000000001
</p> </p>
</HelpTip>
</div> </div>
</div> </div>
</CardContent> </CardContent>
@@ -97,7 +100,9 @@ export default function SettingsPage() {
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<HelpTip label="Saved to this browser only — doesn't sync across devices.">
<Label>Theme</Label> <Label>Theme</Label>
</HelpTip>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Select your preferred color scheme Select your preferred color scheme
</p> </p>
@@ -142,7 +147,9 @@ export default function SettingsPage() {
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<HelpTip label="Also enables the Refresh Interval picker below.">
<Label>Auto Refresh</Label> <Label>Auto Refresh</Label>
</HelpTip>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Periodically re-fetch the current page&apos;s data Periodically re-fetch the current page&apos;s data
</p> </p>
@@ -152,7 +159,15 @@ export default function SettingsPage() {
<Separator /> <Separator />
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<HelpTip
label={
!autoRefresh
? "Disabled — turn on Auto Refresh above to pick an interval."
: undefined
}
>
<Label>Refresh Interval</Label> <Label>Refresh Interval</Label>
</HelpTip>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
How often to fetch new data (seconds) How often to fetch new data (seconds)
</p> </p>
@@ -191,7 +206,9 @@ export default function SettingsPage() {
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<HelpTip label="Also gates Sound Alerts below.">
<Label>Enable Notifications</Label> <Label>Enable Notifications</Label>
</HelpTip>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Toast + bell for incoming agent notifications Toast + bell for incoming agent notifications
</p> </p>
@@ -204,7 +221,15 @@ export default function SettingsPage() {
<Separator /> <Separator />
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<HelpTip
label={
!notificationsEnabled
? "Disabled — turn on Enable Notifications above first."
: undefined
}
>
<Label>Sound Alerts</Label> <Label>Sound Alerts</Label>
</HelpTip>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Chime on new notifications Chime on new notifications
</p> </p>
@@ -231,11 +256,15 @@ export default function SettingsPage() {
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<HelpTip label="Read-only — set via NEXT_PUBLIC_API_URL at build time.">
<Label>API URL</Label> <Label>API URL</Label>
</HelpTip>
<Input value={API_URL} readOnly className="bg-muted" /> <Input value={API_URL} readOnly className="bg-muted" />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<HelpTip label="Read-only — set via NEXT_PUBLIC_WS_URL at build time.">
<Label>WebSocket URL</Label> <Label>WebSocket URL</Label>
</HelpTip>
<Input value={WS_URL} readOnly className="bg-muted" /> <Input value={WS_URL} readOnly className="bg-muted" />
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@@ -11,6 +11,7 @@ import {
} from "@/components/ui/card"; } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { OfflineState } from "@/components/ui/offline-state"; import { OfflineState } from "@/components/ui/offline-state";
import { HelpTip } from "@/components/ui/help-tip";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Loading skeleton — three grouped skeleton blocks // Loading skeleton — three grouped skeleton blocks
@@ -56,9 +57,12 @@ function ScorecardSkeleton() {
// Section header helper // Section header helper
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function SectionLabel({ children }: { children: React.ReactNode }) { function SectionLabel({ children, ...props }: React.ComponentProps<"p">) {
return ( return (
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> <p
className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"
{...props}
>
{children} {children}
</p> </p>
); );
@@ -71,14 +75,17 @@ function SectionLabel({ children }: { children: React.ReactNode }) {
interface DeliveryMetricProps { interface DeliveryMetricProps {
label: string; label: string;
value: number; value: number;
hint: string;
} }
function DeliveryMetric({ label, value }: DeliveryMetricProps) { function DeliveryMetric({ label, value, hint }: DeliveryMetricProps) {
return ( return (
<HelpTip label={hint}>
<div className="rounded-lg border bg-card p-3 text-center"> <div className="rounded-lg border bg-card p-3 text-center">
<div className="text-2xl font-bold tabular-nums">{value}</div> <div className="text-2xl font-bold tabular-nums">{value}</div>
<div className="text-xs text-muted-foreground mt-0.5">{label}</div> <div className="text-xs text-muted-foreground mt-0.5">{label}</div>
</div> </div>
</HelpTip>
); );
} }
@@ -91,12 +98,25 @@ function DeliverySection({ delivery }: DeliverySectionProps) {
<div className="space-y-2"> <div className="space-y-2">
<SectionLabel>Delivery</SectionLabel> <SectionLabel>Delivery</SectionLabel>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<DeliveryMetric label="In flight" value={delivery.in_flight} /> <DeliveryMetric
<DeliveryMetric label="Blocked" value={delivery.blocked} /> label="In flight"
<DeliveryMetric label="Awaiting CEO" value={delivery.awaiting_ceo} /> value={delivery.in_flight}
hint="Tasks currently claimed or in progress"
/>
<DeliveryMetric
label="Blocked"
value={delivery.blocked}
hint="Tasks stuck on an external dependency"
/>
<DeliveryMetric
label="Awaiting CEO"
value={delivery.awaiting_ceo}
hint="Tasks escalated to you for final approval"
/>
<DeliveryMetric <DeliveryMetric
label="Done (30 d)" label="Done (30 d)"
value={delivery.completed_30d ?? 0} value={delivery.completed_30d ?? 0}
hint="Tasks completed in the last 30 days"
/> />
</div> </div>
</div> </div>
@@ -141,7 +161,9 @@ function SpendSection({ spend }: SpendSectionProps) {
</div> </div>
)} )}
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<HelpTip label="Set via operating_policy.monthly_budget_cap on the Goals tab">
<span className="text-muted-foreground">Monthly cap</span> <span className="text-muted-foreground">Monthly cap</span>
</HelpTip>
{monthly_budget_cap_usd === null ? ( {monthly_budget_cap_usd === null ? (
<span className="text-muted-foreground italic"> <span className="text-muted-foreground italic">
No budget cap set No budget cap set
@@ -183,7 +205,9 @@ function SpeedSection({ medianLeadTimeHours }: SpeedSectionProps) {
<SectionLabel>Speed</SectionLabel> <SectionLabel>Speed</SectionLabel>
<div className="rounded-lg border p-3"> <div className="rounded-lg border p-3">
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<HelpTip label="Hours from task creation to completion, over tasks completed in the last 30 days">
<span className="text-muted-foreground">Median lead time</span> <span className="text-muted-foreground">Median lead time</span>
</HelpTip>
{hasData ? ( {hasData ? (
<span className="font-medium tabular-nums"> <span className="font-medium tabular-nums">
{medianLeadTimeHours.toFixed(1)}h median &mdash; target: {medianLeadTimeHours.toFixed(1)}h median &mdash; target:
@@ -210,7 +234,9 @@ function StubObjectivesSection() {
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<HelpTip label="Placeholder — not yet wired to the Goals tab's Objectives list">
<SectionLabel>Objectives</SectionLabel> <SectionLabel>Objectives</SectionLabel>
</HelpTip>
<div className="space-y-2"> <div className="space-y-2">
{stubs.map((stub) => ( {stubs.map((stub) => (
<div <div
@@ -22,6 +22,7 @@ import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { OfflineState } from "@/components/ui/offline-state"; import { OfflineState } from "@/components/ui/offline-state";
import { HelpTip } from "@/components/ui/help-tip";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Helpers // Helpers
@@ -309,7 +310,9 @@ function GoalsForm({ goals, refetch }: GoalsFormProps) {
{/* Brand voice */} {/* Brand voice */}
<div className="space-y-2"> <div className="space-y-2">
<HelpTip label="Feeds every X/TikTok draft and the Head of Marketing's feature-spotlight posts">
<Label htmlFor="brand-voice">Brand voice</Label> <Label htmlFor="brand-voice">Brand voice</Label>
</HelpTip>
<Textarea <Textarea
id="brand-voice" id="brand-voice"
rows={3} rows={3}
@@ -335,7 +338,9 @@ function GoalsForm({ goals, refetch }: GoalsFormProps) {
{/* Objectives */} {/* Objectives */}
<div className="space-y-2"> <div className="space-y-2">
<HelpTip label="Free-form KPI rows injected into every agent's briefing — not yet reflected in the Scorecard tab's Objectives section, which is a placeholder">
<Label>Objectives</Label> <Label>Objectives</Label>
</HelpTip>
<ObjectivesEditor <ObjectivesEditor
items={objectivesVal} items={objectivesVal}
onChange={(items) => setObjectives(items)} onChange={(items) => setObjectives(items)}
@@ -345,7 +350,9 @@ function GoalsForm({ goals, refetch }: GoalsFormProps) {
{/* Operating policy */} {/* Operating policy */}
<div className="space-y-2"> <div className="space-y-2">
<HelpTip label="Free-form operational key-values — set monthly_budget_cap (a number, in USD) here to enable the Scorecard tab's over-budget flag">
<Label>Operating policy</Label> <Label>Operating policy</Label>
</HelpTip>
<PolicyEditor <PolicyEditor
policy={policyVal} policy={policyVal}
onChange={(p) => setPolicy(p)} onChange={(p) => setPolicy(p)}
@@ -7,6 +7,7 @@ import { Check, X } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { HelpTip } from "@/components/ui/help-tip";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { OfflineState } from "@/components/ui/offline-state"; import { OfflineState } from "@/components/ui/offline-state";
import { RequiredNotesDialog } from "@/components/ui/required-notes-dialog"; import { RequiredNotesDialog } from "@/components/ui/required-notes-dialog";
@@ -62,6 +63,12 @@ interface PitchCardProps {
busy: boolean; busy: boolean;
} }
const PITCH_STATUS_HINTS: Record<string, string> = {
proposed: "Awaiting your approve/reject decision",
provisioned: "Approved — a product and workspace were auto-provisioned",
rejected: "Rejected — no product or workspace was created",
};
function PitchCard({ pitch, onApprove, onReject, busy }: PitchCardProps) { function PitchCard({ pitch, onApprove, onReject, busy }: PitchCardProps) {
const [approveOpen, setApproveOpen] = useState(false); const [approveOpen, setApproveOpen] = useState(false);
const [rejectOpen, setRejectOpen] = useState(false); const [rejectOpen, setRejectOpen] = useState(false);
@@ -73,9 +80,11 @@ function PitchCard({ pitch, onApprove, onReject, busy }: PitchCardProps) {
<CardHeader> <CardHeader>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<CardTitle className="text-lg">{pitch.title}</CardTitle> <CardTitle className="text-lg">{pitch.title}</CardTitle>
<HelpTip label={PITCH_STATUS_HINTS[pitch.status]}>
<Badge variant={proposed ? "default" : "secondary"}> <Badge variant={proposed ? "default" : "secondary"}>
{pitch.status} {pitch.status}
</Badge> </Badge>
</HelpTip>
</div> </div>
{pitch.target_cells.length > 0 && ( {pitch.target_cells.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1"> <div className="flex flex-wrap gap-1 mt-1">
@@ -168,7 +168,9 @@ export function ConventionsTab({ projectId }: { projectId: string }) {
const moduleBoundaries = ( const moduleBoundaries = (
<Card className="lg:flex lg:flex-col"> <Card className="lg:flex lg:flex-col">
<CardHeader> <CardHeader>
<CardTitle className="text-sm">Module boundaries</CardTitle> <HelpTip label="The effective map: auto-derived defaults from a scan of this repo, overlaid by any committed .roboco/conventions.yml. Editing here and saving writes it back as that committed file.">
<CardTitle className="text-sm w-fit">Module boundaries</CardTitle>
</HelpTip>
<CardDescription> <CardDescription>
Which definition kinds are forbidden in each module. Click a kind to Which definition kinds are forbidden in each module. Click a kind to
toggle it. toggle it.
@@ -335,9 +337,17 @@ export function ConventionsTab({ projectId }: { projectId: string }) {
placeholder="rule-id" placeholder="rule-id"
onChange={(e) => updateCustom(index, { id: e.target.value })} onChange={(e) => updateCustom(index, { id: e.target.value })}
/> />
<HelpTip
label={
rule.level === "block"
? "Block: a match refuses the conventions gate"
: "Warn: a match is advisory only, never blocks the gate"
}
>
<span className="w-10 text-right text-xs text-muted-foreground"> <span className="w-10 text-right text-xs text-muted-foreground">
{rule.level} {rule.level}
</span> </span>
</HelpTip>
<Switch <Switch
checked={rule.level === "block"} checked={rule.level === "block"}
onCheckedChange={(checked) => onCheckedChange={(checked) =>
@@ -462,6 +472,7 @@ export function ConventionsTab({ projectId }: { projectId: string }) {
{recentViolations} {recentViolations}
<div className="flex justify-between"> <div className="flex justify-between">
<HelpTip label={restore.isPending ? "Restoring…" : null}>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -470,6 +481,16 @@ export function ConventionsTab({ projectId }: { projectId: string }) {
> >
Restore from last-good Restore from last-good
</Button> </Button>
</HelpTip>
<HelpTip
label={
save.isPending
? "Saving…"
: draft == null && !usingDefaults
? "Edit a module, rule, waiver, or custom rule above to enable saving."
: null
}
>
<Button <Button
size="sm" size="sm"
disabled={(draft == null && !usingDefaults) || save.isPending} disabled={(draft == null && !usingDefaults) || save.isPending}
@@ -479,6 +500,7 @@ export function ConventionsTab({ projectId }: { projectId: string }) {
? "Save defaults to repo" ? "Save defaults to repo"
: "Save to repo"} : "Save to repo"}
</Button> </Button>
</HelpTip>
</div> </div>
</div> </div>
); );
@@ -19,6 +19,7 @@ import {
CollapsibleContent, CollapsibleContent,
CollapsibleTrigger, CollapsibleTrigger,
} from "@/components/ui/collapsible"; } from "@/components/ui/collapsible";
import { HelpTip } from "@/components/ui/help-tip";
import { import {
AtSign, AtSign,
ChevronDown, ChevronDown,
@@ -30,16 +31,31 @@ import {
const HISTORY_LIMIT = 50; const HISTORY_LIMIT = 50;
const PLATFORM_LABELS: Record<string, string> = { x: "X", tiktok: "TikTok" }; const PLATFORM_LABELS: Record<string, string> = { x: "X", tiktok: "TikTok" };
const VIDEO_HINT =
"Rendered by the video pipeline — from a release, a feature spotlight, or an on-demand request";
type UnifiedRow = type UnifiedRow =
| { kind: "x"; entry: XPostHistoryEntry } | { kind: "x"; entry: XPostHistoryEntry }
| { kind: "video"; entry: VideoPostHistoryEntry }; | { kind: "video"; entry: VideoPostHistoryEntry };
function xKindMeta(source: XPostHistoryEntry["source"]) { function xKindMeta(source: XPostHistoryEntry["source"]) {
if (source === "x_post") return { label: "X post", icon: Rocket }; if (source === "x_post")
return {
label: "X post",
icon: Rocket,
hint: "Drafted automatically when a release publishes",
};
if (source === "x_feature") if (source === "x_feature")
return { label: "Feature spotlight", icon: Sparkles }; return {
return { label: "X reply", icon: AtSign }; label: "Feature spotlight",
icon: Sparkles,
hint: "Drafted periodically by the Head of Marketing's feature-spotlight sweep",
};
return {
label: "X reply",
icon: AtSign,
hint: "Drafted automatically in reply to a meaningful mention on X",
};
} }
// One unified row: X entries link the posted tweet / show the reject reason; // One unified row: X entries link the posted tweet / show the reject reason;
@@ -48,9 +64,13 @@ function xKindMeta(source: XPostHistoryEntry["source"]) {
function UnifiedHistoryRow({ row }: { row: UnifiedRow }) { function UnifiedHistoryRow({ row }: { row: UnifiedRow }) {
const posted = row.entry.status === "completed"; const posted = row.entry.status === "completed";
const statusBadge = posted ? ( const statusBadge = posted ? (
<HelpTip label="Approved and successfully posted">
<Badge className="bg-green-600 hover:bg-green-600">Posted</Badge> <Badge className="bg-green-600 hover:bg-green-600">Posted</Badge>
</HelpTip>
) : ( ) : (
<HelpTip label="Rejected — this draft was never posted">
<Badge variant="destructive">Rejected</Badge> <Badge variant="destructive">Rejected</Badge>
</HelpTip>
); );
const timestamp = ( const timestamp = (
<span className="ml-auto text-xs text-muted-foreground"> <span className="ml-auto text-xs text-muted-foreground">
@@ -63,8 +83,12 @@ function UnifiedHistoryRow({ row }: { row: UnifiedRow }) {
return ( return (
<div className="rounded-lg border p-3 text-sm"> <div className="rounded-lg border p-3 text-sm">
<div className="mb-1 flex flex-wrap items-center gap-2"> <div className="mb-1 flex flex-wrap items-center gap-2">
<HelpTip label={meta.hint}>
<span className="inline-flex items-center gap-1.5">
<meta.icon className="h-4 w-4 text-muted-foreground" /> <meta.icon className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{meta.label}</span> <span className="font-medium">{meta.label}</span>
</span>
</HelpTip>
{statusBadge} {statusBadge}
{timestamp} {timestamp}
</div> </div>
@@ -92,10 +116,16 @@ function UnifiedHistoryRow({ row }: { row: UnifiedRow }) {
return ( return (
<div className="rounded-lg border p-3 text-sm"> <div className="rounded-lg border p-3 text-sm">
<div className="mb-1 flex flex-wrap items-center gap-2"> <div className="mb-1 flex flex-wrap items-center gap-2">
<HelpTip label={VIDEO_HINT}>
<span className="inline-flex items-center gap-1.5">
<Film className="h-4 w-4 text-muted-foreground" /> <Film className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">Video</span> <span className="font-medium">Video</span>
</span>
</HelpTip>
{row.entry.occasion && ( {row.entry.occasion && (
<HelpTip label="The occasion/event this video was drafted for">
<Badge variant="outline">{row.entry.occasion}</Badge> <Badge variant="outline">{row.entry.occasion}</Badge>
</HelpTip>
)} )}
{statusBadge} {statusBadge}
{timestamp} {timestamp}
@@ -8,6 +8,7 @@ import {
derivePipelineStage, derivePipelineStage,
pipelineStageColor, pipelineStageColor,
pipelineStageLabel, pipelineStageLabel,
type PipelineStage,
} from "./video-pipeline-utils"; } from "./video-pipeline-utils";
import { RerenderControl } from "@/components/dashboard/video-rerender-control"; import { RerenderControl } from "@/components/dashboard/video-rerender-control";
import { import {
@@ -19,8 +20,27 @@ import {
} from "@/components/ui/card"; } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { HelpTip } from "@/components/ui/help-tip";
import { Film } from "lucide-react"; import { Film } from "lucide-react";
// Per-stage explanation for the stage chip — lives here (not in
// video-pipeline-utils.ts) since that file is pure derivation logic with its
// own dedicated unit tests.
function stageHint(stage: PipelineStage): string {
switch (stage.kind) {
case "authoring":
return "A developer is building this video's composition";
case "in_review":
return "The authoring PR is in QA / PR / PM review before assembly";
case "awaiting_approval":
return "Rendered and waiting on your approval — open the task to review";
case "rendering":
return "The renderer is producing the 9:16 and 1:1 cuts, retrying on failure";
case "render_failed":
return "The renderer gave up after all retries — re-render to try again";
}
}
// One row: title + occasion + a colored stage chip. The stage chip is // One row: title + occasion + a colored stage chip. The stage chip is
// derived (never fetched) from status + render_status/render_attempts — // derived (never fetched) from status + render_status/render_attempts —
// see video-pipeline-utils.ts, unit-tested directly there. Only the // see video-pipeline-utils.ts, unit-tested directly there. Only the
@@ -40,10 +60,16 @@ function PipelineRow({ item }: { item: VideoPipelineItem }) {
<div className="flex flex-wrap items-center gap-2 rounded-lg border p-3 text-sm"> <div className="flex flex-wrap items-center gap-2 rounded-lg border p-3 text-sm">
<Film className="h-4 w-4 shrink-0 text-muted-foreground" /> <Film className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="font-medium">{item.title}</span> <span className="font-medium">{item.title}</span>
{item.occasion && <Badge variant="outline">{item.occasion}</Badge>} {item.occasion && (
<HelpTip label="The occasion/event this video was drafted for">
<Badge variant="outline">{item.occasion}</Badge>
</HelpTip>
)}
<HelpTip label={stageHint(stage)}>
<Badge className={`${pipelineStageColor(stage)} text-white`}> <Badge className={`${pipelineStageColor(stage)} text-white`}>
{pipelineStageLabel(stage)} {pipelineStageLabel(stage)}
</Badge> </Badge>
</HelpTip>
{stage.kind === "awaiting_approval" && ( {stage.kind === "awaiting_approval" && (
<Link <Link
href={`/tasks/${item.task_id}`} href={`/tasks/${item.task_id}`}
@@ -31,6 +31,7 @@ import {
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { HelpTip } from "@/components/ui/help-tip";
import { ProjectSelector } from "@/components/projects/project-selector"; import { ProjectSelector } from "@/components/projects/project-selector";
import { useProjects } from "@/hooks/use-projects"; import { useProjects } from "@/hooks/use-projects";
import { RerenderControl } from "@/components/dashboard/video-rerender-control"; import { RerenderControl } from "@/components/dashboard/video-rerender-control";
@@ -47,7 +48,21 @@ const REQUEST_PLATFORMS = ["x", "tiktok"] as const;
// Only one source reaches this queue today; a function (not a literal) // Only one source reaches this queue today; a function (not a literal)
// mirrors XPostQueue's sourceMeta pattern and costs nothing to extend later. // mirrors XPostQueue's sourceMeta pattern and costs nothing to extend later.
function sourceMeta() { function sourceMeta() {
return { label: "Video", icon: Film }; return {
label: "Video",
icon: Film,
hint: "Rendered by the video pipeline — from a release, a feature spotlight, or an on-demand request",
};
}
// Explains what Approve does, or why it's disabled — mirrors x-post-queue's
// approveHint. Always non-empty (see that function's comment for why: a
// null/string toggle on HelpTip's label unmounts the wrapped child).
function approveHint(approving: boolean, overLimit: boolean): string {
if (approving) return "Already posting this draft";
if (overLimit)
return "An edited caption is over its platform's character limit — trim to enable";
return "Post this draft to the selected platforms";
} }
function describeExecuteResult(result: VideoPostExecuteResult): string { function describeExecuteResult(result: VideoPostExecuteResult): string {
@@ -201,9 +216,17 @@ function VideoPostRow({
return ( return (
<div className="rounded-lg border p-4 transition-colors hover:bg-muted/50"> <div className="rounded-lg border p-4 transition-colors hover:bg-muted/50">
<div className="mb-3 flex flex-wrap items-center gap-2"> <div className="mb-3 flex flex-wrap items-center gap-2">
<HelpTip label={meta.hint}>
<span className="inline-flex items-center gap-1.5">
<meta.icon className="h-4 w-4 text-muted-foreground" /> <meta.icon className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{meta.label}</span> <span className="font-medium">{meta.label}</span>
{post.occasion && <Badge variant="outline">{post.occasion}</Badge>} </span>
</HelpTip>
{post.occasion && (
<HelpTip label="The occasion/event this video was drafted for">
<Badge variant="outline">{post.occasion}</Badge>
</HelpTip>
)}
{canRerender && ( {canRerender && (
<div className="ml-auto"> <div className="ml-auto">
<RerenderControl authoringTaskId={post.source_task_id as string} /> <RerenderControl authoringTaskId={post.source_task_id as string} />
@@ -222,30 +245,40 @@ function VideoPostRow({
<div className="mb-3 space-y-2"> <div className="mb-3 space-y-2">
<div className="flex gap-2"> <div className="flex gap-2">
<HelpTip
label={
post.mp4_paths?.vertical
? "Preview the 9:16 cut"
: "9:16 hasn't rendered yet"
}
>
<Button <Button
type="button" type="button"
size="sm" size="sm"
variant={cut === "vertical" ? "default" : "outline"} variant={cut === "vertical" ? "default" : "outline"}
disabled={!post.mp4_paths?.vertical} disabled={!post.mp4_paths?.vertical}
title={
post.mp4_paths?.vertical ? undefined : "9:16 hasn't rendered yet"
}
onClick={() => setCut("vertical")} onClick={() => setCut("vertical")}
> >
9:16{!post.mp4_paths?.vertical && " (missing)"} 9:16{!post.mp4_paths?.vertical && " (missing)"}
</Button> </Button>
</HelpTip>
<HelpTip
label={
post.mp4_paths?.square
? "Preview the 1:1 cut"
: "1:1 hasn't rendered yet"
}
>
<Button <Button
type="button" type="button"
size="sm" size="sm"
variant={cut === "square" ? "default" : "outline"} variant={cut === "square" ? "default" : "outline"}
disabled={!post.mp4_paths?.square} disabled={!post.mp4_paths?.square}
title={
post.mp4_paths?.square ? undefined : "1:1 hasn't rendered yet"
}
onClick={() => setCut("square")} onClick={() => setCut("square")}
> >
1:1{!post.mp4_paths?.square && " (missing)"} 1:1{!post.mp4_paths?.square && " (missing)"}
</Button> </Button>
</HelpTip>
</div> </div>
{post.mp4_paths?.[cut] ? ( {post.mp4_paths?.[cut] ? (
<video <video
@@ -272,9 +305,11 @@ function VideoPostRow({
checked={editX} checked={editX}
onCheckedChange={(c) => setEditX(c === true)} onCheckedChange={(c) => setEditX(c === true)}
/> />
<HelpTip label="Uncheck to keep posting the caption already saved on this draft instead of your edit">
<Label htmlFor={`${post.task_id}-x-edit`} className="text-sm"> <Label htmlFor={`${post.task_id}-x-edit`} className="text-sm">
Edit X caption Edit X caption
</Label> </Label>
</HelpTip>
</div> </div>
<Textarea <Textarea
value={xCaption} value={xCaption}
@@ -283,11 +318,13 @@ function VideoPostRow({
rows={2} rows={2}
className={xOverLimit ? "border-destructive" : undefined} className={xOverLimit ? "border-destructive" : undefined}
/> />
<HelpTip label={`X's per-post character limit (${MAX_X_CAPTION_CHARS})`}>
<p <p
className={`text-right text-xs ${xOverLimit ? "text-destructive" : "text-muted-foreground"}`} className={`text-right text-xs ${xOverLimit ? "text-destructive" : "text-muted-foreground"}`}
> >
{xCaption.length}/{MAX_X_CAPTION_CHARS} {xCaption.length}/{MAX_X_CAPTION_CHARS}
</p> </p>
</HelpTip>
</div> </div>
)} )}
@@ -299,12 +336,14 @@ function VideoPostRow({
checked={editTiktok} checked={editTiktok}
onCheckedChange={(c) => setEditTiktok(c === true)} onCheckedChange={(c) => setEditTiktok(c === true)}
/> />
<HelpTip label="Uncheck to keep posting the caption already saved on this draft instead of your edit">
<Label <Label
htmlFor={`${post.task_id}-tiktok-edit`} htmlFor={`${post.task_id}-tiktok-edit`}
className="text-sm" className="text-sm"
> >
Edit TikTok caption Edit TikTok caption
</Label> </Label>
</HelpTip>
</div> </div>
<Textarea <Textarea
value={tiktokCaption} value={tiktokCaption}
@@ -313,11 +352,15 @@ function VideoPostRow({
rows={2} rows={2}
className={tiktokOverLimit ? "border-destructive" : undefined} className={tiktokOverLimit ? "border-destructive" : undefined}
/> />
<HelpTip
label={`TikTok's caption character limit (${MAX_TIKTOK_CAPTION_CHARS})`}
>
<p <p
className={`text-right text-xs ${tiktokOverLimit ? "text-destructive" : "text-muted-foreground"}`} className={`text-right text-xs ${tiktokOverLimit ? "text-destructive" : "text-muted-foreground"}`}
> >
{tiktokCaption.length}/{MAX_TIKTOK_CAPTION_CHARS} {tiktokCaption.length}/{MAX_TIKTOK_CAPTION_CHARS}
</p> </p>
</HelpTip>
</div> </div>
)} )}
</div> </div>
@@ -332,6 +375,7 @@ function VideoPostRow({
<XCircle className="mr-1 h-4 w-4" /> <XCircle className="mr-1 h-4 w-4" />
Reject Reject
</Button> </Button>
<HelpTip label={approveHint(approving, overLimit)}>
<Button <Button
size="sm" size="sm"
className="bg-green-600 hover:bg-green-700" className="bg-green-600 hover:bg-green-700"
@@ -341,6 +385,7 @@ function VideoPostRow({
<CheckCircle2 className="mr-1 h-4 w-4" /> <CheckCircle2 className="mr-1 h-4 w-4" />
Approve &amp; post Approve &amp; post
</Button> </Button>
</HelpTip>
</div> </div>
</div> </div>
); );
@@ -408,6 +453,21 @@ function RequestVideoDialog({
brief.trim().length > 0 && brief.trim().length > 0 &&
platforms.length > 0; platforms.length > 0;
// Explains what Request does, or why it's disabled — the four canSubmit
// conditions checked in order, plus the in-flight case canSubmit doesn't
// cover. Always non-empty (see approveHint's comment for why).
const requestHint = requestMutation.isPending
? "Request already in flight"
: !effectiveProjectId
? "Pick a project first"
: occasion.trim().length === 0
? "Give this video an occasion"
: brief.trim().length === 0
? "Give this video a brief"
: platforms.length === 0
? "Pick at least one platform"
: "Open a video-authoring task for this brief";
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent> <DialogContent>
@@ -476,12 +536,14 @@ function RequestVideoDialog({
<Button variant="outline" onClick={() => onOpenChange(false)}> <Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel Cancel
</Button> </Button>
<HelpTip label={requestHint}>
<Button <Button
onClick={() => requestMutation.mutate()} onClick={() => requestMutation.mutate()}
disabled={!canSubmit || requestMutation.isPending} disabled={!canSubmit || requestMutation.isPending}
> >
{requestMutation.isPending ? "Requesting..." : "Request"} {requestMutation.isPending ? "Requesting..." : "Request"}
</Button> </Button>
</HelpTip>
</DialogFooter> </DialogFooter>
</> </>
) : ( ) : (
@@ -12,6 +12,7 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { HelpTip } from "@/components/ui/help-tip";
import { RefreshCw } from "lucide-react"; import { RefreshCw } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -85,8 +86,23 @@ export function RerenderControl({
), ),
}); });
// Always non-empty: HelpTip unmounts/remounts its child when `label`
// toggles between falsy and truthy, which would lose the button's DOM
// identity right as the mutation settles (mirrors x-post-queue's
// approveHint comment).
const rerenderHint = rerenderMutation.isPending
? "Re-render in progress"
: rerenderMutation.isError
? `Re-render failed: ${
rerenderMutation.error instanceof Error
? rerenderMutation.error.message
: "unknown error"
} — click to retry`
: "Discards the current render and queues a fresh one from the same composition";
return ( return (
<> <>
<HelpTip label={rerenderHint}>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
@@ -108,6 +124,7 @@ export function RerenderControl({
? "Retry re-render" ? "Retry re-render"
: "Re-render"} : "Re-render"}
</Button> </Button>
</HelpTip>
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}> <Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
@@ -23,6 +23,7 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { HelpTip } from "@/components/ui/help-tip";
import { AtSign, CheckCircle2, Rocket, Sparkles, XCircle } from "lucide-react"; import { AtSign, CheckCircle2, Rocket, Sparkles, XCircle } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -30,10 +31,42 @@ const MAX_TWEET_CHARS = 280;
const _MIN_REASON_CHARS = 4; const _MIN_REASON_CHARS = 4;
function sourceMeta(source: XPost["source"]) { function sourceMeta(source: XPost["source"]) {
if (source === "x_post") return { label: "Release post", icon: Rocket }; if (source === "x_post")
return {
label: "Release post",
icon: Rocket,
hint: "Drafted automatically when a release publishes",
};
if (source === "x_feature") if (source === "x_feature")
return { label: "Feature spotlight", icon: Sparkles }; return {
return { label: "Mention reply", icon: AtSign }; label: "Feature spotlight",
icon: Sparkles,
hint: "Drafted periodically by the Head of Marketing's feature-spotlight sweep",
};
return {
label: "Mention reply",
icon: AtSign,
hint: "Drafted automatically in reply to a meaningful mention on X",
};
}
// Explains what Approve does, or why it's disabled — surfaced on the button
// itself so the CEO doesn't have to guess between "already posting", "over
// the limit", or "empty". Always returns a non-empty string (never null):
// HelpTip renders a bare child vs. a Tooltip-wrapped one depending on
// truthiness, and toggling that branch on a live-changing condition (like
// `approving`) unmounts/remounts the child, losing any DOM reference a
// caller captured before the state flip.
function approveHint(
approving: boolean,
overLimit: boolean,
bodyEmpty: boolean,
): string {
if (approving) return "Already posting this draft";
if (overLimit)
return `Over X's ${MAX_TWEET_CHARS}-character limit — trim the draft to enable`;
if (bodyEmpty) return "Draft body is empty";
return "Post this draft to X";
} }
function describeExecuteResult(result: XPostExecuteResult): string { function describeExecuteResult(result: XPostExecuteResult): string {
@@ -69,8 +102,12 @@ function XPostRow({
return ( return (
<div className="rounded-lg border p-4 transition-colors hover:bg-muted/50"> <div className="rounded-lg border p-4 transition-colors hover:bg-muted/50">
<div className="mb-2 flex flex-wrap items-center gap-2"> <div className="mb-2 flex flex-wrap items-center gap-2">
<HelpTip label={meta.hint}>
<span className="inline-flex items-center gap-1.5">
<meta.icon className="h-4 w-4 text-muted-foreground" /> <meta.icon className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{meta.label}</span> <span className="font-medium">{meta.label}</span>
</span>
</HelpTip>
{post.release_version && ( {post.release_version && (
<Badge variant="outline">v{post.release_version}</Badge> <Badge variant="outline">v{post.release_version}</Badge>
)} )}
@@ -92,6 +129,7 @@ function XPostRow({
rows={3} rows={3}
className={overLimit ? "border-destructive" : undefined} className={overLimit ? "border-destructive" : undefined}
/> />
<HelpTip label={`X's per-post character limit (${MAX_TWEET_CHARS})`}>
<p <p
className={`mt-1 text-right text-xs ${ className={`mt-1 text-right text-xs ${
overLimit ? "text-destructive" : "text-muted-foreground" overLimit ? "text-destructive" : "text-muted-foreground"
@@ -99,6 +137,7 @@ function XPostRow({
> >
{body.length}/{MAX_TWEET_CHARS} {body.length}/{MAX_TWEET_CHARS}
</p> </p>
</HelpTip>
<div className="mt-2 flex flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-end"> <div className="mt-2 flex flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-end">
<Button <Button
@@ -110,6 +149,9 @@ function XPostRow({
<XCircle className="mr-1 h-4 w-4" /> <XCircle className="mr-1 h-4 w-4" />
Reject Reject
</Button> </Button>
<HelpTip
label={approveHint(approving, overLimit, body.trim().length === 0)}
>
<Button <Button
size="sm" size="sm"
className="bg-green-600 hover:bg-green-700" className="bg-green-600 hover:bg-green-700"
@@ -119,6 +161,7 @@ function XPostRow({
<CheckCircle2 className="mr-1 h-4 w-4" /> <CheckCircle2 className="mr-1 h-4 w-4" />
Approve &amp; post Approve &amp; post
</Button> </Button>
</HelpTip>
</div> </div>
</div> </div>
); );
@@ -6,6 +6,7 @@ import { useKBDocuments } from "@/hooks/use-knowledge-base";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { HelpTip } from "@/components/ui/help-tip";
import { KBIndexTypeBadge } from "./kb-index-type-badge"; import { KBIndexTypeBadge } from "./kb-index-type-badge";
import { import {
FileCode, FileCode,
@@ -80,7 +81,11 @@ function KBCategoryViewInner({ category }: { category: KBIndexType }) {
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<KBIndexTypeBadge indexType={category} /> <KBIndexTypeBadge indexType={category} />
</div> </div>
<p className="text-sm font-mono truncate">{doc.source}</p> <HelpTip label={doc.source}>
<p className="text-sm font-mono truncate">
{doc.source}
</p>
</HelpTip>
<div className="flex items-center gap-2 mt-1 text-xs text-muted-foreground"> <div className="flex items-center gap-2 mt-1 text-xs text-muted-foreground">
<Clock className="h-3 w-3" /> <Clock className="h-3 w-3" />
<span> <span>
@@ -3,6 +3,8 @@
import { KBIndexType } from "@/types"; import { KBIndexType } from "@/types";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { HelpTip } from "@/components/ui/help-tip";
import { getIndexTypeDescription } from "./kb-index-type-badge";
import { import {
FileText, FileText,
MessageSquare, MessageSquare,
@@ -107,8 +109,12 @@ export function KBFilters({ selectedTypes, onTypesChange }: KBFiltersProps) {
checked={isChecked} checked={isChecked}
onCheckedChange={() => toggleType(type)} onCheckedChange={() => toggleType(type)}
/> />
<HelpTip label={getIndexTypeDescription(type)}>
<span className="flex items-center gap-2 w-fit">
{config.icon} {config.icon}
<span className="text-sm">{config.label}</span> <span className="text-sm">{config.label}</span>
</span>
</HelpTip>
</label> </label>
); );
})} })}
@@ -1,6 +1,7 @@
"use client"; "use client";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { HelpTip } from "@/components/ui/help-tip";
import { KBIndexType } from "@/types"; import { KBIndexType } from "@/types";
import { import {
FileText, FileText,
@@ -17,62 +18,77 @@ import {
const indexTypeConfig: Record< const indexTypeConfig: Record<
KBIndexType, KBIndexType,
{ label: string; color: string; icon: React.ReactNode } {
label: string;
color: string;
icon: React.ReactNode;
description: string;
}
> = { > = {
[KBIndexType.DOCUMENTATION]: { [KBIndexType.DOCUMENTATION]: {
label: "Docs", label: "Docs",
color: "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300", color: "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300",
icon: <FileText className="h-3 w-3" />, icon: <FileText className="h-3 w-3" />,
description: "READMEs, guides, and API docs indexed from the repo",
}, },
[KBIndexType.CONVERSATIONS]: { [KBIndexType.CONVERSATIONS]: {
label: "Conversations", label: "Conversations",
color: "bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300", color: "bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300",
icon: <MessageSquare className="h-3 w-3" />, icon: <MessageSquare className="h-3 w-3" />,
description: "Agent-to-agent discussion excerpts and decisions",
}, },
[KBIndexType.JOURNALS]: { [KBIndexType.JOURNALS]: {
label: "Journals", label: "Journals",
color: color:
"bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300", "bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300",
icon: <BookOpen className="h-3 w-3" />, icon: <BookOpen className="h-3 w-3" />,
description: "Agent reflections, learnings, and personal logs",
}, },
[KBIndexType.ERRORS]: { [KBIndexType.ERRORS]: {
label: "Errors", label: "Errors",
color: "bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300", color: "bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300",
icon: <AlertTriangle className="h-3 w-3" />, icon: <AlertTriangle className="h-3 w-3" />,
description: "Known error patterns and their solutions",
}, },
[KBIndexType.STANDARDS]: { [KBIndexType.STANDARDS]: {
label: "Standards", label: "Standards",
color: "bg-cyan-100 text-cyan-700 dark:bg-cyan-900 dark:text-cyan-300", color: "bg-cyan-100 text-cyan-700 dark:bg-cyan-900 dark:text-cyan-300",
icon: <Scale className="h-3 w-3" />, icon: <Scale className="h-3 w-3" />,
description: "Coding, security, and workflow rules the fleet follows",
}, },
[KBIndexType.DECISIONS]: { [KBIndexType.DECISIONS]: {
label: "Decisions", label: "Decisions",
color: color:
"bg-indigo-100 text-indigo-700 dark:bg-indigo-900 dark:text-indigo-300", "bg-indigo-100 text-indigo-700 dark:bg-indigo-900 dark:text-indigo-300",
icon: <GitBranch className="h-3 w-3" />, icon: <GitBranch className="h-3 w-3" />,
description: "Architectural and design decisions made by agents",
}, },
[KBIndexType.REVIEWS]: { [KBIndexType.REVIEWS]: {
label: "Reviews", label: "Reviews",
color: "bg-pink-100 text-pink-700 dark:bg-pink-900 dark:text-pink-300", color: "bg-pink-100 text-pink-700 dark:bg-pink-900 dark:text-pink-300",
icon: <ClipboardCheck className="h-3 w-3" />, icon: <ClipboardCheck className="h-3 w-3" />,
description: "Code review feedback from QA and PR reviewers",
}, },
[KBIndexType.LEARNINGS]: { [KBIndexType.LEARNINGS]: {
label: "Learnings", label: "Learnings",
color: color:
"bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300", "bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300",
icon: <Lightbulb className="h-3 w-3" />, icon: <Lightbulb className="h-3 w-3" />,
description: "Cross-agent learnings broadcast as shared knowledge",
}, },
[KBIndexType.PLAYBOOKS]: { [KBIndexType.PLAYBOOKS]: {
label: "Playbooks", label: "Playbooks",
color: color:
"bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300", "bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300",
icon: <ScrollText className="h-3 w-3" />, icon: <ScrollText className="h-3 w-3" />,
description: "Curated, Auditor-approved reusable procedures",
}, },
[KBIndexType.VAULT_NOTES]: { [KBIndexType.VAULT_NOTES]: {
label: "Vault Notes", label: "Vault Notes",
color: color:
"bg-violet-100 text-violet-700 dark:bg-violet-900 dark:text-violet-300", "bg-violet-100 text-violet-700 dark:bg-violet-900 dark:text-violet-300",
icon: <StickyNote className="h-3 w-3" />, icon: <StickyNote className="h-3 w-3" />,
description: "Human-authored notes from the CEO's Obsidian vault",
}, },
}; };
@@ -90,10 +106,15 @@ export function KBIndexTypeBadge({
const config = indexTypeConfig[indexType]; const config = indexTypeConfig[indexType];
return ( return (
<Badge variant="secondary" className={`${config.color} ${className ?? ""}`}> <HelpTip label={config.description}>
<Badge
variant="secondary"
className={`${config.color} ${className ?? ""}`}
>
{showIcon && <span className="mr-1">{config.icon}</span>} {showIcon && <span className="mr-1">{config.icon}</span>}
{config.label} {config.label}
</Badge> </Badge>
</HelpTip>
); );
} }
@@ -104,3 +125,7 @@ export function getIndexTypeIcon(indexType: KBIndexType) {
export function getIndexTypeLabel(indexType: KBIndexType) { export function getIndexTypeLabel(indexType: KBIndexType) {
return indexTypeConfig[indexType].label; return indexTypeConfig[indexType].label;
} }
export function getIndexTypeDescription(indexType: KBIndexType) {
return indexTypeConfig[indexType].description;
}
@@ -3,6 +3,7 @@
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { KBSearchResult } from "@/types"; import { KBSearchResult } from "@/types";
import { KBIndexTypeBadge } from "./kb-index-type-badge"; import { KBIndexTypeBadge } from "./kb-index-type-badge";
import { HelpTip } from "@/components/ui/help-tip";
import { ExternalLink, FileCode, Hash } from "lucide-react"; import { ExternalLink, FileCode, Hash } from "lucide-react";
interface KBResultCardProps { interface KBResultCardProps {
@@ -47,19 +48,23 @@ export function KBResultCard({ result, onClick }: KBResultCardProps) {
{/* Header */} {/* Header */}
<div className="flex items-center gap-2 flex-wrap mb-2"> <div className="flex items-center gap-2 flex-wrap mb-2">
<KBIndexTypeBadge indexType={result.index_type} /> <KBIndexTypeBadge indexType={result.index_type} />
<span className="text-xs text-muted-foreground flex items-center gap-1"> <HelpTip label="Relevance: how closely this chunk's embedding matches your search query">
<span className="text-xs text-muted-foreground flex items-center gap-1 w-fit">
<Hash className="h-3 w-3" /> <Hash className="h-3 w-3" />
{scorePercent}% match {scorePercent}% match
</span> </span>
</HelpTip>
</div> </div>
{/* Source */} {/* Source */}
<div className="flex items-center gap-1 text-sm text-muted-foreground mb-2"> <HelpTip label={result.source}>
<div className="flex items-center gap-1 text-sm text-muted-foreground mb-2 w-fit max-w-full">
<FileCode className="h-3 w-3 shrink-0" /> <FileCode className="h-3 w-3 shrink-0" />
<span className="truncate font-mono text-xs"> <span className="truncate font-mono text-xs">
{formatSource(result.source)} {formatSource(result.source)}
</span> </span>
</div> </div>
</HelpTip>
{/* Content snippet */} {/* Content snippet */}
<p className="text-sm text-foreground/90 whitespace-pre-wrap line-clamp-4"> <p className="text-sm text-foreground/90 whitespace-pre-wrap line-clamp-4">
@@ -89,7 +94,9 @@ export function KBResultCard({ result, onClick }: KBResultCardProps) {
</div> </div>
{onClick && ( {onClick && (
<HelpTip label="Open full document">
<ExternalLink className="h-4 w-4 text-muted-foreground shrink-0" /> <ExternalLink className="h-4 w-4 text-muted-foreground shrink-0" />
</HelpTip>
)} )}
</div> </div>
</CardContent> </CardContent>
@@ -79,12 +79,26 @@ export function KBSearchBar({
)} )}
</div> </div>
{onSearch && ( {onSearch && (
<HelpTip
label={
isLoading
? "Searching…"
: !localValue || localValue.length < 3
? "Enter at least 3 characters to search"
: null
}
>
<Button <Button
onClick={onSearch} onClick={onSearch}
disabled={!localValue || localValue.length < 3 || isLoading} disabled={!localValue || localValue.length < 3 || isLoading}
> >
{isLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : "Search"} {isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
"Search"
)}
</Button> </Button>
</HelpTip>
)} )}
</div> </div>
); );
@@ -2,6 +2,8 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { HelpTip } from "@/components/ui/help-tip";
import { getIndexTypeDescription } from "./kb-index-type-badge";
import { KBStats, KBIndexType } from "@/types"; import { KBStats, KBIndexType } from "@/types";
import { import {
Database, Database,
@@ -111,10 +113,12 @@ export function KBStatsCard({ stats, isLoading }: KBStatsCardProps) {
key={idx.index_type} key={idx.index_type}
className="flex items-center justify-between text-sm" className="flex items-center justify-between text-sm"
> >
<div className="flex items-center gap-2"> <HelpTip label={getIndexTypeDescription(idx.index_type)}>
<div className="flex items-center gap-2 w-fit">
{indexIcons[idx.index_type]} {indexIcons[idx.index_type]}
<span>{indexLabels[idx.index_type]}</span> <span>{indexLabels[idx.index_type]}</span>
</div> </div>
</HelpTip>
<div className="text-right"> <div className="text-right">
<span className="font-medium"> <span className="font-medium">
{idx.document_count.toLocaleString()} {idx.document_count.toLocaleString()}
@@ -131,7 +135,9 @@ export function KBStatsCard({ stats, isLoading }: KBStatsCardProps) {
</span> </span>
</div> </div>
<div className="flex items-center justify-between text-xs text-muted-foreground mt-1"> <div className="flex items-center justify-between text-xs text-muted-foreground mt-1">
<span>Chunks</span> <HelpTip label="A chunk is a segment of a document split for embedding — one document can produce many chunks">
<span className="w-fit">Chunks</span>
</HelpTip>
<span>{stats.total_chunks.toLocaleString()}</span> <span>{stats.total_chunks.toLocaleString()}</span>
</div> </div>
</div> </div>
@@ -63,6 +63,7 @@ import { RAGAnswerDisplay } from "./rag-answer-display";
import { MentorChat } from "./mentor-chat"; import { MentorChat } from "./mentor-chat";
import { KBCategoryNav } from "./kb-category-nav"; import { KBCategoryNav } from "./kb-category-nav";
import { KBCategoryView } from "./kb-category-view"; import { KBCategoryView } from "./kb-category-view";
import { getIndexTypeDescription } from "./kb-index-type-badge";
const TAB_VALUES = ["search", "ask", "mentor", "browse", "admin"] as const; const TAB_VALUES = ["search", "ask", "mentor", "browse", "admin"] as const;
type TabValue = (typeof TAB_VALUES)[number]; type TabValue = (typeof TAB_VALUES)[number];
@@ -541,10 +542,14 @@ function KnowledgeBaseBrowserContent() {
<p className="text-2xl font-bold">{totalDocs}</p> <p className="text-2xl font-bold">{totalDocs}</p>
<p className="text-xs text-muted-foreground">Documents</p> <p className="text-xs text-muted-foreground">Documents</p>
</div> </div>
<div> <HelpTip label="A chunk is a segment of a document split for embedding — one document can produce many chunks">
<div className="w-fit">
<p className="text-2xl font-bold">{totalChunks}</p> <p className="text-2xl font-bold">{totalChunks}</p>
<p className="text-xs text-muted-foreground">Chunks</p> <p className="text-xs text-muted-foreground">
Chunks
</p>
</div> </div>
</HelpTip>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -589,9 +594,16 @@ function KnowledgeBaseBrowserContent() {
<span className="font-medium"> <span className="font-medium">
{INDEX_LABELS[indexType]} {INDEX_LABELS[indexType]}
</span> </span>
<Badge variant="outline" className="text-xs"> <HelpTip
label={getIndexTypeDescription(indexType)}
>
<Badge
variant="outline"
className="text-xs w-fit"
>
{indexType} {indexType}
</Badge> </Badge>
</HelpTip>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<HelpTip label="Refresh this index"> <HelpTip label="Refresh this index">
@@ -602,6 +614,7 @@ function KnowledgeBaseBrowserContent() {
handleRefreshIndex(indexType) handleRefreshIndex(indexType)
} }
disabled={refreshIndex.isPending} disabled={refreshIndex.isPending}
aria-label={`Refresh ${INDEX_LABELS[indexType]} index`}
> >
{refreshIndex.isPending ? ( {refreshIndex.isPending ? (
<RefreshCw className="h-3 w-3 animate-spin" /> <RefreshCw className="h-3 w-3 animate-spin" />
@@ -617,6 +630,7 @@ function KnowledgeBaseBrowserContent() {
size="sm" size="sm"
variant="outline" variant="outline"
className="text-red-600" className="text-red-600"
aria-label={`Delete ${INDEX_LABELS[indexType]} index`}
> >
<Trash2 className="h-3 w-3" /> <Trash2 className="h-3 w-3" />
</Button> </Button>
@@ -661,7 +675,8 @@ function KnowledgeBaseBrowserContent() {
{index.document_count} {index.document_count}
</span> </span>
</div> </div>
<div> <HelpTip label="A chunk is a segment of a document split for embedding — one document can produce many chunks">
<div className="w-fit">
<span className="text-muted-foreground"> <span className="text-muted-foreground">
Chunks: Chunks:
</span>{" "} </span>{" "}
@@ -669,6 +684,7 @@ function KnowledgeBaseBrowserContent() {
{index.chunk_count} {index.chunk_count}
</span> </span>
</div> </div>
</HelpTip>
<div> <div>
<span className="text-muted-foreground"> <span className="text-muted-foreground">
Updated: Updated:
@@ -3,6 +3,7 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { HelpTip } from "@/components/ui/help-tip";
import { MentorAskResponse } from "@/types"; import { MentorAskResponse } from "@/types";
import { RAGCitationCard } from "./rag-citation-card"; import { RAGCitationCard } from "./rag-citation-card";
import { Markdown } from "@/components/ui/markdown"; import { Markdown } from "@/components/ui/markdown";
@@ -232,13 +233,21 @@ export function MentorAnswerDisplay({
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{Object.entries(response.search_stats).map( {Object.entries(response.search_stats).map(
([indexType, count]) => ( ([indexType, count]) => (
<Badge <HelpTip
key={indexType} key={indexType}
label={
count === -1
? `The ${indexType} index failed to search — its results may be missing from this answer.`
: `${count} matching result${count === 1 ? "" : "s"} from the ${indexType} knowledge-base index.`
}
>
<Badge
variant={count > 0 ? "secondary" : "outline"} variant={count > 0 ? "secondary" : "outline"}
className={`text-xs ${count === -1 ? "text-red-500" : ""}`} className={`text-xs ${count === -1 ? "text-red-500" : ""}`}
> >
{indexType}: {count === -1 ? "error" : count} {indexType}: {count === -1 ? "error" : count}
</Badge> </Badge>
</HelpTip>
), ),
)} )}
</div> </div>
@@ -156,6 +156,7 @@ export function MentorChat({ onAsk, isLoading }: MentorChatProps) {
className="absolute bottom-2 right-2" className="absolute bottom-2 right-2"
onClick={() => handleSubmit()} onClick={() => handleSubmit()}
disabled={!input.trim() || isLoading} disabled={!input.trim() || isLoading}
aria-label="Send to your mentor"
> >
<Send className="h-4 w-4" /> <Send className="h-4 w-4" />
</Button> </Button>
@@ -327,6 +328,7 @@ export function MentorChat({ onAsk, isLoading }: MentorChatProps) {
className="absolute bottom-2 right-2" className="absolute bottom-2 right-2"
onClick={() => handleSubmit()} onClick={() => handleSubmit()}
disabled={!input.trim() || isLoading} disabled={!input.trim() || isLoading}
aria-label="Send to your mentor"
> >
{isLoading ? ( {isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
@@ -92,6 +92,7 @@ export function MentorQueryInput({
className="absolute bottom-2 right-2" className="absolute bottom-2 right-2"
onClick={handleSubmit} onClick={handleSubmit}
disabled={!question.trim() || isLoading} disabled={!question.trim() || isLoading}
aria-label="Ask the mentor"
> >
{isLoading ? ( {isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
@@ -3,6 +3,7 @@
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { RAGCitation } from "@/types"; import { RAGCitation } from "@/types";
import { KBIndexTypeBadge } from "./kb-index-type-badge"; import { KBIndexTypeBadge } from "./kb-index-type-badge";
import { HelpTip } from "@/components/ui/help-tip";
import { Quote, Hash } from "lucide-react"; import { Quote, Hash } from "lucide-react";
interface RAGCitationCardProps { interface RAGCitationCardProps {
@@ -43,14 +44,18 @@ export function RAGCitationCard({ citation, index }: RAGCitationCardProps) {
indexType={citation.index_type} indexType={citation.index_type}
className="text-xs" className="text-xs"
/> />
<HelpTip label="Similarity score — how closely this chunk matches your query in the embedding search.">
<span className="text-xs text-muted-foreground flex items-center gap-0.5"> <span className="text-xs text-muted-foreground flex items-center gap-0.5">
<Hash className="h-3 w-3" /> <Hash className="h-3 w-3" />
{scorePercent}% {scorePercent}%
</span> </span>
</HelpTip>
</div> </div>
<HelpTip label={citation.source}>
<p className="text-xs text-muted-foreground font-mono truncate mb-1"> <p className="text-xs text-muted-foreground font-mono truncate mb-1">
{formatSource(citation.source)} {formatSource(citation.source)}
</p> </p>
</HelpTip>
<div className="flex items-start gap-1"> <div className="flex items-start gap-1">
<Quote className="h-3 w-3 text-muted-foreground shrink-0 mt-0.5" /> <Quote className="h-3 w-3 text-muted-foreground shrink-0 mt-0.5" />
<p className="text-sm text-foreground/80 line-clamp-3"> <p className="text-sm text-foreground/80 line-clamp-3">
@@ -58,6 +58,7 @@ export function RAGQueryInput({
className="absolute bottom-2 right-2" className="absolute bottom-2 right-2"
onClick={handleSubmit} onClick={handleSubmit}
disabled={!question.trim() || isLoading} disabled={!question.trim() || isLoading}
aria-label="Ask the knowledge base"
> >
{isLoading ? ( {isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
@@ -117,6 +117,16 @@ describe("NotificationBell — read/ack integration (W9-1)", () => {
expect(screen.getByText("9+")).toBeInTheDocument(); expect(screen.getByText("9+")).toBeInTheDocument();
}); });
it("surfaces the exact unread count via the button's accessible name, even when the badge caps at 9+", () => {
useNotifications.mockReturnValue({
data: { items: [], total: 42, unread_count: 42, pending_ack_count: 0 },
});
render(<NotificationBell />);
expect(
screen.getByRole("button", { name: "View notifications (42 unread)" }),
).toBeInTheDocument();
});
it("renders no badge when there is nothing unread", () => { it("renders no badge when there is nothing unread", () => {
useNotifications.mockReturnValue({ useNotifications.mockReturnValue({
data: { items: [], total: 0, unread_count: 0, pending_ack_count: 0 }, data: { items: [], total: 0, unread_count: 0, pending_ack_count: 0 },
@@ -138,7 +148,8 @@ describe("NotificationBell — read/ack integration (W9-1)", () => {
}); });
const user = userEvent.setup(); const user = userEvent.setup();
render(<NotificationBell />); render(<NotificationBell />);
await user.click(screen.getByRole("button", { name: "View notifications" })); // Name now carries "(1 unread)" — match the stable prefix.
await user.click(screen.getByRole("button", { name: /^View notifications/ }));
const markReadBtn = await screen.findByRole("button", { name: /Mark Read/ }); const markReadBtn = await screen.findByRole("button", { name: /Mark Read/ });
await user.click(markReadBtn); await user.click(markReadBtn);
await waitFor(() => expect(markRead).toHaveBeenCalledWith("notif-1")); await waitFor(() => expect(markRead).toHaveBeenCalledWith("notif-1"));
@@ -163,7 +174,7 @@ describe("NotificationBell — read/ack integration (W9-1)", () => {
}); });
const user = userEvent.setup(); const user = userEvent.setup();
render(<NotificationBell />); render(<NotificationBell />);
await user.click(screen.getByRole("button", { name: "View notifications" })); await user.click(screen.getByRole("button", { name: /^View notifications/ }));
const ackBtn = await screen.findByRole("button", { name: /Acknowledge/ }); const ackBtn = await screen.findByRole("button", { name: /Acknowledge/ });
await user.click(ackBtn); await user.click(ackBtn);
await waitFor(() => expect(ack).toHaveBeenCalledWith("notif-2")); await waitFor(() => expect(ack).toHaveBeenCalledWith("notif-2"));
@@ -182,7 +193,7 @@ describe("NotificationBell — read/ack integration (W9-1)", () => {
}); });
const user = userEvent.setup(); const user = userEvent.setup();
render(<NotificationBell />); render(<NotificationBell />);
await user.click(screen.getByRole("button", { name: "View notifications" })); await user.click(screen.getByRole("button", { name: /^View notifications/ }));
const allBtn = await screen.findByRole("button", { name: /Mark all read/ }); const allBtn = await screen.findByRole("button", { name: /Mark all read/ });
await user.click(allBtn); await user.click(allBtn);
await waitFor(() => expect(markAllRead).toHaveBeenCalled()); await waitFor(() => expect(markAllRead).toHaveBeenCalled());
@@ -23,6 +23,7 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { HelpTip } from "@/components/ui/help-tip";
import { Bell, Wifi, WifiOff, CheckCheck, MailOpen, Check } from "lucide-react"; import { Bell, Wifi, WifiOff, CheckCheck, MailOpen, Check } from "lucide-react";
const BELL_LABEL = "View notifications"; const BELL_LABEL = "View notifications";
@@ -39,6 +40,12 @@ export function NotificationBell() {
const unreadCount = data?.unread_count ?? 0; const unreadCount = data?.unread_count ?? 0;
const pendingAckCount = data?.pending_ack_count ?? 0; const pendingAckCount = data?.pending_ack_count ?? 0;
const items = (data?.items ?? []).slice(0, PREVIEW_LIMIT); const items = (data?.items ?? []).slice(0, PREVIEW_LIMIT);
// The badge caps its own display at "9+", hiding the exact count — surface
// the real number here so it's never lost to sighted or AT users.
const bellLabel =
unreadCount > 0
? `${BELL_LABEL} (${unreadCount} unread)`
: BELL_LABEL;
const handleMarkRead = (id: string) => { const handleMarkRead = (id: string) => {
void markRead.mutateAsync(id); void markRead.mutateAsync(id);
@@ -68,8 +75,8 @@ export function NotificationBell() {
variant="ghost" variant="ghost"
size="icon" size="icon"
className="relative" className="relative"
aria-label={BELL_LABEL} aria-label={bellLabel}
title={BELL_LABEL} title={bellLabel}
> >
<Bell className="h-5 w-5" /> <Bell className="h-5 w-5" />
{unreadCount > 0 && ( {unreadCount > 0 && (
@@ -80,7 +87,7 @@ export function NotificationBell() {
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>{BELL_LABEL}</TooltipContent> <TooltipContent>{bellLabel}</TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
<PopoverContent className="w-80" align="end"> <PopoverContent className="w-80" align="end">
@@ -89,9 +96,13 @@ export function NotificationBell() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h4 className="font-semibold">Notifications</h4> <h4 className="font-semibold">Notifications</h4>
{isConnected ? ( {isConnected ? (
<HelpTip label="Live update stream connected">
<Wifi className="h-4 w-4 text-green-500" aria-label="connected" /> <Wifi className="h-4 w-4 text-green-500" aria-label="connected" />
</HelpTip>
) : ( ) : (
<HelpTip label="Live update stream disconnected — list may be stale">
<WifiOff className="h-4 w-4 text-gray-400" aria-label="disconnected" /> <WifiOff className="h-4 w-4 text-gray-400" aria-label="disconnected" />
</HelpTip>
)} )}
</div> </div>
{unreadCount > 0 && ( {unreadCount > 0 && (
@@ -21,6 +21,7 @@ import { Skeleton } from "@/components/ui/skeleton";
import { Boxes, Pencil } from "lucide-react"; import { Boxes, Pencil } from "lucide-react";
import type { ProductSummary, Team } from "@/types"; import type { ProductSummary, Team } from "@/types";
import { EditProductDialog } from "./edit-product-dialog"; import { EditProductDialog } from "./edit-product-dialog";
import { HelpTip } from "@/components/ui/help-tip";
const TEAM_LABELS: Record<Team, string> = { const TEAM_LABELS: Record<Team, string> = {
board: "Board", board: "Board",
@@ -41,6 +42,7 @@ function CellsList({ cells }: { cells: ProductSummary["cells"] }) {
return <span className="text-muted-foreground text-sm">Unmapped</span>; return <span className="text-muted-foreground text-sm">Unmapped</span>;
} }
return ( return (
<HelpTip label="Each cell (Backend/Frontend/UX-UI) works on the project listed beside it. A cell with no project mapped here does no work for this product.">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{cells.map((c) => ( {cells.map((c) => (
<div key={`${c.team}-${c.project_id}`} className="flex items-center gap-2"> <div key={`${c.team}-${c.project_id}`} className="flex items-center gap-2">
@@ -56,6 +58,7 @@ function CellsList({ cells }: { cells: ProductSummary["cells"] }) {
</div> </div>
))} ))}
</div> </div>
</HelpTip>
); );
} }
@@ -66,15 +69,21 @@ function ProgressCell({
}) { }) {
const { done, active, blocked } = progress; const { done, active, blocked } = progress;
const atRisk = blocked > 0; const atRisk = blocked > 0;
const dotHint = atRisk
? "At risk — this product's mapped projects have blocked tasks needing attention."
: done > 0
? "Healthy — tasks are completing with none currently blocked."
: "No completed or blocked tasks yet.";
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<HelpTip label={dotHint}>
<span <span
className={ className={
"h-2 w-2 rounded-full " + "h-2 w-2 rounded-full inline-block " +
(atRisk ? "bg-amber-500" : done > 0 ? "bg-emerald-500" : "bg-muted") (atRisk ? "bg-amber-500" : done > 0 ? "bg-emerald-500" : "bg-muted")
} }
title={atRisk ? "At risk: blocked tasks" : "Healthy"}
/> />
</HelpTip>
<div className="flex items-center gap-2 text-xs"> <div className="flex items-center gap-2 text-xs">
<span className="text-emerald-600 dark:text-emerald-400">{done} done</span> <span className="text-emerald-600 dark:text-emerald-400">{done} done</span>
<span className="text-muted-foreground">{active} active</span> <span className="text-muted-foreground">{active} active</span>
@@ -148,14 +157,16 @@ export function ProductTable({ products, isLoading }: ProductTableProps) {
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<HelpTip label="Edit product name, description, and cell-project mapping">
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => setEditingProductId(product.id)} onClick={() => setEditingProductId(product.id)}
title="Edit product" aria-label="Edit product"
> >
<Pencil className="h-4 w-4" /> <Pencil className="h-4 w-4" />
</Button> </Button>
</HelpTip>
</div> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -181,15 +192,17 @@ export function ProductTable({ products, isLoading }: ProductTableProps) {
{product.slug} {product.slug}
</p> </p>
</div> </div>
<HelpTip label="Edit product name, description, and cell-project mapping">
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="shrink-0" className="shrink-0"
onClick={() => setEditingProductId(product.id)} onClick={() => setEditingProductId(product.id)}
title="Edit product" aria-label="Edit product"
> >
<Pencil className="h-4 w-4" /> <Pencil className="h-4 w-4" />
</Button> </Button>
</HelpTip>
</div> </div>
<div className="mt-3 divide-y"> <div className="mt-3 divide-y">
<ResponsiveTableCardRow label="Cells"> <ResponsiveTableCardRow label="Cells">
@@ -26,6 +26,7 @@ import { toast } from "sonner";
import { Team, type ProjectCreate } from "@/types"; import { Team, type ProjectCreate } from "@/types";
import { EnvironmentLadderEditor } from "@/components/projects/environment-ladder-editor"; import { EnvironmentLadderEditor } from "@/components/projects/environment-ladder-editor";
import { validateLadder } from "@/components/projects/ladder-validation"; import { validateLadder } from "@/components/projects/ladder-validation";
import { HelpTip } from "@/components/ui/help-tip";
const cells: { value: Team; label: string }[] = [ const cells: { value: Team; label: string }[] = [
{ value: Team.BACKEND, label: "Backend" }, { value: Team.BACKEND, label: "Backend" },
@@ -182,7 +183,9 @@ export function CreateProjectDialog() {
{/* Git Token */} {/* Git Token */}
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="git_token" className="flex items-center gap-1"> <Label htmlFor="git_token" className="flex items-center gap-1">
<HelpTip label="Optional at creation — stored encrypted (Fernet) and never re-displayed once saved; add or replace it later from the project's edit settings.">
<Key className="h-3.5 w-3.5" /> <Key className="h-3.5 w-3.5" />
</HelpTip>
GitHub Token GitHub Token
</Label> </Label>
<Input <Input
@@ -29,6 +29,7 @@ import { toast } from "sonner";
import { Team, type ProjectUpdate, type Project } from "@/types"; import { Team, type ProjectUpdate, type Project } from "@/types";
import { EnvironmentLadderEditor } from "@/components/projects/environment-ladder-editor"; import { EnvironmentLadderEditor } from "@/components/projects/environment-ladder-editor";
import { validateLadder } from "@/components/projects/ladder-validation"; import { validateLadder } from "@/components/projects/ladder-validation";
import { HelpTip } from "@/components/ui/help-tip";
const cells: { value: Team; label: string }[] = [ const cells: { value: Team; label: string }[] = [
{ value: Team.BACKEND, label: "Backend" }, { value: Team.BACKEND, label: "Backend" },
@@ -46,21 +47,65 @@ const SANDBOX_SERVICES = [
// (roboco/models/sandbox.py SANDBOX_ENGINE_FEATURES). The allowlist is the // (roboco/models/sandbox.py SANDBOX_ENGINE_FEATURES). The allowlist is the
// security containment — a plpython3u (superuser-RCE) is absent by design. // security containment — a plpython3u (superuser-RCE) is absent by design.
// Mongo has no activatable features and is intentionally absent here. // Mongo has no activatable features and is intentionally absent here.
const SANDBOX_EXTENSIONS: Record<string, { id: string; label: string }[]> = { const SANDBOX_EXTENSIONS: Record<
string,
{ id: string; label: string; hint: string }[]
> = {
postgres: [ postgres: [
{ id: "vector", label: "pgvector" }, {
{ id: "postgis", label: "PostGIS" }, id: "vector",
{ id: "pg_trgm", label: "pg_trgm" }, label: "pgvector",
{ id: "citext", label: "citext" }, hint: "Vector similarity search/indexing for embeddings.",
{ id: "uuid-ossp", label: "uuid-ossp" }, },
{
id: "postgis",
label: "PostGIS",
hint: "Geospatial types and queries for PostgreSQL.",
},
{
id: "pg_trgm",
label: "pg_trgm",
hint: "Trigram-based fuzzy text matching and similarity search.",
},
{
id: "citext",
label: "citext",
hint: "Case-insensitive text column type.",
},
{
id: "uuid-ossp",
label: "uuid-ossp",
hint: "Functions to generate UUIDs (e.g. uuid_generate_v4()).",
},
], ],
redis: [ redis: [
{ id: "search", label: "RediSearch" }, {
{ id: "json", label: "RedisJSON" }, id: "search",
{ id: "bloom", label: "RedisBloom" }, label: "RediSearch",
hint: "Full-text search and secondary indexing for Redis.",
},
{
id: "json",
label: "RedisJSON",
hint: "Native JSON document storage and querying.",
},
{
id: "bloom",
label: "RedisBloom",
hint: "Probabilistic data structures (Bloom/Cuckoo filters, HyperLogLog).",
},
], ],
}; };
const SANDBOX_SERVICE_HINTS: Record<string, string> = {
postgres:
"Ephemeral PostgreSQL container for this project's agent spawns — random creds, tmpfs storage, torn down at end of engagement.",
redis:
"Ephemeral Redis container for this project's agent spawns — random creds, tmpfs storage, torn down at end of engagement.",
mongo:
"Ephemeral MongoDB container for this project's agent spawns — random creds, tmpfs storage, torn down at end of engagement.",
};
interface EditProjectDialogProps { interface EditProjectDialogProps {
projectId: string; projectId: string;
open: boolean; open: boolean;
@@ -260,6 +305,7 @@ function EditProjectForm({
{/* Git Token Section */} {/* Git Token Section */}
<div className="grid gap-2 p-3 border rounded-lg bg-muted/30"> <div className="grid gap-2 p-3 border rounded-lg bg-muted/30">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<HelpTip label="Stored encrypted (Fernet) and never re-displayed once saved — required for HTTPS clone/push/PR operations.">
<Label className="flex items-center gap-2"> <Label className="flex items-center gap-2">
{project.has_git_token ? ( {project.has_git_token ? (
<> <>
@@ -277,14 +323,17 @@ function EditProjectForm({
</> </>
)} )}
</Label> </Label>
</HelpTip>
{project.has_git_token && ( {project.has_git_token && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<HelpTip label="Clears the stored token when you save. Leave off to keep the current token, or enter a replacement below.">
<Label <Label
htmlFor="clear-token" htmlFor="clear-token"
className="text-xs text-muted-foreground" className="text-xs text-muted-foreground"
> >
Clear token Clear token
</Label> </Label>
</HelpTip>
<Switch <Switch
id="clear-token" id="clear-token"
checked={clearToken} checked={clearToken}
@@ -359,7 +408,9 @@ function EditProjectForm({
{/* Active Status */} {/* Active Status */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<HelpTip label="Inactive projects are hidden from the default project list (toggle 'Show Inactive' to see them) and are skipped as the fallback project for idle-agent spawns.">
<Label htmlFor="is_active">Active</Label> <Label htmlFor="is_active">Active</Label>
</HelpTip>
<Switch <Switch
id="is_active" id="is_active"
checked={isActive} checked={isActive}
@@ -458,9 +509,11 @@ function EditProjectForm({
{showAutonomy && ( {showAutonomy && (
<> <>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<HelpTip label="Opens a fix task automatically when this repo's default-branch CI goes red. Also requires the CI-watch engine armed fleet-wide (ROBOCO_CI_WATCH_ENABLED) to actually run.">
<Label htmlFor="ci_watch_enabled"> <Label htmlFor="ci_watch_enabled">
CI-watch (open a fix task when CI goes red) CI-watch (open a fix task when CI goes red)
</Label> </Label>
</HelpTip>
<Switch <Switch
id="ci_watch_enabled" id="ci_watch_enabled"
checked={ciWatchEnabled} checked={ciWatchEnabled}
@@ -483,9 +536,11 @@ function EditProjectForm({
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<HelpTip label="Opts this repo into authoring motion-graphics videos under motion/. Also requires the video engine armed fleet-wide (ROBOCO_VIDEO_ENGINE_ENABLED) to render/post.">
<Label htmlFor="video_engine_enabled"> <Label htmlFor="video_engine_enabled">
Video engine (author marketing videos into this project) Video engine (author marketing videos into this project)
</Label> </Label>
</HelpTip>
<Switch <Switch
id="video_engine_enabled" id="video_engine_enabled"
checked={videoEngineEnabled} checked={videoEngineEnabled}
@@ -526,15 +581,19 @@ function EditProjectForm({
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<HelpTip label="Requires the sandbox engine armed fleet-wide (ROBOCO_SANDBOX_DB_ENABLED); agents call request_sandbox() on-demand rather than getting creds at spawn.">
<Label>Sandbox Services</Label> <Label>Sandbox Services</Label>
</HelpTip>
{SANDBOX_SERVICES.map((svc) => ( {SANDBOX_SERVICES.map((svc) => (
<div key={svc.id} className="flex items-center justify-between"> <div key={svc.id} className="flex items-center justify-between">
<HelpTip label={SANDBOX_SERVICE_HINTS[svc.id]}>
<Label <Label
htmlFor={`sandbox_${svc.id}`} htmlFor={`sandbox_${svc.id}`}
className="text-sm font-normal" className="text-sm font-normal"
> >
{svc.label} {svc.label}
</Label> </Label>
</HelpTip>
<Switch <Switch
id={`sandbox_${svc.id}`} id={`sandbox_${svc.id}`}
checked={sandboxSet.has(svc.id)} checked={sandboxSet.has(svc.id)}
@@ -565,12 +624,14 @@ function EditProjectForm({
key={ext.id} key={ext.id}
className="flex items-center justify-between" className="flex items-center justify-between"
> >
<HelpTip label={ext.hint}>
<Label <Label
htmlFor={`ext_${svc.id}_${ext.id}`} htmlFor={`ext_${svc.id}_${ext.id}`}
className="text-sm font-normal" className="text-sm font-normal"
> >
{ext.label} {ext.label}
</Label> </Label>
</HelpTip>
<Switch <Switch
id={`ext_${svc.id}_${ext.id}`} id={`ext_${svc.id}_${ext.id}`}
checked={sandboxExtensions[svc.id]?.has(ext.id) ?? false} checked={sandboxExtensions[svc.id]?.has(ext.id) ?? false}
@@ -80,6 +80,7 @@ export function EnvironmentLadderEditor({
className="h-6 w-6" className="h-6 w-6"
disabled={isFirst} disabled={isFirst}
onClick={() => handleMove(index, -1)} onClick={() => handleMove(index, -1)}
aria-label="Move rung up, toward head"
> >
<ArrowUp className="h-3.5 w-3.5" /> <ArrowUp className="h-3.5 w-3.5" />
</Button> </Button>
@@ -95,6 +96,7 @@ export function EnvironmentLadderEditor({
className="h-6 w-6" className="h-6 w-6"
disabled={isLast} disabled={isLast}
onClick={() => handleMove(index, 1)} onClick={() => handleMove(index, 1)}
aria-label="Move rung down, toward prod"
> >
<ArrowDown className="h-3.5 w-3.5" /> <ArrowDown className="h-3.5 w-3.5" />
</Button> </Button>
@@ -125,6 +127,7 @@ export function EnvironmentLadderEditor({
size="icon" size="icon"
className="h-8 w-8 shrink-0" className="h-8 w-8 shrink-0"
onClick={() => handleRemove(index)} onClick={() => handleRemove(index)}
aria-label="Remove this rung"
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</Button> </Button>
+46 -38
View File
@@ -21,6 +21,7 @@ import { Skeleton } from "@/components/ui/skeleton";
import { ExternalLink, Pencil, GitBranch, Key, KeyRound, Radar } from "lucide-react"; import { ExternalLink, Pencil, GitBranch, Key, KeyRound, Radar } from "lucide-react";
import type { ProjectSummary, ProjectTaskCounts, Team } from "@/types"; import type { ProjectSummary, ProjectTaskCounts, Team } from "@/types";
import { EditProjectDialog } from "./edit-project-dialog"; import { EditProjectDialog } from "./edit-project-dialog";
import { HelpTip } from "@/components/ui/help-tip";
interface ProjectTableProps { interface ProjectTableProps {
projects: ProjectSummary[] | undefined; projects: ProjectSummary[] | undefined;
@@ -46,20 +47,36 @@ const teamColors: Record<Team, string> = {
}; };
function getTokenBadge(hasGitToken: boolean) { function getTokenBadge(hasGitToken: boolean) {
if (hasGitToken) { const badge = hasGitToken ? (
return (
<Badge className="bg-green-500/10 text-green-500"> <Badge className="bg-green-500/10 text-green-500">
<Key className="h-3 w-3 mr-1" /> <Key className="h-3 w-3 mr-1" />
Token Set Token Set
</Badge> </Badge>
); ) : (
}
return (
<Badge variant="outline" className="text-amber-500 border-amber-500/30"> <Badge variant="outline" className="text-amber-500 border-amber-500/30">
<KeyRound className="h-3 w-3 mr-1" /> <KeyRound className="h-3 w-3 mr-1" />
No Token No Token
</Badge> </Badge>
); );
return (
<HelpTip label="A token is required for HTTPS clone, push, and PR operations; stored encrypted and never displayed once set.">
{badge}
</HelpTip>
);
}
function getStatusBadge(isActive: boolean) {
const badge = isActive ? (
<Badge className="bg-green-500/10 text-green-500">Active</Badge>
) : (
<Badge variant="outline" className="text-muted-foreground">
Inactive
</Badge>
);
const hint = isActive
? "Shown by default and eligible as the fallback project for idle-agent spawns."
: "Hidden from the default project list and skipped as the fallback project for idle-agent spawns; existing tasks are unaffected.";
return <HelpTip label={hint}>{badge}</HelpTip>;
} }
function TasksCell({ counts }: { counts: ProjectTaskCounts | null }) { function TasksCell({ counts }: { counts: ProjectTaskCounts | null }) {
@@ -67,19 +84,25 @@ function TasksCell({ counts }: { counts: ProjectTaskCounts | null }) {
return <span className="text-muted-foreground text-xs"></span>; return <span className="text-muted-foreground text-xs"></span>;
} }
const atRisk = counts.blocked > 0; const atRisk = counts.blocked > 0;
const dotHint = atRisk
? "At risk — this project has blocked tasks needing attention."
: counts.done > 0
? "Healthy — tasks are completing with none currently blocked."
: "No completed or blocked tasks yet.";
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<HelpTip label={dotHint}>
<span <span
className={ className={
"h-2 w-2 rounded-full " + "h-2 w-2 rounded-full inline-block " +
(atRisk (atRisk
? "bg-amber-500" ? "bg-amber-500"
: counts.done > 0 : counts.done > 0
? "bg-emerald-500" ? "bg-emerald-500"
: "bg-muted") : "bg-muted")
} }
title={atRisk ? "At risk: blocked tasks" : "Healthy"}
/> />
</HelpTip>
<div className="flex items-center gap-2 text-xs"> <div className="flex items-center gap-2 text-xs">
<span className="text-emerald-600 dark:text-emerald-400"> <span className="text-emerald-600 dark:text-emerald-400">
{counts.done} done {counts.done} done
@@ -98,14 +121,15 @@ function TasksCell({ counts }: { counts: ProjectTaskCounts | null }) {
function CiWatchBadge({ enabled }: { enabled: boolean }) { function CiWatchBadge({ enabled }: { enabled: boolean }) {
if (!enabled) return null; if (!enabled) return null;
return ( return (
<HelpTip label="Opens a fix task automatically when this project's CI goes red on its default branch.">
<Badge <Badge
variant="outline" variant="outline"
className="bg-sky-500/10 text-sky-500 border-sky-500/30" className="bg-sky-500/10 text-sky-500 border-sky-500/30"
title="CI-watch armed"
> >
<Radar className="h-3 w-3 mr-1" /> <Radar className="h-3 w-3 mr-1" />
CI-Watch CI-Watch
</Badge> </Badge>
</HelpTip>
); );
} }
@@ -197,35 +221,25 @@ export function ProjectTable({ projects, isLoading }: ProjectTableProps) {
<TableCell> <TableCell>
{getTokenBadge(project.has_git_token)} {getTokenBadge(project.has_git_token)}
</TableCell> </TableCell>
<TableCell> <TableCell>{getStatusBadge(project.is_active)}</TableCell>
{project.is_active ? (
<Badge className="bg-green-500/10 text-green-500">
Active
</Badge>
) : (
<Badge
variant="outline"
className="text-muted-foreground"
>
Inactive
</Badge>
)}
</TableCell>
<TableCell> <TableCell>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<HelpTip label="Edit project settings and CI/CD commands">
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => setEditingProjectId(project.id)} onClick={() => setEditingProjectId(project.id)}
title="Edit project" aria-label="Edit project"
> >
<Pencil className="h-4 w-4" /> <Pencil className="h-4 w-4" />
</Button> </Button>
</HelpTip>
<HelpTip label="Open the git repository in a new tab">
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
asChild asChild
title="View repository" aria-label="View repository"
> >
<a <a
href={getExternalUrl(project)} href={getExternalUrl(project)}
@@ -235,6 +249,7 @@ export function ProjectTable({ projects, isLoading }: ProjectTableProps) {
<ExternalLink className="h-4 w-4" /> <ExternalLink className="h-4 w-4" />
</a> </a>
</Button> </Button>
</HelpTip>
</div> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -261,19 +276,22 @@ export function ProjectTable({ projects, isLoading }: ProjectTableProps) {
</p> </p>
</div> </div>
<div className="flex shrink-0 items-center gap-1"> <div className="flex shrink-0 items-center gap-1">
<HelpTip label="Edit project settings and CI/CD commands">
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => setEditingProjectId(project.id)} onClick={() => setEditingProjectId(project.id)}
title="Edit project" aria-label="Edit project"
> >
<Pencil className="h-4 w-4" /> <Pencil className="h-4 w-4" />
</Button> </Button>
</HelpTip>
<HelpTip label="Open the git repository in a new tab">
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
asChild asChild
title="View repository" aria-label="View repository"
> >
<a <a
href={getExternalUrl(project)} href={getExternalUrl(project)}
@@ -283,6 +301,7 @@ export function ProjectTable({ projects, isLoading }: ProjectTableProps) {
<ExternalLink className="h-4 w-4" /> <ExternalLink className="h-4 w-4" />
</a> </a>
</Button> </Button>
</HelpTip>
</div> </div>
</div> </div>
<div className="mt-3 divide-y"> <div className="mt-3 divide-y">
@@ -299,18 +318,7 @@ export function ProjectTable({ projects, isLoading }: ProjectTableProps) {
</ResponsiveTableCardRow> </ResponsiveTableCardRow>
<ResponsiveTableCardRow label="Status"> <ResponsiveTableCardRow label="Status">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{project.is_active ? ( {getStatusBadge(project.is_active)}
<Badge className="bg-green-500/10 text-green-500">
Active
</Badge>
) : (
<Badge
variant="outline"
className="text-muted-foreground"
>
Inactive
</Badge>
)}
{project.ci_watch_enabled && <CiWatchBadge enabled />} {project.ci_watch_enabled && <CiWatchBadge enabled />}
</div> </div>
</ResponsiveTableCardRow> </ResponsiveTableCardRow>
@@ -206,6 +206,27 @@ describe("AIRoutingCard", () => {
).toHaveLength(20); ).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 () => { it("saving the mix with no picks shows an error and never calls applyMode", async () => {
render(withQueryClient(<AIRoutingCard />)); render(withQueryClient(<AIRoutingCard />));
await screen.findByText("Per-agent override (mix mode)"); 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); resolveQueue.current.shift()?.(undefined);
await waitFor(() => expect(beta).not.toBeDisabled()); 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(); ).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 () => { it("disables Save until both fields are filled", async () => {
render(withQueryClient(<TelegramCredentialsForm />)); render(withQueryClient(<TelegramCredentialsForm />));
await screen.findByText("No credentials configured"); await screen.findByText("No credentials configured");
@@ -37,6 +37,14 @@ describe("TikTokCredentialsForm", () => {
).toBeInTheDocument(); ).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 () => { it("disables Save until all 4 fields are filled", async () => {
render(withQueryClient(<TikTokCredentialsForm />)); render(withQueryClient(<TikTokCredentialsForm />));
await screen.findByText("No credentials configured"); await screen.findByText("No credentials configured");
@@ -37,6 +37,15 @@ describe("XCredentialsForm", () => {
).toBeInTheDocument(); ).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 () => { it("disables Save until all 4 fields are filled", async () => {
render(withQueryClient(<XCredentialsForm />)); render(withQueryClient(<XCredentialsForm />));
await screen.findByText("No credentials configured"); 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 { SelfHostedSection } from "@/components/settings/self-hosted-section";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { HelpTip } from "@/components/ui/help-tip";
// Matches the roboco agents_config AGENT_ROLE_MAP / AGENT_TEAM_MAP. // 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 // Hard-coded so Mix mode shows a stable 18-row picker without an extra
@@ -353,17 +354,23 @@ export function AIRoutingCard() {
{/* -------- Grok (xAI) key -------- */} {/* -------- Grok (xAI) key -------- */}
<section className="space-y-2"> <section className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<HelpTip label="Stored encrypted server-side; never displayed once saved.">
<Label className="text-sm font-medium"> <Label className="text-sm font-medium">
Grok (xAI) API key Grok (xAI) API key
</Label> </Label>
</HelpTip>
{hasGrokKey ? ( {hasGrokKey ? (
<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"> <Badge className="bg-emerald-500/10 text-emerald-600 border-0">
<KeyRound className="h-3 w-3" /> key set <KeyRound className="h-3 w-3" /> key set
</Badge> </Badge>
</HelpTip>
) : ( ) : (
<HelpTip label="Required before any agent can route to a Grok model.">
<Badge className="bg-amber-500/10 text-amber-600 border-0"> <Badge className="bg-amber-500/10 text-amber-600 border-0">
<Key className="h-3 w-3" /> not set <Key className="h-3 w-3" /> not set
</Badge> </Badge>
</HelpTip>
)} )}
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
@@ -405,17 +412,23 @@ export function AIRoutingCard() {
{/* -------- Ollama key -------- */} {/* -------- Ollama key -------- */}
<section className="space-y-2"> <section className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<HelpTip label="Stored encrypted server-side; never displayed once saved.">
<Label className="text-sm font-medium"> <Label className="text-sm font-medium">
Ollama Cloud API key Ollama Cloud API key
</Label> </Label>
</HelpTip>
{hasOllamaKey ? ( {hasOllamaKey ? (
<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"> <Badge className="bg-emerald-500/10 text-emerald-600 border-0">
<KeyRound className="h-3 w-3" /> key set <KeyRound className="h-3 w-3" /> key set
</Badge> </Badge>
</HelpTip>
) : ( ) : (
<HelpTip label="Required before any agent can route to an Ollama Cloud model.">
<Badge className="bg-amber-500/10 text-amber-600 border-0"> <Badge className="bg-amber-500/10 text-amber-600 border-0">
<Key className="h-3 w-3" /> not set <Key className="h-3 w-3" /> not set
</Badge> </Badge>
</HelpTip>
)} )}
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
@@ -467,7 +480,9 @@ export function AIRoutingCard() {
{/* -------- Mode toggle -------- */} {/* -------- Mode toggle -------- */}
<section className="space-y-3"> <section className="space-y-3">
<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> <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"> <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-2">
<ModeButton <ModeButton
icon={<ShieldCheck className="h-4 w-4" />} icon={<ShieldCheck className="h-4 w-4" />}
@@ -584,9 +599,11 @@ export function AIRoutingCard() {
<Separator /> <Separator />
<section className="space-y-3"> <section className="space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<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"> <Label className="text-sm font-medium">
Per-agent override (mix mode) Per-agent override (mix mode)
</Label> </Label>
</HelpTip>
<Button size="sm" onClick={saveMix} disabled={applyMode.isPending}> <Button size="sm" onClick={saveMix} disabled={applyMode.isPending}>
{applyMode.isPending ? "Saving…" : "Save mix"} {applyMode.isPending ? "Saving…" : "Save mix"}
</Button> </Button>
@@ -32,6 +32,7 @@ import {
import { XCredentialsForm } from "@/components/settings/x-credentials-card"; import { XCredentialsForm } from "@/components/settings/x-credentials-card";
import { TikTokCredentialsForm } from "@/components/settings/tiktok-credentials-card"; import { TikTokCredentialsForm } from "@/components/settings/tiktok-credentials-card";
import { TelegramCredentialsForm } from "@/components/settings/telegram-credentials-card"; import { TelegramCredentialsForm } from "@/components/settings/telegram-credentials-card";
import { HelpTip } from "@/components/ui/help-tip";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Flag, ChevronDown, ChevronRight } from "lucide-react"; import { Flag, ChevronDown, ChevronRight } from "lucide-react";
import { toast } from "sonner"; 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.", "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() { export function FeatureFlagsCard() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [xCredsOpen, setXCredsOpen] = useState(false); const [xCredsOpen, setXCredsOpen] = useState(false);
@@ -185,7 +249,14 @@ export function FeatureFlagsCard() {
> >
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div className="min-w-0"> <div className="min-w-0">
{/* 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> <Label htmlFor={`flag-${flag.key}`}>{flag.label}</Label>
</HelpTip>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{FLAG_DESCRIPTIONS[flag.key] ?? ""} {FLAG_DESCRIPTIONS[flag.key] ?? ""}
</p> </p>
@@ -160,7 +160,9 @@ export function SelfHostedSection({
{/* Base URL input */} {/* Base URL input */}
<div className="space-y-1"> <div className="space-y-1">
<HelpTip label="Any OpenAI-compatible endpoint — e.g. Ollama, vLLM, LM Studio.">
<Label className="text-xs text-muted-foreground">Base URL</Label> <Label className="text-xs text-muted-foreground">Base URL</Label>
</HelpTip>
<div className="flex gap-2"> <div className="flex gap-2">
<Input <Input
type="text" type="text"
@@ -178,10 +180,12 @@ export function SelfHostedSection({
{/* Auth token input with Eye toggle */} {/* Auth token input with Eye toggle */}
<div className="space-y-1"> <div className="space-y-1">
<HelpTip label="Stored encrypted server-side; never displayed once saved.">
<Label className="text-xs text-muted-foreground"> <Label className="text-xs text-muted-foreground">
Auth token{" "} Auth token{" "}
<span className="text-muted-foreground/60">(optional)</span> <span className="text-muted-foreground/60">(optional)</span>
</Label> </Label>
</HelpTip>
<div className="flex gap-2"> <div className="flex gap-2">
<div className="relative flex-1"> <div className="relative flex-1">
<Input <Input
@@ -320,7 +324,17 @@ export function SelfHostedSection({
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Last refreshed:{" "} 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> </p>
<Button <Button
variant="ghost" variant="ghost"
@@ -353,9 +367,11 @@ export function SelfHostedSection({
{m.model_name} {m.model_name}
</span> </span>
</div> </div>
<HelpTip label="Found by probing the endpoint's model list — not manually added.">
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
auto-discovered auto-discovered
</Badge> </Badge>
</HelpTip>
</div> </div>
))} ))}
</div> </div>
@@ -6,6 +6,7 @@ import { telegramApi } from "@/lib/api";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { HelpTip } from "@/components/ui/help-tip";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -87,9 +88,13 @@ export function TelegramCredentialsForm() {
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{FIELDS.map((field) => ( {FIELDS.map((field) => (
<div key={field.key} className="space-y-2"> <div key={field.key} className="space-y-2">
<HelpTip label="Stored encrypted server-side; never displayed again once saved.">
<Label htmlFor={`tg-cred-${field.key}`}> <Label htmlFor={`tg-cred-${field.key}`}>
{status?.has_credentials ? `Replace ${field.label}` : field.label} {status?.has_credentials
? `Replace ${field.label}`
: field.label}
</Label> </Label>
</HelpTip>
<Input <Input
id={`tg-cred-${field.key}`} id={`tg-cred-${field.key}`}
type="password" type="password"
@@ -6,6 +6,7 @@ import { videoApi } from "@/lib/api";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { HelpTip } from "@/components/ui/help-tip";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -106,9 +107,13 @@ export function TikTokCredentialsForm() {
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{FIELDS.map((field) => ( {FIELDS.map((field) => (
<div key={field.key} className="space-y-2"> <div key={field.key} className="space-y-2">
<HelpTip label="Stored encrypted server-side; never displayed again once saved.">
<Label htmlFor={`tiktok-cred-${field.key}`}> <Label htmlFor={`tiktok-cred-${field.key}`}>
{status?.has_credentials ? `Replace ${field.label}` : field.label} {status?.has_credentials
? `Replace ${field.label}`
: field.label}
</Label> </Label>
</HelpTip>
<Input <Input
id={`tiktok-cred-${field.key}`} id={`tiktok-cred-${field.key}`}
type="password" type="password"
@@ -13,6 +13,7 @@ import {
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { HelpTip } from "@/components/ui/help-tip";
import { HardDrive, Save } from "lucide-react"; import { HardDrive, Save } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -72,9 +73,11 @@ export function TranscriptRetentionCard() {
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<HelpTip label="Only takes effect while the transcript_prune_enabled feature flag is on.">
<Label htmlFor="transcript-retention-days"> <Label htmlFor="transcript-retention-days">
Retention window (days) Retention window (days)
</Label> </Label>
</HelpTip>
<Input <Input
id="transcript-retention-days" id="transcript-retention-days"
type="number" type="number"
@@ -6,6 +6,7 @@ import { xApi } from "@/lib/api";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { HelpTip } from "@/components/ui/help-tip";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -104,9 +105,13 @@ export function XCredentialsForm() {
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{FIELDS.map((field) => ( {FIELDS.map((field) => (
<div key={field.key} className="space-y-2"> <div key={field.key} className="space-y-2">
<HelpTip label="Stored encrypted server-side; never displayed again once saved.">
<Label htmlFor={`x-cred-${field.key}`}> <Label htmlFor={`x-cred-${field.key}`}>
{status?.has_credentials ? `Replace ${field.label}` : field.label} {status?.has_credentials
? `Replace ${field.label}`
: field.label}
</Label> </Label>
</HelpTip>
<Input <Input
id={`x-cred-${field.key}`} id={`x-cred-${field.key}`}
type="password" type="password"