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 { useRouter, useSearchParams } from "next/navigation";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Skeleton } from "@/components/ui/skeleton";
import { GoalsTab } from "@/components/business/goals-tab";
import { CompanyScorecardCard } from "@/components/business/company-scorecard-card";
@@ -13,7 +18,36 @@ import { PitchesTab } from "@/components/business/pitches-tab";
// 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];
function isValidTab(value: string | null): value is TabValue {
@@ -50,10 +84,23 @@ function BusinessPageContent() {
<Tabs value={activeTab} onValueChange={handleTabChange}>
<TabsList>
<TabsTrigger value="goals">Goals</TabsTrigger>
<TabsTrigger value="scorecard">Scorecard</TabsTrigger>
<TabsTrigger value="secretary">Secretary</TabsTrigger>
<TabsTrigger value="pitches">Pitches</TabsTrigger>
{TAB_DEFS.map((tab) => (
<Tooltip key={tab.value}>
<TooltipTrigger asChild>
{/* 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>
<TabsContent value="goals" className="mt-4">
@@ -16,6 +16,7 @@ import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { OfflineState } from "@/components/ui/offline-state";
import { HelpTip } from "@/components/ui/help-tip";
import { usePageRefresh } from "@/hooks";
import {
Bell,
@@ -64,6 +65,21 @@ const typeIcons: Record<NotificationType, React.ReactNode> = {
[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> = {
[NotificationPriority.NORMAL]:
"bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300",
@@ -92,7 +108,9 @@ function NotificationCard({
>
<CardContent className="p-4">
<div className="flex items-start gap-3">
<div className="mt-1">{typeIcons[notification.type]}</div>
<HelpTip label={typeLabels[notification.type]}>
<div className="mt-1">{typeIcons[notification.type]}</div>
</HelpTip>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium">{notification.subject}</span>
@@ -116,12 +134,14 @@ function NotificationCard({
href={`/tasks/${notification.related_task_id}`}
prefetch={false}
>
<Badge
variant="outline"
className="text-xs hover:bg-muted cursor-pointer"
>
Task #{notification.related_task_id.slice(0, 8)}
</Badge>
<HelpTip label={notification.related_task_id}>
<Badge
variant="outline"
className="text-xs hover:bg-muted cursor-pointer"
>
Task #{notification.related_task_id.slice(0, 8)}
</Badge>
</HelpTip>
</Link>
)}
</div>
@@ -130,8 +150,11 @@ function NotificationCard({
</div>
<div className="flex items-center justify-between mt-3">
<div className="text-xs text-muted-foreground">
From: {notification.from_agent.slice(0, 8)} {" "}
{formatDistanceToNow(new Date(notification.timestamp))} ago
From:{" "}
<HelpTip label={notification.from_agent}>
<span>{notification.from_agent.slice(0, 8)}</span>
</HelpTip>{" "}
{formatDistanceToNow(new Date(notification.timestamp))} ago
</div>
<div className="flex items-center gap-2">
{!notification.is_read && (
@@ -254,9 +277,11 @@ function NotificationsPageContent() {
<div className="grid grid-cols-3 gap-2 sm:gap-4">
<Card className="py-4 sm:py-6">
<CardHeader className="px-3 pb-2 sm:px-6">
<CardTitle className="text-sm font-medium text-muted-foreground">
Total
</CardTitle>
<HelpTip label="Scoped to the active tab's filter, not the full mailbox">
<CardTitle className="text-sm font-medium text-muted-foreground">
Total
</CardTitle>
</HelpTip>
</CardHeader>
<CardContent className="px-3 sm:px-6">
<div className="text-2xl font-bold">{data.total}</div>
@@ -264,10 +289,12 @@ function NotificationsPageContent() {
</Card>
<Card className="py-4 sm:py-6">
<CardHeader className="px-3 pb-2 sm:px-6">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-1">
<Mail className="h-4 w-4" />
Unread
</CardTitle>
<HelpTip label="Unread count within the active tab's filter">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-1">
<Mail className="h-4 w-4" />
Unread
</CardTitle>
</HelpTip>
</CardHeader>
<CardContent className="px-3 sm:px-6">
<div className="text-2xl font-bold text-blue-600">
@@ -277,10 +304,12 @@ function NotificationsPageContent() {
</Card>
<Card className="py-4 sm:py-6">
<CardHeader className="px-3 pb-2 sm:px-6">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-1">
<Bell className="h-4 w-4" />
Pending Ack
</CardTitle>
<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">
<Bell className="h-4 w-4" />
Pending Ack
</CardTitle>
</HelpTip>
</CardHeader>
<CardContent className="px-3 sm:px-6">
<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);
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",
);
});
});
+39 -10
View File
@@ -20,6 +20,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { HelpTip } from "@/components/ui/help-tip";
import { Settings, Palette, Bell, Server, User } from "lucide-react";
import { API_URL, WS_URL } from "@/lib/constants";
import { TranscriptRetentionCard } from "@/components/settings/transcript-retention-card";
@@ -75,9 +76,11 @@ export default function SettingsPage() {
<p className="text-sm text-muted-foreground">
Chief Executive Officer
</p>
<p className="text-xs text-muted-foreground mt-1">
Agent ID: 00000000-0000-0000-0000-000000000001
</p>
<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
</p>
</HelpTip>
</div>
</div>
</CardContent>
@@ -97,7 +100,9 @@ export default function SettingsPage() {
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label>Theme</Label>
<HelpTip label="Saved to this browser only — doesn't sync across devices.">
<Label>Theme</Label>
</HelpTip>
<p className="text-sm text-muted-foreground">
Select your preferred color scheme
</p>
@@ -142,7 +147,9 @@ export default function SettingsPage() {
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label>Auto Refresh</Label>
<HelpTip label="Also enables the Refresh Interval picker below.">
<Label>Auto Refresh</Label>
</HelpTip>
<p className="text-sm text-muted-foreground">
Periodically re-fetch the current page&apos;s data
</p>
@@ -152,7 +159,15 @@ export default function SettingsPage() {
<Separator />
<div className="flex items-center justify-between">
<div>
<Label>Refresh Interval</Label>
<HelpTip
label={
!autoRefresh
? "Disabled — turn on Auto Refresh above to pick an interval."
: undefined
}
>
<Label>Refresh Interval</Label>
</HelpTip>
<p className="text-sm text-muted-foreground">
How often to fetch new data (seconds)
</p>
@@ -191,7 +206,9 @@ export default function SettingsPage() {
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label>Enable Notifications</Label>
<HelpTip label="Also gates Sound Alerts below.">
<Label>Enable Notifications</Label>
</HelpTip>
<p className="text-sm text-muted-foreground">
Toast + bell for incoming agent notifications
</p>
@@ -204,7 +221,15 @@ export default function SettingsPage() {
<Separator />
<div className="flex items-center justify-between">
<div>
<Label>Sound Alerts</Label>
<HelpTip
label={
!notificationsEnabled
? "Disabled — turn on Enable Notifications above first."
: undefined
}
>
<Label>Sound Alerts</Label>
</HelpTip>
<p className="text-sm text-muted-foreground">
Chime on new notifications
</p>
@@ -231,11 +256,15 @@ export default function SettingsPage() {
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>API URL</Label>
<HelpTip label="Read-only — set via NEXT_PUBLIC_API_URL at build time.">
<Label>API URL</Label>
</HelpTip>
<Input value={API_URL} readOnly className="bg-muted" />
</div>
<div className="space-y-2">
<Label>WebSocket URL</Label>
<HelpTip label="Read-only — set via NEXT_PUBLIC_WS_URL at build time.">
<Label>WebSocket URL</Label>
</HelpTip>
<Input value={WS_URL} readOnly className="bg-muted" />
</div>
<p className="text-xs text-muted-foreground">
@@ -11,6 +11,7 @@ import {
} from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { OfflineState } from "@/components/ui/offline-state";
import { HelpTip } from "@/components/ui/help-tip";
// ---------------------------------------------------------------------------
// Loading skeleton — three grouped skeleton blocks
@@ -56,9 +57,12 @@ function ScorecardSkeleton() {
// Section header helper
// ---------------------------------------------------------------------------
function SectionLabel({ children }: { children: React.ReactNode }) {
function SectionLabel({ children, ...props }: React.ComponentProps<"p">) {
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}
</p>
);
@@ -71,14 +75,17 @@ function SectionLabel({ children }: { children: React.ReactNode }) {
interface DeliveryMetricProps {
label: string;
value: number;
hint: string;
}
function DeliveryMetric({ label, value }: DeliveryMetricProps) {
function DeliveryMetric({ label, value, hint }: DeliveryMetricProps) {
return (
<div className="rounded-lg border bg-card p-3 text-center">
<div className="text-2xl font-bold tabular-nums">{value}</div>
<div className="text-xs text-muted-foreground mt-0.5">{label}</div>
</div>
<HelpTip label={hint}>
<div className="rounded-lg border bg-card p-3 text-center">
<div className="text-2xl font-bold tabular-nums">{value}</div>
<div className="text-xs text-muted-foreground mt-0.5">{label}</div>
</div>
</HelpTip>
);
}
@@ -91,12 +98,25 @@ function DeliverySection({ delivery }: DeliverySectionProps) {
<div className="space-y-2">
<SectionLabel>Delivery</SectionLabel>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<DeliveryMetric label="In flight" value={delivery.in_flight} />
<DeliveryMetric label="Blocked" value={delivery.blocked} />
<DeliveryMetric label="Awaiting CEO" value={delivery.awaiting_ceo} />
<DeliveryMetric
label="In flight"
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
label="Done (30 d)"
value={delivery.completed_30d ?? 0}
hint="Tasks completed in the last 30 days"
/>
</div>
</div>
@@ -141,7 +161,9 @@ function SpendSection({ spend }: SpendSectionProps) {
</div>
)}
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Monthly cap</span>
<HelpTip label="Set via operating_policy.monthly_budget_cap on the Goals tab">
<span className="text-muted-foreground">Monthly cap</span>
</HelpTip>
{monthly_budget_cap_usd === null ? (
<span className="text-muted-foreground italic">
No budget cap set
@@ -183,7 +205,9 @@ function SpeedSection({ medianLeadTimeHours }: SpeedSectionProps) {
<SectionLabel>Speed</SectionLabel>
<div className="rounded-lg border p-3">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Median lead time</span>
<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>
</HelpTip>
{hasData ? (
<span className="font-medium tabular-nums">
{medianLeadTimeHours.toFixed(1)}h median &mdash; target:
@@ -210,7 +234,9 @@ function StubObjectivesSection() {
return (
<div className="space-y-2">
<SectionLabel>Objectives</SectionLabel>
<HelpTip label="Placeholder — not yet wired to the Goals tab's Objectives list">
<SectionLabel>Objectives</SectionLabel>
</HelpTip>
<div className="space-y-2">
{stubs.map((stub) => (
<div
+10 -3
View File
@@ -22,6 +22,7 @@ import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { OfflineState } from "@/components/ui/offline-state";
import { HelpTip } from "@/components/ui/help-tip";
// ---------------------------------------------------------------------------
// Helpers
@@ -309,7 +310,9 @@ function GoalsForm({ goals, refetch }: GoalsFormProps) {
{/* Brand voice */}
<div className="space-y-2">
<Label htmlFor="brand-voice">Brand voice</Label>
<HelpTip label="Feeds every X/TikTok draft and the Head of Marketing's feature-spotlight posts">
<Label htmlFor="brand-voice">Brand voice</Label>
</HelpTip>
<Textarea
id="brand-voice"
rows={3}
@@ -335,7 +338,9 @@ function GoalsForm({ goals, refetch }: GoalsFormProps) {
{/* Objectives */}
<div className="space-y-2">
<Label>Objectives</Label>
<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>
</HelpTip>
<ObjectivesEditor
items={objectivesVal}
onChange={(items) => setObjectives(items)}
@@ -345,7 +350,9 @@ function GoalsForm({ goals, refetch }: GoalsFormProps) {
{/* Operating policy */}
<div className="space-y-2">
<Label>Operating policy</Label>
<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>
</HelpTip>
<PolicyEditor
policy={policyVal}
onChange={(p) => setPolicy(p)}
+12 -3
View File
@@ -7,6 +7,7 @@ import { Check, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { HelpTip } from "@/components/ui/help-tip";
import { Skeleton } from "@/components/ui/skeleton";
import { OfflineState } from "@/components/ui/offline-state";
import { RequiredNotesDialog } from "@/components/ui/required-notes-dialog";
@@ -62,6 +63,12 @@ interface PitchCardProps {
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) {
const [approveOpen, setApproveOpen] = useState(false);
const [rejectOpen, setRejectOpen] = useState(false);
@@ -73,9 +80,11 @@ function PitchCard({ pitch, onApprove, onReject, busy }: PitchCardProps) {
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg">{pitch.title}</CardTitle>
<Badge variant={proposed ? "default" : "secondary"}>
{pitch.status}
</Badge>
<HelpTip label={PITCH_STATUS_HINTS[pitch.status]}>
<Badge variant={proposed ? "default" : "secondary"}>
{pitch.status}
</Badge>
</HelpTip>
</div>
{pitch.target_cells.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
@@ -168,7 +168,9 @@ export function ConventionsTab({ projectId }: { projectId: string }) {
const moduleBoundaries = (
<Card className="lg:flex lg:flex-col">
<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>
Which definition kinds are forbidden in each module. Click a kind to
toggle it.
@@ -335,9 +337,17 @@ export function ConventionsTab({ projectId }: { projectId: string }) {
placeholder="rule-id"
onChange={(e) => updateCustom(index, { id: e.target.value })}
/>
<span className="w-10 text-right text-xs text-muted-foreground">
{rule.level}
</span>
<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">
{rule.level}
</span>
</HelpTip>
<Switch
checked={rule.level === "block"}
onCheckedChange={(checked) =>
@@ -462,23 +472,35 @@ export function ConventionsTab({ projectId }: { projectId: string }) {
{recentViolations}
<div className="flex justify-between">
<Button
variant="outline"
size="sm"
disabled={restore.isPending}
onClick={() => restore.mutate()}
<HelpTip label={restore.isPending ? "Restoring…" : null}>
<Button
variant="outline"
size="sm"
disabled={restore.isPending}
onClick={() => restore.mutate()}
>
Restore from last-good
</Button>
</HelpTip>
<HelpTip
label={
save.isPending
? "Saving…"
: draft == null && !usingDefaults
? "Edit a module, rule, waiver, or custom rule above to enable saving."
: null
}
>
Restore from last-good
</Button>
<Button
size="sm"
disabled={(draft == null && !usingDefaults) || save.isPending}
onClick={() => save.mutate(draft ?? standard)}
>
{usingDefaults && draft == null
? "Save defaults to repo"
: "Save to repo"}
</Button>
<Button
size="sm"
disabled={(draft == null && !usingDefaults) || save.isPending}
onClick={() => save.mutate(draft ?? standard)}
>
{usingDefaults && draft == null
? "Save defaults to repo"
: "Save to repo"}
</Button>
</HelpTip>
</div>
</div>
);
@@ -19,6 +19,7 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { HelpTip } from "@/components/ui/help-tip";
import {
AtSign,
ChevronDown,
@@ -30,16 +31,31 @@ import {
const HISTORY_LIMIT = 50;
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 =
| { kind: "x"; entry: XPostHistoryEntry }
| { kind: "video"; entry: VideoPostHistoryEntry };
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")
return { label: "Feature spotlight", icon: Sparkles };
return { label: "X reply", icon: AtSign };
return {
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;
@@ -48,9 +64,13 @@ function xKindMeta(source: XPostHistoryEntry["source"]) {
function UnifiedHistoryRow({ row }: { row: UnifiedRow }) {
const posted = row.entry.status === "completed";
const statusBadge = posted ? (
<Badge className="bg-green-600 hover:bg-green-600">Posted</Badge>
<HelpTip label="Approved and successfully posted">
<Badge className="bg-green-600 hover:bg-green-600">Posted</Badge>
</HelpTip>
) : (
<Badge variant="destructive">Rejected</Badge>
<HelpTip label="Rejected — this draft was never posted">
<Badge variant="destructive">Rejected</Badge>
</HelpTip>
);
const timestamp = (
<span className="ml-auto text-xs text-muted-foreground">
@@ -63,8 +83,12 @@ function UnifiedHistoryRow({ row }: { row: UnifiedRow }) {
return (
<div className="rounded-lg border p-3 text-sm">
<div className="mb-1 flex flex-wrap items-center gap-2">
<meta.icon className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{meta.label}</span>
<HelpTip label={meta.hint}>
<span className="inline-flex items-center gap-1.5">
<meta.icon className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{meta.label}</span>
</span>
</HelpTip>
{statusBadge}
{timestamp}
</div>
@@ -92,10 +116,16 @@ function UnifiedHistoryRow({ row }: { row: UnifiedRow }) {
return (
<div className="rounded-lg border p-3 text-sm">
<div className="mb-1 flex flex-wrap items-center gap-2">
<Film className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">Video</span>
<HelpTip label={VIDEO_HINT}>
<span className="inline-flex items-center gap-1.5">
<Film className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">Video</span>
</span>
</HelpTip>
{row.entry.occasion && (
<Badge variant="outline">{row.entry.occasion}</Badge>
<HelpTip label="The occasion/event this video was drafted for">
<Badge variant="outline">{row.entry.occasion}</Badge>
</HelpTip>
)}
{statusBadge}
{timestamp}
@@ -8,6 +8,7 @@ import {
derivePipelineStage,
pipelineStageColor,
pipelineStageLabel,
type PipelineStage,
} from "./video-pipeline-utils";
import { RerenderControl } from "@/components/dashboard/video-rerender-control";
import {
@@ -19,8 +20,27 @@ import {
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { HelpTip } from "@/components/ui/help-tip";
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
// derived (never fetched) from status + render_status/render_attempts —
// 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">
<Film className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="font-medium">{item.title}</span>
{item.occasion && <Badge variant="outline">{item.occasion}</Badge>}
<Badge className={`${pipelineStageColor(stage)} text-white`}>
{pipelineStageLabel(stage)}
</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`}>
{pipelineStageLabel(stage)}
</Badge>
</HelpTip>
{stage.kind === "awaiting_approval" && (
<Link
href={`/tasks/${item.task_id}`}
@@ -31,6 +31,7 @@ import {
import { Textarea } from "@/components/ui/textarea";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { HelpTip } from "@/components/ui/help-tip";
import { ProjectSelector } from "@/components/projects/project-selector";
import { useProjects } from "@/hooks/use-projects";
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)
// mirrors XPostQueue's sourceMeta pattern and costs nothing to extend later.
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 {
@@ -201,9 +216,17 @@ function VideoPostRow({
return (
<div className="rounded-lg border p-4 transition-colors hover:bg-muted/50">
<div className="mb-3 flex flex-wrap items-center gap-2">
<meta.icon className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{meta.label}</span>
{post.occasion && <Badge variant="outline">{post.occasion}</Badge>}
<HelpTip label={meta.hint}>
<span className="inline-flex items-center gap-1.5">
<meta.icon className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{meta.label}</span>
</span>
</HelpTip>
{post.occasion && (
<HelpTip label="The occasion/event this video was drafted for">
<Badge variant="outline">{post.occasion}</Badge>
</HelpTip>
)}
{canRerender && (
<div className="ml-auto">
<RerenderControl authoringTaskId={post.source_task_id as string} />
@@ -222,30 +245,40 @@ function VideoPostRow({
<div className="mb-3 space-y-2">
<div className="flex gap-2">
<Button
type="button"
size="sm"
variant={cut === "vertical" ? "default" : "outline"}
disabled={!post.mp4_paths?.vertical}
title={
post.mp4_paths?.vertical ? undefined : "9:16 hasn't rendered yet"
<HelpTip
label={
post.mp4_paths?.vertical
? "Preview the 9:16 cut"
: "9:16 hasn't rendered yet"
}
onClick={() => setCut("vertical")}
>
9:16{!post.mp4_paths?.vertical && " (missing)"}
</Button>
<Button
type="button"
size="sm"
variant={cut === "square" ? "default" : "outline"}
disabled={!post.mp4_paths?.square}
title={
post.mp4_paths?.square ? undefined : "1:1 hasn't rendered yet"
<Button
type="button"
size="sm"
variant={cut === "vertical" ? "default" : "outline"}
disabled={!post.mp4_paths?.vertical}
onClick={() => setCut("vertical")}
>
9:16{!post.mp4_paths?.vertical && " (missing)"}
</Button>
</HelpTip>
<HelpTip
label={
post.mp4_paths?.square
? "Preview the 1:1 cut"
: "1:1 hasn't rendered yet"
}
onClick={() => setCut("square")}
>
1:1{!post.mp4_paths?.square && " (missing)"}
</Button>
<Button
type="button"
size="sm"
variant={cut === "square" ? "default" : "outline"}
disabled={!post.mp4_paths?.square}
onClick={() => setCut("square")}
>
1:1{!post.mp4_paths?.square && " (missing)"}
</Button>
</HelpTip>
</div>
{post.mp4_paths?.[cut] ? (
<video
@@ -272,9 +305,11 @@ function VideoPostRow({
checked={editX}
onCheckedChange={(c) => setEditX(c === true)}
/>
<Label htmlFor={`${post.task_id}-x-edit`} className="text-sm">
Edit X caption
</Label>
<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">
Edit X caption
</Label>
</HelpTip>
</div>
<Textarea
value={xCaption}
@@ -283,11 +318,13 @@ function VideoPostRow({
rows={2}
className={xOverLimit ? "border-destructive" : undefined}
/>
<p
className={`text-right text-xs ${xOverLimit ? "text-destructive" : "text-muted-foreground"}`}
>
{xCaption.length}/{MAX_X_CAPTION_CHARS}
</p>
<HelpTip label={`X's per-post character limit (${MAX_X_CAPTION_CHARS})`}>
<p
className={`text-right text-xs ${xOverLimit ? "text-destructive" : "text-muted-foreground"}`}
>
{xCaption.length}/{MAX_X_CAPTION_CHARS}
</p>
</HelpTip>
</div>
)}
@@ -299,12 +336,14 @@ function VideoPostRow({
checked={editTiktok}
onCheckedChange={(c) => setEditTiktok(c === true)}
/>
<Label
htmlFor={`${post.task_id}-tiktok-edit`}
className="text-sm"
>
Edit TikTok caption
</Label>
<HelpTip label="Uncheck to keep posting the caption already saved on this draft instead of your edit">
<Label
htmlFor={`${post.task_id}-tiktok-edit`}
className="text-sm"
>
Edit TikTok caption
</Label>
</HelpTip>
</div>
<Textarea
value={tiktokCaption}
@@ -313,11 +352,15 @@ function VideoPostRow({
rows={2}
className={tiktokOverLimit ? "border-destructive" : undefined}
/>
<p
className={`text-right text-xs ${tiktokOverLimit ? "text-destructive" : "text-muted-foreground"}`}
<HelpTip
label={`TikTok's caption character limit (${MAX_TIKTOK_CAPTION_CHARS})`}
>
{tiktokCaption.length}/{MAX_TIKTOK_CAPTION_CHARS}
</p>
<p
className={`text-right text-xs ${tiktokOverLimit ? "text-destructive" : "text-muted-foreground"}`}
>
{tiktokCaption.length}/{MAX_TIKTOK_CAPTION_CHARS}
</p>
</HelpTip>
</div>
)}
</div>
@@ -332,15 +375,17 @@ function VideoPostRow({
<XCircle className="mr-1 h-4 w-4" />
Reject
</Button>
<Button
size="sm"
className="bg-green-600 hover:bg-green-700"
disabled={approving || overLimit}
onClick={handleApprove}
>
<CheckCircle2 className="mr-1 h-4 w-4" />
Approve &amp; post
</Button>
<HelpTip label={approveHint(approving, overLimit)}>
<Button
size="sm"
className="bg-green-600 hover:bg-green-700"
disabled={approving || overLimit}
onClick={handleApprove}
>
<CheckCircle2 className="mr-1 h-4 w-4" />
Approve &amp; post
</Button>
</HelpTip>
</div>
</div>
);
@@ -408,6 +453,21 @@ function RequestVideoDialog({
brief.trim().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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
@@ -476,12 +536,14 @@ function RequestVideoDialog({
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
onClick={() => requestMutation.mutate()}
disabled={!canSubmit || requestMutation.isPending}
>
{requestMutation.isPending ? "Requesting..." : "Request"}
</Button>
<HelpTip label={requestHint}>
<Button
onClick={() => requestMutation.mutate()}
disabled={!canSubmit || requestMutation.isPending}
>
{requestMutation.isPending ? "Requesting..." : "Request"}
</Button>
</HelpTip>
</DialogFooter>
</>
) : (
@@ -12,6 +12,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { HelpTip } from "@/components/ui/help-tip";
import { RefreshCw } from "lucide-react";
import { toast } from "sonner";
@@ -85,29 +86,45 @@ 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 (
<>
<Button
type="button"
variant="outline"
size="sm"
disabled={rerenderMutation.isPending}
onClick={() => setConfirmOpen(true)}
className={
rerenderMutation.isError
? "border-destructive text-destructive"
: undefined
}
>
<RefreshCw
className={`mr-1 h-4 w-4 ${rerenderMutation.isPending ? "animate-spin" : ""}`}
/>
{rerenderMutation.isPending
? "Re-rendering..."
: rerenderMutation.isError
? "Retry re-render"
: "Re-render"}
</Button>
<HelpTip label={rerenderHint}>
<Button
type="button"
variant="outline"
size="sm"
disabled={rerenderMutation.isPending}
onClick={() => setConfirmOpen(true)}
className={
rerenderMutation.isError
? "border-destructive text-destructive"
: undefined
}
>
<RefreshCw
className={`mr-1 h-4 w-4 ${rerenderMutation.isPending ? "animate-spin" : ""}`}
/>
{rerenderMutation.isPending
? "Re-rendering..."
: rerenderMutation.isError
? "Retry re-render"
: "Re-render"}
</Button>
</HelpTip>
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<DialogContent>
<DialogHeader>
+63 -20
View File
@@ -23,6 +23,7 @@ import {
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { HelpTip } from "@/components/ui/help-tip";
import { AtSign, CheckCircle2, Rocket, Sparkles, XCircle } from "lucide-react";
import { toast } from "sonner";
@@ -30,10 +31,42 @@ const MAX_TWEET_CHARS = 280;
const _MIN_REASON_CHARS = 4;
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")
return { label: "Feature spotlight", icon: Sparkles };
return { label: "Mention reply", icon: AtSign };
return {
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 {
@@ -69,8 +102,12 @@ function XPostRow({
return (
<div className="rounded-lg border p-4 transition-colors hover:bg-muted/50">
<div className="mb-2 flex flex-wrap items-center gap-2">
<meta.icon className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{meta.label}</span>
<HelpTip label={meta.hint}>
<span className="inline-flex items-center gap-1.5">
<meta.icon className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{meta.label}</span>
</span>
</HelpTip>
{post.release_version && (
<Badge variant="outline">v{post.release_version}</Badge>
)}
@@ -92,13 +129,15 @@ function XPostRow({
rows={3}
className={overLimit ? "border-destructive" : undefined}
/>
<p
className={`mt-1 text-right text-xs ${
overLimit ? "text-destructive" : "text-muted-foreground"
}`}
>
{body.length}/{MAX_TWEET_CHARS}
</p>
<HelpTip label={`X's per-post character limit (${MAX_TWEET_CHARS})`}>
<p
className={`mt-1 text-right text-xs ${
overLimit ? "text-destructive" : "text-muted-foreground"
}`}
>
{body.length}/{MAX_TWEET_CHARS}
</p>
</HelpTip>
<div className="mt-2 flex flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-end">
<Button
@@ -110,15 +149,19 @@ function XPostRow({
<XCircle className="mr-1 h-4 w-4" />
Reject
</Button>
<Button
size="sm"
className="bg-green-600 hover:bg-green-700"
disabled={approving || overLimit || body.trim().length === 0}
onClick={() => onApprove(post.task_id, body)}
<HelpTip
label={approveHint(approving, overLimit, body.trim().length === 0)}
>
<CheckCircle2 className="mr-1 h-4 w-4" />
Approve &amp; post
</Button>
<Button
size="sm"
className="bg-green-600 hover:bg-green-700"
disabled={approving || overLimit || body.trim().length === 0}
onClick={() => onApprove(post.task_id, body)}
>
<CheckCircle2 className="mr-1 h-4 w-4" />
Approve &amp; post
</Button>
</HelpTip>
</div>
</div>
);
@@ -6,6 +6,7 @@ import { useKBDocuments } from "@/hooks/use-knowledge-base";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { HelpTip } from "@/components/ui/help-tip";
import { KBIndexTypeBadge } from "./kb-index-type-badge";
import {
FileCode,
@@ -80,7 +81,11 @@ function KBCategoryViewInner({ category }: { category: KBIndexType }) {
<div className="flex items-center gap-2 mb-1">
<KBIndexTypeBadge indexType={category} />
</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">
<Clock className="h-3 w-3" />
<span>
@@ -3,6 +3,8 @@
import { KBIndexType } from "@/types";
import { Checkbox } from "@/components/ui/checkbox";
import { Button } from "@/components/ui/button";
import { HelpTip } from "@/components/ui/help-tip";
import { getIndexTypeDescription } from "./kb-index-type-badge";
import {
FileText,
MessageSquare,
@@ -107,8 +109,12 @@ export function KBFilters({ selectedTypes, onTypesChange }: KBFiltersProps) {
checked={isChecked}
onCheckedChange={() => toggleType(type)}
/>
{config.icon}
<span className="text-sm">{config.label}</span>
<HelpTip label={getIndexTypeDescription(type)}>
<span className="flex items-center gap-2 w-fit">
{config.icon}
<span className="text-sm">{config.label}</span>
</span>
</HelpTip>
</label>
);
})}
@@ -1,6 +1,7 @@
"use client";
import { Badge } from "@/components/ui/badge";
import { HelpTip } from "@/components/ui/help-tip";
import { KBIndexType } from "@/types";
import {
FileText,
@@ -17,62 +18,77 @@ import {
const indexTypeConfig: Record<
KBIndexType,
{ label: string; color: string; icon: React.ReactNode }
{
label: string;
color: string;
icon: React.ReactNode;
description: string;
}
> = {
[KBIndexType.DOCUMENTATION]: {
label: "Docs",
color: "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300",
icon: <FileText className="h-3 w-3" />,
description: "READMEs, guides, and API docs indexed from the repo",
},
[KBIndexType.CONVERSATIONS]: {
label: "Conversations",
color: "bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300",
icon: <MessageSquare className="h-3 w-3" />,
description: "Agent-to-agent discussion excerpts and decisions",
},
[KBIndexType.JOURNALS]: {
label: "Journals",
color:
"bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300",
icon: <BookOpen className="h-3 w-3" />,
description: "Agent reflections, learnings, and personal logs",
},
[KBIndexType.ERRORS]: {
label: "Errors",
color: "bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300",
icon: <AlertTriangle className="h-3 w-3" />,
description: "Known error patterns and their solutions",
},
[KBIndexType.STANDARDS]: {
label: "Standards",
color: "bg-cyan-100 text-cyan-700 dark:bg-cyan-900 dark:text-cyan-300",
icon: <Scale className="h-3 w-3" />,
description: "Coding, security, and workflow rules the fleet follows",
},
[KBIndexType.DECISIONS]: {
label: "Decisions",
color:
"bg-indigo-100 text-indigo-700 dark:bg-indigo-900 dark:text-indigo-300",
icon: <GitBranch className="h-3 w-3" />,
description: "Architectural and design decisions made by agents",
},
[KBIndexType.REVIEWS]: {
label: "Reviews",
color: "bg-pink-100 text-pink-700 dark:bg-pink-900 dark:text-pink-300",
icon: <ClipboardCheck className="h-3 w-3" />,
description: "Code review feedback from QA and PR reviewers",
},
[KBIndexType.LEARNINGS]: {
label: "Learnings",
color:
"bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300",
icon: <Lightbulb className="h-3 w-3" />,
description: "Cross-agent learnings broadcast as shared knowledge",
},
[KBIndexType.PLAYBOOKS]: {
label: "Playbooks",
color:
"bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300",
icon: <ScrollText className="h-3 w-3" />,
description: "Curated, Auditor-approved reusable procedures",
},
[KBIndexType.VAULT_NOTES]: {
label: "Vault Notes",
color:
"bg-violet-100 text-violet-700 dark:bg-violet-900 dark:text-violet-300",
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];
return (
<Badge variant="secondary" className={`${config.color} ${className ?? ""}`}>
{showIcon && <span className="mr-1">{config.icon}</span>}
{config.label}
</Badge>
<HelpTip label={config.description}>
<Badge
variant="secondary"
className={`${config.color} ${className ?? ""}`}
>
{showIcon && <span className="mr-1">{config.icon}</span>}
{config.label}
</Badge>
</HelpTip>
);
}
@@ -104,3 +125,7 @@ export function getIndexTypeIcon(indexType: KBIndexType) {
export function getIndexTypeLabel(indexType: KBIndexType) {
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 { KBSearchResult } from "@/types";
import { KBIndexTypeBadge } from "./kb-index-type-badge";
import { HelpTip } from "@/components/ui/help-tip";
import { ExternalLink, FileCode, Hash } from "lucide-react";
interface KBResultCardProps {
@@ -47,19 +48,23 @@ export function KBResultCard({ result, onClick }: KBResultCardProps) {
{/* Header */}
<div className="flex items-center gap-2 flex-wrap mb-2">
<KBIndexTypeBadge indexType={result.index_type} />
<span className="text-xs text-muted-foreground flex items-center gap-1">
<Hash className="h-3 w-3" />
{scorePercent}% match
</span>
<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" />
{scorePercent}% match
</span>
</HelpTip>
</div>
{/* Source */}
<div className="flex items-center gap-1 text-sm text-muted-foreground mb-2">
<FileCode className="h-3 w-3 shrink-0" />
<span className="truncate font-mono text-xs">
{formatSource(result.source)}
</span>
</div>
<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" />
<span className="truncate font-mono text-xs">
{formatSource(result.source)}
</span>
</div>
</HelpTip>
{/* Content snippet */}
<p className="text-sm text-foreground/90 whitespace-pre-wrap line-clamp-4">
@@ -89,7 +94,9 @@ export function KBResultCard({ result, onClick }: KBResultCardProps) {
</div>
{onClick && (
<ExternalLink className="h-4 w-4 text-muted-foreground shrink-0" />
<HelpTip label="Open full document">
<ExternalLink className="h-4 w-4 text-muted-foreground shrink-0" />
</HelpTip>
)}
</div>
</CardContent>
@@ -79,12 +79,26 @@ export function KBSearchBar({
)}
</div>
{onSearch && (
<Button
onClick={onSearch}
disabled={!localValue || localValue.length < 3 || isLoading}
<HelpTip
label={
isLoading
? "Searching…"
: !localValue || localValue.length < 3
? "Enter at least 3 characters to search"
: null
}
>
{isLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : "Search"}
</Button>
<Button
onClick={onSearch}
disabled={!localValue || localValue.length < 3 || isLoading}
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
"Search"
)}
</Button>
</HelpTip>
)}
</div>
);
@@ -2,6 +2,8 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
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 {
Database,
@@ -111,10 +113,12 @@ export function KBStatsCard({ stats, isLoading }: KBStatsCardProps) {
key={idx.index_type}
className="flex items-center justify-between text-sm"
>
<div className="flex items-center gap-2">
{indexIcons[idx.index_type]}
<span>{indexLabels[idx.index_type]}</span>
</div>
<HelpTip label={getIndexTypeDescription(idx.index_type)}>
<div className="flex items-center gap-2 w-fit">
{indexIcons[idx.index_type]}
<span>{indexLabels[idx.index_type]}</span>
</div>
</HelpTip>
<div className="text-right">
<span className="font-medium">
{idx.document_count.toLocaleString()}
@@ -131,7 +135,9 @@ export function KBStatsCard({ stats, isLoading }: KBStatsCardProps) {
</span>
</div>
<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>
</div>
</div>
@@ -63,6 +63,7 @@ import { RAGAnswerDisplay } from "./rag-answer-display";
import { MentorChat } from "./mentor-chat";
import { KBCategoryNav } from "./kb-category-nav";
import { KBCategoryView } from "./kb-category-view";
import { getIndexTypeDescription } from "./kb-index-type-badge";
const TAB_VALUES = ["search", "ask", "mentor", "browse", "admin"] as const;
type TabValue = (typeof TAB_VALUES)[number];
@@ -541,10 +542,14 @@ function KnowledgeBaseBrowserContent() {
<p className="text-2xl font-bold">{totalDocs}</p>
<p className="text-xs text-muted-foreground">Documents</p>
</div>
<div>
<p className="text-2xl font-bold">{totalChunks}</p>
<p className="text-xs text-muted-foreground">Chunks</p>
</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-xs text-muted-foreground">
Chunks
</p>
</div>
</HelpTip>
</div>
</CardContent>
</Card>
@@ -589,9 +594,16 @@ function KnowledgeBaseBrowserContent() {
<span className="font-medium">
{INDEX_LABELS[indexType]}
</span>
<Badge variant="outline" className="text-xs">
{indexType}
</Badge>
<HelpTip
label={getIndexTypeDescription(indexType)}
>
<Badge
variant="outline"
className="text-xs w-fit"
>
{indexType}
</Badge>
</HelpTip>
</div>
<div className="flex items-center gap-2">
<HelpTip label="Refresh this index">
@@ -602,6 +614,7 @@ function KnowledgeBaseBrowserContent() {
handleRefreshIndex(indexType)
}
disabled={refreshIndex.isPending}
aria-label={`Refresh ${INDEX_LABELS[indexType]} index`}
>
{refreshIndex.isPending ? (
<RefreshCw className="h-3 w-3 animate-spin" />
@@ -617,6 +630,7 @@ function KnowledgeBaseBrowserContent() {
size="sm"
variant="outline"
className="text-red-600"
aria-label={`Delete ${INDEX_LABELS[indexType]} index`}
>
<Trash2 className="h-3 w-3" />
</Button>
@@ -661,14 +675,16 @@ function KnowledgeBaseBrowserContent() {
{index.document_count}
</span>
</div>
<div>
<span className="text-muted-foreground">
Chunks:
</span>{" "}
<span className="font-medium">
{index.chunk_count}
</span>
</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">
Chunks:
</span>{" "}
<span className="font-medium">
{index.chunk_count}
</span>
</div>
</HelpTip>
<div>
<span className="text-muted-foreground">
Updated:
@@ -3,6 +3,7 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { HelpTip } from "@/components/ui/help-tip";
import { MentorAskResponse } from "@/types";
import { RAGCitationCard } from "./rag-citation-card";
import { Markdown } from "@/components/ui/markdown";
@@ -232,13 +233,21 @@ export function MentorAnswerDisplay({
<div className="flex flex-wrap gap-1.5">
{Object.entries(response.search_stats).map(
([indexType, count]) => (
<Badge
<HelpTip
key={indexType}
variant={count > 0 ? "secondary" : "outline"}
className={`text-xs ${count === -1 ? "text-red-500" : ""}`}
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.`
}
>
{indexType}: {count === -1 ? "error" : count}
</Badge>
<Badge
variant={count > 0 ? "secondary" : "outline"}
className={`text-xs ${count === -1 ? "text-red-500" : ""}`}
>
{indexType}: {count === -1 ? "error" : count}
</Badge>
</HelpTip>
),
)}
</div>
@@ -156,6 +156,7 @@ export function MentorChat({ onAsk, isLoading }: MentorChatProps) {
className="absolute bottom-2 right-2"
onClick={() => handleSubmit()}
disabled={!input.trim() || isLoading}
aria-label="Send to your mentor"
>
<Send className="h-4 w-4" />
</Button>
@@ -327,6 +328,7 @@ export function MentorChat({ onAsk, isLoading }: MentorChatProps) {
className="absolute bottom-2 right-2"
onClick={() => handleSubmit()}
disabled={!input.trim() || isLoading}
aria-label="Send to your mentor"
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
@@ -92,6 +92,7 @@ export function MentorQueryInput({
className="absolute bottom-2 right-2"
onClick={handleSubmit}
disabled={!question.trim() || isLoading}
aria-label="Ask the mentor"
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
@@ -3,6 +3,7 @@
import { Card, CardContent } from "@/components/ui/card";
import { RAGCitation } from "@/types";
import { KBIndexTypeBadge } from "./kb-index-type-badge";
import { HelpTip } from "@/components/ui/help-tip";
import { Quote, Hash } from "lucide-react";
interface RAGCitationCardProps {
@@ -43,14 +44,18 @@ export function RAGCitationCard({ citation, index }: RAGCitationCardProps) {
indexType={citation.index_type}
className="text-xs"
/>
<span className="text-xs text-muted-foreground flex items-center gap-0.5">
<Hash className="h-3 w-3" />
{scorePercent}%
</span>
<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">
<Hash className="h-3 w-3" />
{scorePercent}%
</span>
</HelpTip>
</div>
<p className="text-xs text-muted-foreground font-mono truncate mb-1">
{formatSource(citation.source)}
</p>
<HelpTip label={citation.source}>
<p className="text-xs text-muted-foreground font-mono truncate mb-1">
{formatSource(citation.source)}
</p>
</HelpTip>
<div className="flex items-start gap-1">
<Quote className="h-3 w-3 text-muted-foreground shrink-0 mt-0.5" />
<p className="text-sm text-foreground/80 line-clamp-3">
@@ -58,6 +58,7 @@ export function RAGQueryInput({
className="absolute bottom-2 right-2"
onClick={handleSubmit}
disabled={!question.trim() || isLoading}
aria-label="Ask the knowledge base"
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
@@ -117,6 +117,16 @@ describe("NotificationBell — read/ack integration (W9-1)", () => {
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", () => {
useNotifications.mockReturnValue({
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();
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/ });
await user.click(markReadBtn);
await waitFor(() => expect(markRead).toHaveBeenCalledWith("notif-1"));
@@ -163,7 +174,7 @@ describe("NotificationBell — read/ack integration (W9-1)", () => {
});
const user = userEvent.setup();
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/ });
await user.click(ackBtn);
await waitFor(() => expect(ack).toHaveBeenCalledWith("notif-2"));
@@ -182,7 +193,7 @@ describe("NotificationBell — read/ack integration (W9-1)", () => {
});
const user = userEvent.setup();
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/ });
await user.click(allBtn);
await waitFor(() => expect(markAllRead).toHaveBeenCalled());
@@ -23,6 +23,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { HelpTip } from "@/components/ui/help-tip";
import { Bell, Wifi, WifiOff, CheckCheck, MailOpen, Check } from "lucide-react";
const BELL_LABEL = "View notifications";
@@ -39,6 +40,12 @@ export function NotificationBell() {
const unreadCount = data?.unread_count ?? 0;
const pendingAckCount = data?.pending_ack_count ?? 0;
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) => {
void markRead.mutateAsync(id);
@@ -68,8 +75,8 @@ export function NotificationBell() {
variant="ghost"
size="icon"
className="relative"
aria-label={BELL_LABEL}
title={BELL_LABEL}
aria-label={bellLabel}
title={bellLabel}
>
<Bell className="h-5 w-5" />
{unreadCount > 0 && (
@@ -80,7 +87,7 @@ export function NotificationBell() {
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>{BELL_LABEL}</TooltipContent>
<TooltipContent>{bellLabel}</TooltipContent>
</Tooltip>
</TooltipProvider>
<PopoverContent className="w-80" align="end">
@@ -89,9 +96,13 @@ export function NotificationBell() {
<div className="flex items-center gap-2">
<h4 className="font-semibold">Notifications</h4>
{isConnected ? (
<Wifi className="h-4 w-4 text-green-500" aria-label="connected" />
<HelpTip label="Live update stream connected">
<Wifi className="h-4 w-4 text-green-500" aria-label="connected" />
</HelpTip>
) : (
<WifiOff className="h-4 w-4 text-gray-400" aria-label="disconnected" />
<HelpTip label="Live update stream disconnected — list may be stale">
<WifiOff className="h-4 w-4 text-gray-400" aria-label="disconnected" />
</HelpTip>
)}
</div>
{unreadCount > 0 && (
+52 -39
View File
@@ -21,6 +21,7 @@ import { Skeleton } from "@/components/ui/skeleton";
import { Boxes, Pencil } from "lucide-react";
import type { ProductSummary, Team } from "@/types";
import { EditProductDialog } from "./edit-product-dialog";
import { HelpTip } from "@/components/ui/help-tip";
const TEAM_LABELS: Record<Team, string> = {
board: "Board",
@@ -41,21 +42,23 @@ function CellsList({ cells }: { cells: ProductSummary["cells"] }) {
return <span className="text-muted-foreground text-sm">Unmapped</span>;
}
return (
<div className="flex flex-col gap-1">
{cells.map((c) => (
<div key={`${c.team}-${c.project_id}`} className="flex items-center gap-2">
<Badge
variant="outline"
className="bg-blue-500/10 text-blue-500 text-xs"
>
{TEAM_LABELS[c.team] ?? c.team}
</Badge>
<span className="text-muted-foreground text-xs truncate">
{c.project_name || "—"}
</span>
</div>
))}
</div>
<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">
{cells.map((c) => (
<div key={`${c.team}-${c.project_id}`} className="flex items-center gap-2">
<Badge
variant="outline"
className="bg-blue-500/10 text-blue-500 text-xs"
>
{TEAM_LABELS[c.team] ?? c.team}
</Badge>
<span className="text-muted-foreground text-xs truncate">
{c.project_name || "—"}
</span>
</div>
))}
</div>
</HelpTip>
);
}
@@ -66,15 +69,21 @@ function ProgressCell({
}) {
const { done, active, blocked } = progress;
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 (
<div className="flex items-center gap-2">
<span
className={
"h-2 w-2 rounded-full " +
(atRisk ? "bg-amber-500" : done > 0 ? "bg-emerald-500" : "bg-muted")
}
title={atRisk ? "At risk: blocked tasks" : "Healthy"}
/>
<HelpTip label={dotHint}>
<span
className={
"h-2 w-2 rounded-full inline-block " +
(atRisk ? "bg-amber-500" : done > 0 ? "bg-emerald-500" : "bg-muted")
}
/>
</HelpTip>
<div className="flex items-center gap-2 text-xs">
<span className="text-emerald-600 dark:text-emerald-400">{done} done</span>
<span className="text-muted-foreground">{active} active</span>
@@ -148,14 +157,16 @@ export function ProductTable({ products, isLoading }: ProductTableProps) {
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => setEditingProductId(product.id)}
title="Edit product"
>
<Pencil className="h-4 w-4" />
</Button>
<HelpTip label="Edit product name, description, and cell-project mapping">
<Button
variant="ghost"
size="icon"
onClick={() => setEditingProductId(product.id)}
aria-label="Edit product"
>
<Pencil className="h-4 w-4" />
</Button>
</HelpTip>
</div>
</TableCell>
</TableRow>
@@ -181,15 +192,17 @@ export function ProductTable({ products, isLoading }: ProductTableProps) {
{product.slug}
</p>
</div>
<Button
variant="ghost"
size="icon"
className="shrink-0"
onClick={() => setEditingProductId(product.id)}
title="Edit product"
>
<Pencil className="h-4 w-4" />
</Button>
<HelpTip label="Edit product name, description, and cell-project mapping">
<Button
variant="ghost"
size="icon"
className="shrink-0"
onClick={() => setEditingProductId(product.id)}
aria-label="Edit product"
>
<Pencil className="h-4 w-4" />
</Button>
</HelpTip>
</div>
<div className="mt-3 divide-y">
<ResponsiveTableCardRow label="Cells">
@@ -26,6 +26,7 @@ import { toast } from "sonner";
import { Team, type ProjectCreate } from "@/types";
import { EnvironmentLadderEditor } from "@/components/projects/environment-ladder-editor";
import { validateLadder } from "@/components/projects/ladder-validation";
import { HelpTip } from "@/components/ui/help-tip";
const cells: { value: Team; label: string }[] = [
{ value: Team.BACKEND, label: "Backend" },
@@ -182,7 +183,9 @@ export function CreateProjectDialog() {
{/* Git Token */}
<div className="grid gap-2">
<Label htmlFor="git_token" className="flex items-center gap-1">
<Key className="h-3.5 w-3.5" />
<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" />
</HelpTip>
GitHub Token
</Label>
<Input
@@ -29,6 +29,7 @@ import { toast } from "sonner";
import { Team, type ProjectUpdate, type Project } from "@/types";
import { EnvironmentLadderEditor } from "@/components/projects/environment-ladder-editor";
import { validateLadder } from "@/components/projects/ladder-validation";
import { HelpTip } from "@/components/ui/help-tip";
const cells: { value: Team; label: string }[] = [
{ value: Team.BACKEND, label: "Backend" },
@@ -46,21 +47,65 @@ const SANDBOX_SERVICES = [
// (roboco/models/sandbox.py SANDBOX_ENGINE_FEATURES). The allowlist is the
// security containment — a plpython3u (superuser-RCE) is absent by design.
// 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: [
{ id: "vector", label: "pgvector" },
{ id: "postgis", label: "PostGIS" },
{ id: "pg_trgm", label: "pg_trgm" },
{ id: "citext", label: "citext" },
{ id: "uuid-ossp", label: "uuid-ossp" },
{
id: "vector",
label: "pgvector",
hint: "Vector similarity search/indexing for embeddings.",
},
{
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: [
{ id: "search", label: "RediSearch" },
{ id: "json", label: "RedisJSON" },
{ id: "bloom", label: "RedisBloom" },
{
id: "search",
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 {
projectId: string;
open: boolean;
@@ -260,31 +305,35 @@ function EditProjectForm({
{/* Git Token Section */}
<div className="grid gap-2 p-3 border rounded-lg bg-muted/30">
<div className="flex items-center justify-between">
<Label className="flex items-center gap-2">
{project.has_git_token ? (
<>
<Key className="h-4 w-4 text-green-500" />
<span className="text-green-600 dark:text-green-400">
Token is set
</span>
</>
) : (
<>
<KeyRound className="h-4 w-4 text-amber-500" />
<span className="text-amber-600 dark:text-amber-400">
No token configured
</span>
</>
)}
</Label>
<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">
{project.has_git_token ? (
<>
<Key className="h-4 w-4 text-green-500" />
<span className="text-green-600 dark:text-green-400">
Token is set
</span>
</>
) : (
<>
<KeyRound className="h-4 w-4 text-amber-500" />
<span className="text-amber-600 dark:text-amber-400">
No token configured
</span>
</>
)}
</Label>
</HelpTip>
{project.has_git_token && (
<div className="flex items-center gap-2">
<Label
htmlFor="clear-token"
className="text-xs text-muted-foreground"
>
Clear token
</Label>
<HelpTip label="Clears the stored token when you save. Leave off to keep the current token, or enter a replacement below.">
<Label
htmlFor="clear-token"
className="text-xs text-muted-foreground"
>
Clear token
</Label>
</HelpTip>
<Switch
id="clear-token"
checked={clearToken}
@@ -359,7 +408,9 @@ function EditProjectForm({
{/* Active Status */}
<div className="flex items-center justify-between">
<Label htmlFor="is_active">Active</Label>
<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>
</HelpTip>
<Switch
id="is_active"
checked={isActive}
@@ -458,9 +509,11 @@ function EditProjectForm({
{showAutonomy && (
<>
<div className="flex items-center justify-between">
<Label htmlFor="ci_watch_enabled">
CI-watch (open a fix task when CI goes red)
</Label>
<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">
CI-watch (open a fix task when CI goes red)
</Label>
</HelpTip>
<Switch
id="ci_watch_enabled"
checked={ciWatchEnabled}
@@ -483,9 +536,11 @@ function EditProjectForm({
</div>
<div className="flex items-center justify-between">
<Label htmlFor="video_engine_enabled">
Video engine (author marketing videos into this project)
</Label>
<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">
Video engine (author marketing videos into this project)
</Label>
</HelpTip>
<Switch
id="video_engine_enabled"
checked={videoEngineEnabled}
@@ -526,15 +581,19 @@ function EditProjectForm({
</div>
<div className="grid gap-2">
<Label>Sandbox Services</Label>
<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>
</HelpTip>
{SANDBOX_SERVICES.map((svc) => (
<div key={svc.id} className="flex items-center justify-between">
<Label
htmlFor={`sandbox_${svc.id}`}
className="text-sm font-normal"
>
{svc.label}
</Label>
<HelpTip label={SANDBOX_SERVICE_HINTS[svc.id]}>
<Label
htmlFor={`sandbox_${svc.id}`}
className="text-sm font-normal"
>
{svc.label}
</Label>
</HelpTip>
<Switch
id={`sandbox_${svc.id}`}
checked={sandboxSet.has(svc.id)}
@@ -565,12 +624,14 @@ function EditProjectForm({
key={ext.id}
className="flex items-center justify-between"
>
<Label
htmlFor={`ext_${svc.id}_${ext.id}`}
className="text-sm font-normal"
>
{ext.label}
</Label>
<HelpTip label={ext.hint}>
<Label
htmlFor={`ext_${svc.id}_${ext.id}`}
className="text-sm font-normal"
>
{ext.label}
</Label>
</HelpTip>
<Switch
id={`ext_${svc.id}_${ext.id}`}
checked={sandboxExtensions[svc.id]?.has(ext.id) ?? false}
@@ -80,6 +80,7 @@ export function EnvironmentLadderEditor({
className="h-6 w-6"
disabled={isFirst}
onClick={() => handleMove(index, -1)}
aria-label="Move rung up, toward head"
>
<ArrowUp className="h-3.5 w-3.5" />
</Button>
@@ -95,6 +96,7 @@ export function EnvironmentLadderEditor({
className="h-6 w-6"
disabled={isLast}
onClick={() => handleMove(index, 1)}
aria-label="Move rung down, toward prod"
>
<ArrowDown className="h-3.5 w-3.5" />
</Button>
@@ -125,6 +127,7 @@ export function EnvironmentLadderEditor({
size="icon"
className="h-8 w-8 shrink-0"
onClick={() => handleRemove(index)}
aria-label="Remove this rung"
>
<X className="h-4 w-4" />
</Button>
+104 -96
View File
@@ -21,6 +21,7 @@ import { Skeleton } from "@/components/ui/skeleton";
import { ExternalLink, Pencil, GitBranch, Key, KeyRound, Radar } from "lucide-react";
import type { ProjectSummary, ProjectTaskCounts, Team } from "@/types";
import { EditProjectDialog } from "./edit-project-dialog";
import { HelpTip } from "@/components/ui/help-tip";
interface ProjectTableProps {
projects: ProjectSummary[] | undefined;
@@ -46,20 +47,36 @@ const teamColors: Record<Team, string> = {
};
function getTokenBadge(hasGitToken: boolean) {
if (hasGitToken) {
return (
<Badge className="bg-green-500/10 text-green-500">
<Key className="h-3 w-3 mr-1" />
Token Set
</Badge>
);
}
return (
const badge = hasGitToken ? (
<Badge className="bg-green-500/10 text-green-500">
<Key className="h-3 w-3 mr-1" />
Token Set
</Badge>
) : (
<Badge variant="outline" className="text-amber-500 border-amber-500/30">
<KeyRound className="h-3 w-3 mr-1" />
No Token
</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 }) {
@@ -67,19 +84,25 @@ function TasksCell({ counts }: { counts: ProjectTaskCounts | null }) {
return <span className="text-muted-foreground text-xs"></span>;
}
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 (
<div className="flex items-center gap-2">
<span
className={
"h-2 w-2 rounded-full " +
(atRisk
? "bg-amber-500"
: counts.done > 0
? "bg-emerald-500"
: "bg-muted")
}
title={atRisk ? "At risk: blocked tasks" : "Healthy"}
/>
<HelpTip label={dotHint}>
<span
className={
"h-2 w-2 rounded-full inline-block " +
(atRisk
? "bg-amber-500"
: counts.done > 0
? "bg-emerald-500"
: "bg-muted")
}
/>
</HelpTip>
<div className="flex items-center gap-2 text-xs">
<span className="text-emerald-600 dark:text-emerald-400">
{counts.done} done
@@ -98,14 +121,15 @@ function TasksCell({ counts }: { counts: ProjectTaskCounts | null }) {
function CiWatchBadge({ enabled }: { enabled: boolean }) {
if (!enabled) return null;
return (
<Badge
variant="outline"
className="bg-sky-500/10 text-sky-500 border-sky-500/30"
title="CI-watch armed"
>
<Radar className="h-3 w-3 mr-1" />
CI-Watch
</Badge>
<HelpTip label="Opens a fix task automatically when this project's CI goes red on its default branch.">
<Badge
variant="outline"
className="bg-sky-500/10 text-sky-500 border-sky-500/30"
>
<Radar className="h-3 w-3 mr-1" />
CI-Watch
</Badge>
</HelpTip>
);
}
@@ -197,44 +221,35 @@ export function ProjectTable({ projects, isLoading }: ProjectTableProps) {
<TableCell>
{getTokenBadge(project.has_git_token)}
</TableCell>
<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>{getStatusBadge(project.is_active)}</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => setEditingProjectId(project.id)}
title="Edit project"
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
asChild
title="View repository"
>
<a
href={getExternalUrl(project)}
target="_blank"
rel="noopener noreferrer"
<HelpTip label="Edit project settings and CI/CD commands">
<Button
variant="ghost"
size="icon"
onClick={() => setEditingProjectId(project.id)}
aria-label="Edit project"
>
<ExternalLink className="h-4 w-4" />
</a>
</Button>
<Pencil className="h-4 w-4" />
</Button>
</HelpTip>
<HelpTip label="Open the git repository in a new tab">
<Button
variant="ghost"
size="icon"
asChild
aria-label="View repository"
>
<a
href={getExternalUrl(project)}
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink className="h-4 w-4" />
</a>
</Button>
</HelpTip>
</div>
</TableCell>
</TableRow>
@@ -261,28 +276,32 @@ export function ProjectTable({ projects, isLoading }: ProjectTableProps) {
</p>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => setEditingProjectId(project.id)}
title="Edit project"
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
asChild
title="View repository"
>
<a
href={getExternalUrl(project)}
target="_blank"
rel="noopener noreferrer"
<HelpTip label="Edit project settings and CI/CD commands">
<Button
variant="ghost"
size="icon"
onClick={() => setEditingProjectId(project.id)}
aria-label="Edit project"
>
<ExternalLink className="h-4 w-4" />
</a>
</Button>
<Pencil className="h-4 w-4" />
</Button>
</HelpTip>
<HelpTip label="Open the git repository in a new tab">
<Button
variant="ghost"
size="icon"
asChild
aria-label="View repository"
>
<a
href={getExternalUrl(project)}
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink className="h-4 w-4" />
</a>
</Button>
</HelpTip>
</div>
</div>
<div className="mt-3 divide-y">
@@ -299,18 +318,7 @@ export function ProjectTable({ projects, isLoading }: ProjectTableProps) {
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="Status">
<div className="flex items-center gap-2">
{project.is_active ? (
<Badge className="bg-green-500/10 text-green-500">
Active
</Badge>
) : (
<Badge
variant="outline"
className="text-muted-foreground"
>
Inactive
</Badge>
)}
{getStatusBadge(project.is_active)}
{project.ci_watch_enabled && <CiWatchBadge enabled />}
</div>
</ResponsiveTableCardRow>
@@ -206,6 +206,27 @@ describe("AIRoutingCard", () => {
).toHaveLength(20);
});
it("tooltip-wraps the Grok/Ollama key labels and status badges, not the raw Switch", async () => {
render(withQueryClient(<AIRoutingCard />));
await screen.findByText("Grok (xAI) API key");
// TooltipTrigger always stamps data-state onto its asChild target, so
// its presence is a reliable proxy for "this element is tooltip-wrapped"
// without simulating hover (Radix only portals content once open).
expect(
screen.getByText("Grok (xAI) API key").getAttribute("data-state"),
).toBe("closed");
expect(
screen.getByText("Ollama Cloud API key").getAttribute("data-state"),
).toBe("closed");
const notSetBadges = screen.getAllByText("not set");
expect(notSetBadges).toHaveLength(2);
for (const badge of notSetBadges) {
expect(badge.getAttribute("data-state")).toBe("closed");
}
});
it("saving the mix with no picks shows an error and never calls applyMode", async () => {
render(withQueryClient(<AIRoutingCard />));
await screen.findByText("Per-agent override (mix mode)");
@@ -127,4 +127,31 @@ describe("FeatureFlagsCard — M42 off-transition confirm + pending-keys Set", (
resolveQueue.current.shift()?.(undefined);
await waitFor(() => expect(beta).not.toBeDisabled());
});
// W9-5 follow-up: every real flag key gets a one-line hover tip on its
// label (FLAG_TOOLTIPS in feature-flags-card.tsx); an unmapped key (the
// "alpha"/"beta" fixtures above) renders bare per HelpTip's falsy short-
// circuit. TooltipTrigger always stamps data-state ("closed" while
// unopened) onto its asChild target, so its presence/absence is a reliable
// proxy for "is this label tooltip-wrapped" without simulating hover.
it("attaches the mapped tooltip to a real flag key and leaves unmapped keys bare", async () => {
getFeatureFlags.mockResolvedValueOnce({
flags: [
{ key: "alpha", label: "Alpha", enabled: true },
{
key: "external_pr_enabled",
label: "External PR Review",
enabled: true,
},
],
note: "Changes take effect on the next backend restart.",
});
render(withQueryClient(<FeatureFlagsCard />));
const mapped = await screen.findByText("External PR Review");
expect(mapped.getAttribute("data-state")).toBe("closed");
const unmapped = screen.getByText("Alpha");
expect(unmapped.getAttribute("data-state")).toBeNull();
});
});
@@ -37,6 +37,16 @@ describe("TelegramCredentialsForm", () => {
).toBeInTheDocument();
});
it("tooltip-wraps each field label with the write-only storage note", async () => {
render(withQueryClient(<TelegramCredentialsForm />));
await screen.findByText("No credentials configured");
expect(
screen
.getByText("Bot token (from @BotFather)")
.getAttribute("data-state"),
).toBe("closed");
});
it("disables Save until both fields are filled", async () => {
render(withQueryClient(<TelegramCredentialsForm />));
await screen.findByText("No credentials configured");
@@ -37,6 +37,14 @@ describe("TikTokCredentialsForm", () => {
).toBeInTheDocument();
});
it("tooltip-wraps each field label with the write-only storage note", async () => {
render(withQueryClient(<TikTokCredentialsForm />));
await screen.findByText("No credentials configured");
expect(screen.getByText("Client key").getAttribute("data-state")).toBe(
"closed",
);
});
it("disables Save until all 4 fields are filled", async () => {
render(withQueryClient(<TikTokCredentialsForm />));
await screen.findByText("No credentials configured");
@@ -37,6 +37,15 @@ describe("XCredentialsForm", () => {
).toBeInTheDocument();
});
it("tooltip-wraps each field label with the write-only storage note", async () => {
render(withQueryClient(<XCredentialsForm />));
await screen.findByText("No credentials configured");
// TooltipTrigger always stamps data-state onto its asChild target.
expect(screen.getByText("API key").getAttribute("data-state")).toBe(
"closed",
);
});
it("disables Save until all 4 fields are filled", async () => {
render(withQueryClient(<XCredentialsForm />));
await screen.findByText("No credentials configured");
@@ -48,6 +48,7 @@ import type { RoutingMode, SelfHostedTestResult } from "@/lib/api/providers";
import { SelfHostedSection } from "@/components/settings/self-hosted-section";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { HelpTip } from "@/components/ui/help-tip";
// Matches the roboco agents_config AGENT_ROLE_MAP / AGENT_TEAM_MAP.
// Hard-coded so Mix mode shows a stable 18-row picker without an extra
@@ -353,17 +354,23 @@ export function AIRoutingCard() {
{/* -------- Grok (xAI) key -------- */}
<section className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">
Grok (xAI) API key
</Label>
<HelpTip label="Stored encrypted server-side; never displayed once saved.">
<Label className="text-sm font-medium">
Grok (xAI) API key
</Label>
</HelpTip>
{hasGrokKey ? (
<Badge className="bg-emerald-500/10 text-emerald-600 border-0">
<KeyRound className="h-3 w-3" /> key set
</Badge>
<HelpTip label="Enables the Grok mode button and any Grok row in Mix mode below.">
<Badge className="bg-emerald-500/10 text-emerald-600 border-0">
<KeyRound className="h-3 w-3" /> key set
</Badge>
</HelpTip>
) : (
<Badge className="bg-amber-500/10 text-amber-600 border-0">
<Key className="h-3 w-3" /> not set
</Badge>
<HelpTip label="Required before any agent can route to a Grok model.">
<Badge className="bg-amber-500/10 text-amber-600 border-0">
<Key className="h-3 w-3" /> not set
</Badge>
</HelpTip>
)}
</div>
<div className="flex gap-2">
@@ -405,17 +412,23 @@ export function AIRoutingCard() {
{/* -------- Ollama key -------- */}
<section className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">
Ollama Cloud API key
</Label>
<HelpTip label="Stored encrypted server-side; never displayed once saved.">
<Label className="text-sm font-medium">
Ollama Cloud API key
</Label>
</HelpTip>
{hasOllamaKey ? (
<Badge className="bg-emerald-500/10 text-emerald-600 border-0">
<KeyRound className="h-3 w-3" /> key set
</Badge>
<HelpTip label="Enables the Ollama mode button and any Ollama row in Mix mode below.">
<Badge className="bg-emerald-500/10 text-emerald-600 border-0">
<KeyRound className="h-3 w-3" /> key set
</Badge>
</HelpTip>
) : (
<Badge className="bg-amber-500/10 text-amber-600 border-0">
<Key className="h-3 w-3" /> not set
</Badge>
<HelpTip label="Required before any agent can route to an Ollama Cloud model.">
<Badge className="bg-amber-500/10 text-amber-600 border-0">
<Key className="h-3 w-3" /> not set
</Badge>
</HelpTip>
)}
</div>
<div className="flex gap-2">
@@ -467,7 +480,9 @@ export function AIRoutingCard() {
{/* -------- Mode toggle -------- */}
<section className="space-y-3">
<Label className="text-sm font-medium">Routing mode</Label>
<HelpTip label="Anthropic / Grok / Ollama / Self-Hosted route every agent to one provider and clear all per-agent overrides below. Mix keeps whatever's picked in the table.">
<Label className="text-sm font-medium">Routing mode</Label>
</HelpTip>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-2">
<ModeButton
icon={<ShieldCheck className="h-4 w-4" />}
@@ -584,9 +599,11 @@ export function AIRoutingCard() {
<Separator />
<section className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">
Per-agent override (mix mode)
</Label>
<HelpTip label="A blank row falls back to that agent's role default, then the last global mode's model — not a separate 'mix default'.">
<Label className="text-sm font-medium">
Per-agent override (mix mode)
</Label>
</HelpTip>
<Button size="sm" onClick={saveMix} disabled={applyMode.isPending}>
{applyMode.isPending ? "Saving…" : "Save mix"}
</Button>
@@ -32,6 +32,7 @@ import {
import { XCredentialsForm } from "@/components/settings/x-credentials-card";
import { TikTokCredentialsForm } from "@/components/settings/tiktok-credentials-card";
import { TelegramCredentialsForm } from "@/components/settings/telegram-credentials-card";
import { HelpTip } from "@/components/ui/help-tip";
import { cn } from "@/lib/utils";
import { Flag, ChevronDown, ChevronRight } from "lucide-react";
import { toast } from "sonner";
@@ -98,6 +99,69 @@ const FLAG_DESCRIPTIONS: Record<string, string> = {
"Best-effort Telegram DMs to you alongside in-app notifications when a task is escalated for your approval or completes. Server-side fan-out — never blocks the in-app notification. Stays inert until you set bot-token + chat-id credentials in the Telegram card below.",
};
// Short hover tips for the label of each flag row — a terser companion to
// FLAG_DESCRIPTIONS' always-visible paragraph above. Keyed by settings-key,
// not label text, since label text alone is sometimes ambiguous.
const FLAG_TOOLTIPS: Record<string, string> = {
external_pr_enabled:
"Reviews inbound external/fork pull requests before merge.",
internal_pr_enabled: "Safety-reviews internal PRs before merge.",
research_enabled: "Lets Board and PM agents research the web for planning.",
strategy_engine_enabled: "Runs the background company strategy engine.",
self_heal_enabled:
"Watches RoboCo's own CI and flags regressions; never auto-fixes.",
self_heal_originate_enabled:
"On a CI regression, opens a fix task held for CEO approval.",
provisioning_enabled: "Auto-provisions infra for approved Board pitches.",
toolchain_match_enabled:
"Matches an agent's runtime toolchain to its project.",
conventions_enabled: "Enforces each project's architectural placement rules.",
possibilities_matrix_enabled:
"Fast-paths work that's already been done elsewhere.",
rag_auto_update_enabled:
"Keeps the RAG knowledge index automatically refreshed.",
transcript_prune_enabled:
"Prunes old agent transcripts per the retention setting.",
gateway_health_enabled:
"Recycles an agent whose tool gateway is broken but alive.",
ci_watch_enabled:
"Watches opted-in projects' CI and opens a fix task on red builds.",
dep_update_enabled:
"Weekly checks for dependency upgrades and opens a task if one applies.",
env_sync_enabled: "Cascades prod branch changes down to dev branches.",
docs_sync_enabled:
"Opens a docs-update task when a release drifts from the docs.",
release_manager_enabled:
"Assembles a release proposal for CEO approval; never auto-publishes.",
org_memory_enabled:
"Captures task learnings and re-injects them into future briefings.",
sandbox_db_enabled:
"Gives agents on-demand disposable DB/Redis sandboxes for testing.",
routing_strict:
"Fails closed instead of silently falling back on a disabled provider.",
x_engine_enabled: "Drafts X posts for CEO review; nothing auto-posts.",
x_replies_enabled: "Drafts replies to X mentions (needs a paid X API tier).",
x_feature_spotlight_enabled:
"Periodically drafts a spotlight post for an under-publicized feature.",
video_engine_enabled:
"Authors and renders motion-graphics videos for social posts.",
video_on_release: "Drafts a video whenever a release publishes.",
video_on_spotlight:
"Drafts a video whenever a feature spotlight is drafted.",
roadmap_engine_enabled:
"Weekly has the Board draft a themed roadmap for CEO approval.",
fable_mode_enabled: "Adopts the Fable/Ponytail behavioral doctrine fleet-wide.",
obsidian_vault_enabled:
"Projects tasks/journals/A2A into a human-readable Obsidian vault.",
vault_intake_enabled:
"Turns #roboco-tagged vault notes into board-review drafts.",
vault_report_enabled:
"Writes a weekly org metrics report note into the vault.",
vault_kb_enabled:
"Embeds the CEO's own vault notes into RAG for agent retrieval.",
telegram_enabled: "Sends CEO notification DMs over Telegram.",
};
export function FeatureFlagsCard() {
const queryClient = useQueryClient();
const [xCredsOpen, setXCredsOpen] = useState(false);
@@ -185,7 +249,14 @@ export function FeatureFlagsCard() {
>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<Label htmlFor={`flag-${flag.key}`}>{flag.label}</Label>
{/* HelpTip wraps the Label, not the Switch: Switch is a
Radix stateful trigger whose internal render spreads
props after its own literal data-state, so a
TooltipTrigger asChild wrapping it would clobber the
on/off data-state the Switch's own CSS depends on. */}
<HelpTip label={FLAG_TOOLTIPS[flag.key]}>
<Label htmlFor={`flag-${flag.key}`}>{flag.label}</Label>
</HelpTip>
<p className="text-sm text-muted-foreground">
{FLAG_DESCRIPTIONS[flag.key] ?? ""}
</p>
@@ -160,7 +160,9 @@ export function SelfHostedSection({
{/* Base URL input */}
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Base URL</Label>
<HelpTip label="Any OpenAI-compatible endpoint — e.g. Ollama, vLLM, LM Studio.">
<Label className="text-xs text-muted-foreground">Base URL</Label>
</HelpTip>
<div className="flex gap-2">
<Input
type="text"
@@ -178,10 +180,12 @@ export function SelfHostedSection({
{/* Auth token input with Eye toggle */}
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">
Auth token{" "}
<span className="text-muted-foreground/60">(optional)</span>
</Label>
<HelpTip label="Stored encrypted server-side; never displayed once saved.">
<Label className="text-xs text-muted-foreground">
Auth token{" "}
<span className="text-muted-foreground/60">(optional)</span>
</Label>
</HelpTip>
<div className="flex gap-2">
<div className="relative flex-1">
<Input
@@ -320,7 +324,17 @@ export function SelfHostedSection({
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">
Last refreshed:{" "}
<span className="font-medium">{relativeTime(lastRefreshed)}</span>
<HelpTip
label={
lastRefreshed
? new Date(lastRefreshed).toLocaleString()
: undefined
}
>
<span className="font-medium">
{relativeTime(lastRefreshed)}
</span>
</HelpTip>
</p>
<Button
variant="ghost"
@@ -353,9 +367,11 @@ export function SelfHostedSection({
{m.model_name}
</span>
</div>
<Badge variant="secondary" className="text-xs">
auto-discovered
</Badge>
<HelpTip label="Found by probing the endpoint's model list — not manually added.">
<Badge variant="secondary" className="text-xs">
auto-discovered
</Badge>
</HelpTip>
</div>
))}
</div>
@@ -6,6 +6,7 @@ import { telegramApi } from "@/lib/api";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { HelpTip } from "@/components/ui/help-tip";
import {
AlertDialog,
AlertDialogAction,
@@ -87,9 +88,13 @@ export function TelegramCredentialsForm() {
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{FIELDS.map((field) => (
<div key={field.key} className="space-y-2">
<Label htmlFor={`tg-cred-${field.key}`}>
{status?.has_credentials ? `Replace ${field.label}` : field.label}
</Label>
<HelpTip label="Stored encrypted server-side; never displayed again once saved.">
<Label htmlFor={`tg-cred-${field.key}`}>
{status?.has_credentials
? `Replace ${field.label}`
: field.label}
</Label>
</HelpTip>
<Input
id={`tg-cred-${field.key}`}
type="password"
@@ -6,6 +6,7 @@ import { videoApi } from "@/lib/api";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { HelpTip } from "@/components/ui/help-tip";
import {
AlertDialog,
AlertDialogAction,
@@ -106,9 +107,13 @@ export function TikTokCredentialsForm() {
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{FIELDS.map((field) => (
<div key={field.key} className="space-y-2">
<Label htmlFor={`tiktok-cred-${field.key}`}>
{status?.has_credentials ? `Replace ${field.label}` : field.label}
</Label>
<HelpTip label="Stored encrypted server-side; never displayed again once saved.">
<Label htmlFor={`tiktok-cred-${field.key}`}>
{status?.has_credentials
? `Replace ${field.label}`
: field.label}
</Label>
</HelpTip>
<Input
id={`tiktok-cred-${field.key}`}
type="password"
@@ -13,6 +13,7 @@ import {
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { HelpTip } from "@/components/ui/help-tip";
import { HardDrive, Save } from "lucide-react";
import { toast } from "sonner";
@@ -72,9 +73,11 @@ export function TranscriptRetentionCard() {
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="transcript-retention-days">
Retention window (days)
</Label>
<HelpTip label="Only takes effect while the transcript_prune_enabled feature flag is on.">
<Label htmlFor="transcript-retention-days">
Retention window (days)
</Label>
</HelpTip>
<Input
id="transcript-retention-days"
type="number"
@@ -6,6 +6,7 @@ import { xApi } from "@/lib/api";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { HelpTip } from "@/components/ui/help-tip";
import {
AlertDialog,
AlertDialogAction,
@@ -104,9 +105,13 @@ export function XCredentialsForm() {
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{FIELDS.map((field) => (
<div key={field.key} className="space-y-2">
<Label htmlFor={`x-cred-${field.key}`}>
{status?.has_credentials ? `Replace ${field.label}` : field.label}
</Label>
<HelpTip label="Stored encrypted server-side; never displayed again once saved.">
<Label htmlFor={`x-cred-${field.key}`}>
{status?.has_credentials
? `Replace ${field.label}`
: field.label}
</Label>
</HelpTip>
<Input
id={`x-cred-${field.key}`}
type="password"