feat(panel): tooltip sweep — projects, products, social, KB, business, settings, notifications

All 34 feature flags get verified one-line tips; secret inputs state
the write-only contract; KB index types get canonical descriptions;
switch tips ride Labels so Radix data-state stays intact. Also fixes
the scorecard SectionLabel swallowing props, which made tooltips on it
silently inert.
This commit is contained in:
Renn F
2026-07-15 16:33:22 +02:00
parent d1b02e3747
commit c37516f640
44 changed files with 1258 additions and 482 deletions
@@ -19,6 +19,7 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { HelpTip } from "@/components/ui/help-tip";
import {
AtSign,
ChevronDown,
@@ -30,16 +31,31 @@ import {
const HISTORY_LIMIT = 50;
const PLATFORM_LABELS: Record<string, string> = { x: "X", tiktok: "TikTok" };
const VIDEO_HINT =
"Rendered by the video pipeline — from a release, a feature spotlight, or an on-demand request";
type UnifiedRow =
| { kind: "x"; entry: XPostHistoryEntry }
| { kind: "video"; entry: VideoPostHistoryEntry };
function xKindMeta(source: XPostHistoryEntry["source"]) {
if (source === "x_post") return { label: "X post", icon: Rocket };
if (source === "x_post")
return {
label: "X post",
icon: Rocket,
hint: "Drafted automatically when a release publishes",
};
if (source === "x_feature")
return { label: "Feature spotlight", icon: Sparkles };
return { label: "X reply", icon: AtSign };
return {
label: "Feature spotlight",
icon: Sparkles,
hint: "Drafted periodically by the Head of Marketing's feature-spotlight sweep",
};
return {
label: "X reply",
icon: AtSign,
hint: "Drafted automatically in reply to a meaningful mention on X",
};
}
// One unified row: X entries link the posted tweet / show the reject reason;
@@ -48,9 +64,13 @@ function xKindMeta(source: XPostHistoryEntry["source"]) {
function UnifiedHistoryRow({ row }: { row: UnifiedRow }) {
const posted = row.entry.status === "completed";
const statusBadge = posted ? (
<Badge className="bg-green-600 hover:bg-green-600">Posted</Badge>
<HelpTip label="Approved and successfully posted">
<Badge className="bg-green-600 hover:bg-green-600">Posted</Badge>
</HelpTip>
) : (
<Badge variant="destructive">Rejected</Badge>
<HelpTip label="Rejected — this draft was never posted">
<Badge variant="destructive">Rejected</Badge>
</HelpTip>
);
const timestamp = (
<span className="ml-auto text-xs text-muted-foreground">
@@ -63,8 +83,12 @@ function UnifiedHistoryRow({ row }: { row: UnifiedRow }) {
return (
<div className="rounded-lg border p-3 text-sm">
<div className="mb-1 flex flex-wrap items-center gap-2">
<meta.icon className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{meta.label}</span>
<HelpTip label={meta.hint}>
<span className="inline-flex items-center gap-1.5">
<meta.icon className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{meta.label}</span>
</span>
</HelpTip>
{statusBadge}
{timestamp}
</div>
@@ -92,10 +116,16 @@ function UnifiedHistoryRow({ row }: { row: UnifiedRow }) {
return (
<div className="rounded-lg border p-3 text-sm">
<div className="mb-1 flex flex-wrap items-center gap-2">
<Film className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">Video</span>
<HelpTip label={VIDEO_HINT}>
<span className="inline-flex items-center gap-1.5">
<Film className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">Video</span>
</span>
</HelpTip>
{row.entry.occasion && (
<Badge variant="outline">{row.entry.occasion}</Badge>
<HelpTip label="The occasion/event this video was drafted for">
<Badge variant="outline">{row.entry.occasion}</Badge>
</HelpTip>
)}
{statusBadge}
{timestamp}
@@ -8,6 +8,7 @@ import {
derivePipelineStage,
pipelineStageColor,
pipelineStageLabel,
type PipelineStage,
} from "./video-pipeline-utils";
import { RerenderControl } from "@/components/dashboard/video-rerender-control";
import {
@@ -19,8 +20,27 @@ import {
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { HelpTip } from "@/components/ui/help-tip";
import { Film } from "lucide-react";
// Per-stage explanation for the stage chip — lives here (not in
// video-pipeline-utils.ts) since that file is pure derivation logic with its
// own dedicated unit tests.
function stageHint(stage: PipelineStage): string {
switch (stage.kind) {
case "authoring":
return "A developer is building this video's composition";
case "in_review":
return "The authoring PR is in QA / PR / PM review before assembly";
case "awaiting_approval":
return "Rendered and waiting on your approval — open the task to review";
case "rendering":
return "The renderer is producing the 9:16 and 1:1 cuts, retrying on failure";
case "render_failed":
return "The renderer gave up after all retries — re-render to try again";
}
}
// One row: title + occasion + a colored stage chip. The stage chip is
// derived (never fetched) from status + render_status/render_attempts —
// see video-pipeline-utils.ts, unit-tested directly there. Only the
@@ -40,10 +60,16 @@ function PipelineRow({ item }: { item: VideoPipelineItem }) {
<div className="flex flex-wrap items-center gap-2 rounded-lg border p-3 text-sm">
<Film className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="font-medium">{item.title}</span>
{item.occasion && <Badge variant="outline">{item.occasion}</Badge>}
<Badge className={`${pipelineStageColor(stage)} text-white`}>
{pipelineStageLabel(stage)}
</Badge>
{item.occasion && (
<HelpTip label="The occasion/event this video was drafted for">
<Badge variant="outline">{item.occasion}</Badge>
</HelpTip>
)}
<HelpTip label={stageHint(stage)}>
<Badge className={`${pipelineStageColor(stage)} text-white`}>
{pipelineStageLabel(stage)}
</Badge>
</HelpTip>
{stage.kind === "awaiting_approval" && (
<Link
href={`/tasks/${item.task_id}`}
@@ -31,6 +31,7 @@ import {
import { Textarea } from "@/components/ui/textarea";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { HelpTip } from "@/components/ui/help-tip";
import { ProjectSelector } from "@/components/projects/project-selector";
import { useProjects } from "@/hooks/use-projects";
import { RerenderControl } from "@/components/dashboard/video-rerender-control";
@@ -47,7 +48,21 @@ const REQUEST_PLATFORMS = ["x", "tiktok"] as const;
// Only one source reaches this queue today; a function (not a literal)
// mirrors XPostQueue's sourceMeta pattern and costs nothing to extend later.
function sourceMeta() {
return { label: "Video", icon: Film };
return {
label: "Video",
icon: Film,
hint: "Rendered by the video pipeline — from a release, a feature spotlight, or an on-demand request",
};
}
// Explains what Approve does, or why it's disabled — mirrors x-post-queue's
// approveHint. Always non-empty (see that function's comment for why: a
// null/string toggle on HelpTip's label unmounts the wrapped child).
function approveHint(approving: boolean, overLimit: boolean): string {
if (approving) return "Already posting this draft";
if (overLimit)
return "An edited caption is over its platform's character limit — trim to enable";
return "Post this draft to the selected platforms";
}
function describeExecuteResult(result: VideoPostExecuteResult): string {
@@ -201,9 +216,17 @@ function VideoPostRow({
return (
<div className="rounded-lg border p-4 transition-colors hover:bg-muted/50">
<div className="mb-3 flex flex-wrap items-center gap-2">
<meta.icon className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{meta.label}</span>
{post.occasion && <Badge variant="outline">{post.occasion}</Badge>}
<HelpTip label={meta.hint}>
<span className="inline-flex items-center gap-1.5">
<meta.icon className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{meta.label}</span>
</span>
</HelpTip>
{post.occasion && (
<HelpTip label="The occasion/event this video was drafted for">
<Badge variant="outline">{post.occasion}</Badge>
</HelpTip>
)}
{canRerender && (
<div className="ml-auto">
<RerenderControl authoringTaskId={post.source_task_id as string} />
@@ -222,30 +245,40 @@ function VideoPostRow({
<div className="mb-3 space-y-2">
<div className="flex gap-2">
<Button
type="button"
size="sm"
variant={cut === "vertical" ? "default" : "outline"}
disabled={!post.mp4_paths?.vertical}
title={
post.mp4_paths?.vertical ? undefined : "9:16 hasn't rendered yet"
<HelpTip
label={
post.mp4_paths?.vertical
? "Preview the 9:16 cut"
: "9:16 hasn't rendered yet"
}
onClick={() => setCut("vertical")}
>
9:16{!post.mp4_paths?.vertical && " (missing)"}
</Button>
<Button
type="button"
size="sm"
variant={cut === "square" ? "default" : "outline"}
disabled={!post.mp4_paths?.square}
title={
post.mp4_paths?.square ? undefined : "1:1 hasn't rendered yet"
<Button
type="button"
size="sm"
variant={cut === "vertical" ? "default" : "outline"}
disabled={!post.mp4_paths?.vertical}
onClick={() => setCut("vertical")}
>
9:16{!post.mp4_paths?.vertical && " (missing)"}
</Button>
</HelpTip>
<HelpTip
label={
post.mp4_paths?.square
? "Preview the 1:1 cut"
: "1:1 hasn't rendered yet"
}
onClick={() => setCut("square")}
>
1:1{!post.mp4_paths?.square && " (missing)"}
</Button>
<Button
type="button"
size="sm"
variant={cut === "square" ? "default" : "outline"}
disabled={!post.mp4_paths?.square}
onClick={() => setCut("square")}
>
1:1{!post.mp4_paths?.square && " (missing)"}
</Button>
</HelpTip>
</div>
{post.mp4_paths?.[cut] ? (
<video
@@ -272,9 +305,11 @@ function VideoPostRow({
checked={editX}
onCheckedChange={(c) => setEditX(c === true)}
/>
<Label htmlFor={`${post.task_id}-x-edit`} className="text-sm">
Edit X caption
</Label>
<HelpTip label="Uncheck to keep posting the caption already saved on this draft instead of your edit">
<Label htmlFor={`${post.task_id}-x-edit`} className="text-sm">
Edit X caption
</Label>
</HelpTip>
</div>
<Textarea
value={xCaption}
@@ -283,11 +318,13 @@ function VideoPostRow({
rows={2}
className={xOverLimit ? "border-destructive" : undefined}
/>
<p
className={`text-right text-xs ${xOverLimit ? "text-destructive" : "text-muted-foreground"}`}
>
{xCaption.length}/{MAX_X_CAPTION_CHARS}
</p>
<HelpTip label={`X's per-post character limit (${MAX_X_CAPTION_CHARS})`}>
<p
className={`text-right text-xs ${xOverLimit ? "text-destructive" : "text-muted-foreground"}`}
>
{xCaption.length}/{MAX_X_CAPTION_CHARS}
</p>
</HelpTip>
</div>
)}
@@ -299,12 +336,14 @@ function VideoPostRow({
checked={editTiktok}
onCheckedChange={(c) => setEditTiktok(c === true)}
/>
<Label
htmlFor={`${post.task_id}-tiktok-edit`}
className="text-sm"
>
Edit TikTok caption
</Label>
<HelpTip label="Uncheck to keep posting the caption already saved on this draft instead of your edit">
<Label
htmlFor={`${post.task_id}-tiktok-edit`}
className="text-sm"
>
Edit TikTok caption
</Label>
</HelpTip>
</div>
<Textarea
value={tiktokCaption}
@@ -313,11 +352,15 @@ function VideoPostRow({
rows={2}
className={tiktokOverLimit ? "border-destructive" : undefined}
/>
<p
className={`text-right text-xs ${tiktokOverLimit ? "text-destructive" : "text-muted-foreground"}`}
<HelpTip
label={`TikTok's caption character limit (${MAX_TIKTOK_CAPTION_CHARS})`}
>
{tiktokCaption.length}/{MAX_TIKTOK_CAPTION_CHARS}
</p>
<p
className={`text-right text-xs ${tiktokOverLimit ? "text-destructive" : "text-muted-foreground"}`}
>
{tiktokCaption.length}/{MAX_TIKTOK_CAPTION_CHARS}
</p>
</HelpTip>
</div>
)}
</div>
@@ -332,15 +375,17 @@ function VideoPostRow({
<XCircle className="mr-1 h-4 w-4" />
Reject
</Button>
<Button
size="sm"
className="bg-green-600 hover:bg-green-700"
disabled={approving || overLimit}
onClick={handleApprove}
>
<CheckCircle2 className="mr-1 h-4 w-4" />
Approve &amp; post
</Button>
<HelpTip label={approveHint(approving, overLimit)}>
<Button
size="sm"
className="bg-green-600 hover:bg-green-700"
disabled={approving || overLimit}
onClick={handleApprove}
>
<CheckCircle2 className="mr-1 h-4 w-4" />
Approve &amp; post
</Button>
</HelpTip>
</div>
</div>
);
@@ -408,6 +453,21 @@ function RequestVideoDialog({
brief.trim().length > 0 &&
platforms.length > 0;
// Explains what Request does, or why it's disabled — the four canSubmit
// conditions checked in order, plus the in-flight case canSubmit doesn't
// cover. Always non-empty (see approveHint's comment for why).
const requestHint = requestMutation.isPending
? "Request already in flight"
: !effectiveProjectId
? "Pick a project first"
: occasion.trim().length === 0
? "Give this video an occasion"
: brief.trim().length === 0
? "Give this video a brief"
: platforms.length === 0
? "Pick at least one platform"
: "Open a video-authoring task for this brief";
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
@@ -476,12 +536,14 @@ function RequestVideoDialog({
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
onClick={() => requestMutation.mutate()}
disabled={!canSubmit || requestMutation.isPending}
>
{requestMutation.isPending ? "Requesting..." : "Request"}
</Button>
<HelpTip label={requestHint}>
<Button
onClick={() => requestMutation.mutate()}
disabled={!canSubmit || requestMutation.isPending}
>
{requestMutation.isPending ? "Requesting..." : "Request"}
</Button>
</HelpTip>
</DialogFooter>
</>
) : (
@@ -12,6 +12,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { HelpTip } from "@/components/ui/help-tip";
import { RefreshCw } from "lucide-react";
import { toast } from "sonner";
@@ -85,29 +86,45 @@ export function RerenderControl({
),
});
// Always non-empty: HelpTip unmounts/remounts its child when `label`
// toggles between falsy and truthy, which would lose the button's DOM
// identity right as the mutation settles (mirrors x-post-queue's
// approveHint comment).
const rerenderHint = rerenderMutation.isPending
? "Re-render in progress"
: rerenderMutation.isError
? `Re-render failed: ${
rerenderMutation.error instanceof Error
? rerenderMutation.error.message
: "unknown error"
} click to retry`
: "Discards the current render and queues a fresh one from the same composition";
return (
<>
<Button
type="button"
variant="outline"
size="sm"
disabled={rerenderMutation.isPending}
onClick={() => setConfirmOpen(true)}
className={
rerenderMutation.isError
? "border-destructive text-destructive"
: undefined
}
>
<RefreshCw
className={`mr-1 h-4 w-4 ${rerenderMutation.isPending ? "animate-spin" : ""}`}
/>
{rerenderMutation.isPending
? "Re-rendering..."
: rerenderMutation.isError
? "Retry re-render"
: "Re-render"}
</Button>
<HelpTip label={rerenderHint}>
<Button
type="button"
variant="outline"
size="sm"
disabled={rerenderMutation.isPending}
onClick={() => setConfirmOpen(true)}
className={
rerenderMutation.isError
? "border-destructive text-destructive"
: undefined
}
>
<RefreshCw
className={`mr-1 h-4 w-4 ${rerenderMutation.isPending ? "animate-spin" : ""}`}
/>
{rerenderMutation.isPending
? "Re-rendering..."
: rerenderMutation.isError
? "Retry re-render"
: "Re-render"}
</Button>
</HelpTip>
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<DialogContent>
<DialogHeader>
+63 -20
View File
@@ -23,6 +23,7 @@ import {
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { HelpTip } from "@/components/ui/help-tip";
import { AtSign, CheckCircle2, Rocket, Sparkles, XCircle } from "lucide-react";
import { toast } from "sonner";
@@ -30,10 +31,42 @@ const MAX_TWEET_CHARS = 280;
const _MIN_REASON_CHARS = 4;
function sourceMeta(source: XPost["source"]) {
if (source === "x_post") return { label: "Release post", icon: Rocket };
if (source === "x_post")
return {
label: "Release post",
icon: Rocket,
hint: "Drafted automatically when a release publishes",
};
if (source === "x_feature")
return { label: "Feature spotlight", icon: Sparkles };
return { label: "Mention reply", icon: AtSign };
return {
label: "Feature spotlight",
icon: Sparkles,
hint: "Drafted periodically by the Head of Marketing's feature-spotlight sweep",
};
return {
label: "Mention reply",
icon: AtSign,
hint: "Drafted automatically in reply to a meaningful mention on X",
};
}
// Explains what Approve does, or why it's disabled — surfaced on the button
// itself so the CEO doesn't have to guess between "already posting", "over
// the limit", or "empty". Always returns a non-empty string (never null):
// HelpTip renders a bare child vs. a Tooltip-wrapped one depending on
// truthiness, and toggling that branch on a live-changing condition (like
// `approving`) unmounts/remounts the child, losing any DOM reference a
// caller captured before the state flip.
function approveHint(
approving: boolean,
overLimit: boolean,
bodyEmpty: boolean,
): string {
if (approving) return "Already posting this draft";
if (overLimit)
return `Over X's ${MAX_TWEET_CHARS}-character limit — trim the draft to enable`;
if (bodyEmpty) return "Draft body is empty";
return "Post this draft to X";
}
function describeExecuteResult(result: XPostExecuteResult): string {
@@ -69,8 +102,12 @@ function XPostRow({
return (
<div className="rounded-lg border p-4 transition-colors hover:bg-muted/50">
<div className="mb-2 flex flex-wrap items-center gap-2">
<meta.icon className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{meta.label}</span>
<HelpTip label={meta.hint}>
<span className="inline-flex items-center gap-1.5">
<meta.icon className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{meta.label}</span>
</span>
</HelpTip>
{post.release_version && (
<Badge variant="outline">v{post.release_version}</Badge>
)}
@@ -92,13 +129,15 @@ function XPostRow({
rows={3}
className={overLimit ? "border-destructive" : undefined}
/>
<p
className={`mt-1 text-right text-xs ${
overLimit ? "text-destructive" : "text-muted-foreground"
}`}
>
{body.length}/{MAX_TWEET_CHARS}
</p>
<HelpTip label={`X's per-post character limit (${MAX_TWEET_CHARS})`}>
<p
className={`mt-1 text-right text-xs ${
overLimit ? "text-destructive" : "text-muted-foreground"
}`}
>
{body.length}/{MAX_TWEET_CHARS}
</p>
</HelpTip>
<div className="mt-2 flex flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-end">
<Button
@@ -110,15 +149,19 @@ function XPostRow({
<XCircle className="mr-1 h-4 w-4" />
Reject
</Button>
<Button
size="sm"
className="bg-green-600 hover:bg-green-700"
disabled={approving || overLimit || body.trim().length === 0}
onClick={() => onApprove(post.task_id, body)}
<HelpTip
label={approveHint(approving, overLimit, body.trim().length === 0)}
>
<CheckCircle2 className="mr-1 h-4 w-4" />
Approve &amp; post
</Button>
<Button
size="sm"
className="bg-green-600 hover:bg-green-700"
disabled={approving || overLimit || body.trim().length === 0}
onClick={() => onApprove(post.task_id, body)}
>
<CheckCircle2 className="mr-1 h-4 w-4" />
Approve &amp; post
</Button>
</HelpTip>
</div>
</div>
);