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