mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
feat(panel): tooltip sweep — projects, products, social, KB, business, settings, notifications
All 34 feature flags get verified one-line tips; secret inputs state the write-only contract; KB index types get canonical descriptions; switch tips ride Labels so Radix data-state stays intact. Also fixes the scorecard SectionLabel swallowing props, which made tooltips on it silently inert.
This commit is contained in:
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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'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 — 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
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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 & 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 & 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>
|
||||
|
||||
@@ -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 & 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 & 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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user