feat(03-02): add SocialDraftPreview component and ApprovalInspector integration

- Create SocialDraftPreview with platform icon, color, content, char count, media URLs, schedule display
- Integrate SocialDraftPreview into ApprovalInspector for social_post approvals
- Add schedule picker with immediate/scheduled radio group and datetime-local input
- Add social_post to type badge with accent styling
- Approve button shows 'Approve & Post' for social_post approvals
- Accessibility: role=region, aria-label, aria-live=polite, role=radiogroup
- Fix test type annotation for TS compatibility
This commit is contained in:
ZacharyZhang-NY committed 2026-03-22 14:33:47 -04:00
1 parent 931e38c823
commit 43cb97ac6d
3 files changed
+259 -5

No files matched your search

@@ -1,11 +1,20 @@
import { useState } from "react"
import { Briefcase, CheckCircle, ClockCounterClockwise, Robot, Trash, XCircle } from "@phosphor-icons/react"
import { Briefcase, CheckCircle, Clock, ClockCounterClockwise, Robot, Trash, XCircle } from "@phosphor-icons/react"
import type { AgentRecord, ApprovalRecord, TaskRecord } from "@shared/types"
import { useT } from "../../i18n"
import { formatMoney, formatTime } from "../../lib/formatters"
import { unwrap } from "../../lib/desktop"
import { ActionButton, ConfirmDialog, InlineNotice, StatusPill, TextArea } from "../ui"
import { describeActionError, parseApprovalPayloadJson } from "./execution-utils"
import { SocialDraftPreview } from "./SocialDraftPreview"
function getTypeBadgeClass(type: string): string {
if (type === "hire_agent") return "bg-[color:var(--accent-soft)] text-[color:var(--accent)]"
if (type === "approve_ceo_strategy") return "bg-[color:var(--warn-soft)] text-[color:var(--warn)]"
if (type === "secret_access") return "bg-[color:var(--danger-soft)] text-[color:var(--danger)]"
if (type === "social_post") return "bg-[color:var(--accent-soft)] text-[color:var(--accent)]"
return "bg-[color:var(--panel-soft)] text-[color:var(--muted)]"
}
export function ApprovalInspector({ approval, agents, tasks, companyId, onDecide, onDeleted }: {
approval: ApprovalRecord; agents: AgentRecord[]; tasks: TaskRecord[]; companyId: string
@@ -14,12 +23,61 @@ export function ApprovalInspector({ approval, agents, tasks, companyId, onDecide
const t = useT()
const [decisionNote, setDecisionNote] = useState(""); const [confirmDelete, setConfirmDelete] = useState(false)
const [actionError, setActionError] = useState<string | null>(null); const [acting, setActing] = useState<"approved"|"rejected"|"revision_requested"|"delete"|null>(null)
const [scheduleMode, setScheduleMode] = useState<"immediate" | "scheduled">("immediate")
const [scheduledAt, setScheduledAt] = useState("")
const [scheduleError, setScheduleError] = useState<string | null>(null)
const requestingAgent = agents.find((a) => a.id === approval.requestedByAgentId) ?? null
const relatedTask = tasks.find((t) => t.id === approval.relatedTaskId) ?? null
const isDecided = approval.state !== "pending"
const isSocialPost = approval.type === "social_post"
async function handleDelete() { setActionError(null); setActing("delete"); try { unwrap(await window.agentCompany.deleteApproval({ id: approval.id, companyId })); setConfirmDelete(false); await onDeleted() } catch (e) { setActionError(describeActionError(e)) } finally { setActing(null) } }
async function handleDecision(state: "approved"|"rejected"|"revision_requested") { setActionError(null); setActing(state); try { await onDecide(state, decisionNote || undefined) } catch (e) { setActionError(describeActionError(e)) } finally { setActing(null) } }
async function handleDecision(state: "approved"|"rejected"|"revision_requested") {
setActionError(null)
setScheduleError(null)
// Validate schedule if social_post and user picked "scheduled"
if (isSocialPost && state === "approved" && scheduleMode === "scheduled") {
if (!scheduledAt) {
setScheduleError("Please select a date and time for scheduling.")
return
}
const chosenTime = new Date(scheduledAt).getTime()
if (chosenTime <= Date.now()) {
setScheduleError("Scheduled time must be in the future.")
return
}
}
setActing(state)
try {
// For social_post approvals with a schedule, include the schedule info in the decision note
let noteForDecision = decisionNote || undefined
if (isSocialPost && state === "approved" && scheduleMode === "scheduled" && scheduledAt) {
const scheduleInfo = `[scheduled:${new Date(scheduledAt).toISOString()}]`
noteForDecision = noteForDecision ? `${noteForDecision} ${scheduleInfo}` : scheduleInfo
}
await onDecide(state, noteForDecision)
} catch (e) {
setActionError(describeActionError(e))
} finally {
setActing(null)
}
}
// Compute minimum datetime for schedule picker: now + 5 minutes
const minDatetime = (() => {
const d = new Date(Date.now() + 5 * 60 * 1000)
// Format as YYYY-MM-DDTHH:mm for datetime-local input
const pad = (n: number) => String(n).padStart(2, "0")
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
})()
const socialPlatform = (() => {
if (!isSocialPost || !approval.payloadJson) return "other"
try { return (JSON.parse(approval.payloadJson) as { platform?: string }).platform ?? "other" } catch { return "other" }
})()
return (
<div className="space-y-5">
@@ -27,7 +85,7 @@ export function ApprovalInspector({ approval, agents, tasks, companyId, onDecide
{actionError && !confirmDelete ? <InlineNotice message={actionError} /> : null}
<div className="border-y border-[color:var(--line)] py-4 space-y-4">
<div className="flex items-center gap-3">
<span className={`rounded-full px-3 py-1 text-[11px] font-bold uppercase tracking-[0.08em] ${approval.type === "hire_agent" ? "bg-[color:var(--accent-soft)] text-[color:var(--accent)]" : approval.type === "approve_ceo_strategy" ? "bg-[color:var(--warn-soft)] text-[color:var(--warn)]" : approval.type === "secret_access" ? "bg-[color:var(--danger-soft)] text-[color:var(--danger)]" : "bg-[color:var(--panel-soft)] text-[color:var(--muted)]"}`}>{approval.type.replaceAll("_", " ")}</span>
<span className={`rounded-full px-3 py-1 text-[11px] font-bold uppercase tracking-[0.08em] ${getTypeBadgeClass(approval.type)}`}>{approval.type.replaceAll("_", " ")}</span>
<StatusPill status={approval.state} />
</div>
<div className="text-[14px] font-medium text-[color:var(--text)]">{approval.payloadSummary}</div>
@@ -49,10 +107,75 @@ export function ApprovalInspector({ approval, agents, tasks, companyId, onDecide
{mgr ? <div><span className="text-[color:var(--muted)]">Reports to:</span> <span className="text-[color:var(--text)]">{mgr.name}</span></div> : null}
</div></div>) })() : null}
{approval.type === "approve_ceo_strategy" ? <div className="rounded-[8px] border border-[color:var(--warn)] border-opacity-30 bg-[color:var(--warn-soft)] px-4 py-3"><div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.06em] text-[color:var(--warn)]">Organization Strategy</div><div className="whitespace-pre-wrap text-[12px] leading-relaxed text-[color:var(--text)]">{approval.payloadSummary}</div></div> : null}
{isSocialPost && approval.payloadJson ? (
<SocialDraftPreview
payloadJson={approval.payloadJson}
platform={socialPlatform}
isPending={approval.state === "pending"}
/>
) : null}
</div>
{/* Schedule picker for social_post approvals */}
{isSocialPost && !isDecided ? (
<div className="space-y-3">
<div role="radiogroup" aria-label="Post timing" className="space-y-2">
<label className="flex cursor-pointer items-center gap-2">
<span
role="radio"
aria-checked={scheduleMode === "immediate"}
tabIndex={0}
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded-full border-2 ${scheduleMode === "immediate" ? "border-[color:var(--accent)]" : "border-[color:var(--line)]"}`}
onClick={() => { setScheduleMode("immediate"); setScheduleError(null) }}
onKeyDown={(e) => { if (e.key === " " || e.key === "Enter") { setScheduleMode("immediate"); setScheduleError(null) } }}
>
{scheduleMode === "immediate" ? <span className="h-2 w-2 rounded-full bg-[color:var(--accent)]" /> : null}
</span>
<span className="text-[13px] font-medium text-[color:var(--text)]">Post immediately</span>
</label>
<label className="flex cursor-pointer items-center gap-2">
<span
role="radio"
aria-checked={scheduleMode === "scheduled"}
tabIndex={0}
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded-full border-2 ${scheduleMode === "scheduled" ? "border-[color:var(--accent)]" : "border-[color:var(--line)]"}`}
onClick={() => setScheduleMode("scheduled")}
onKeyDown={(e) => { if (e.key === " " || e.key === "Enter") setScheduleMode("scheduled") }}
>
{scheduleMode === "scheduled" ? <span className="h-2 w-2 rounded-full bg-[color:var(--accent)]" /> : null}
</span>
<span className="text-[13px] font-medium text-[color:var(--text)]">Schedule for later</span>
</label>
</div>
{scheduleMode === "scheduled" ? (
<div className="flex items-center gap-2">
<Clock size={16} className="text-[color:var(--muted)]" />
<input
type="datetime-local"
value={scheduledAt}
min={minDatetime}
onChange={(e) => { setScheduledAt(e.target.value); setScheduleError(null) }}
className="rounded-[8px] border border-[color:var(--line)] bg-[color:var(--panel)] px-3.5 py-2.5 text-[13px] text-[color:var(--text)] focus:border-[color:var(--accent)] focus:outline-none"
/>
</div>
) : null}
{scheduleError ? <InlineNotice message={scheduleError} /> : null}
</div>
) : null}
{!isDecided ? <div><label className="mb-1 block text-[11px] font-semibold text-[color:var(--muted)]">Decision note</label><TextArea value={decisionNote} onChange={setDecisionNote} placeholder="Optional note to accompany your decision..." rows={3} /></div> : null}
<div className="flex flex-wrap gap-3">
{!isDecided ? <><ActionButton label={t("exec.approve")} tone="accent" onClick={() => void handleDecision("approved")} icon={CheckCircle} loading={acting === "approved"} /><ActionButton label={t("exec.requestRevision")} onClick={() => void handleDecision("revision_requested")} icon={ClockCounterClockwise} loading={acting === "revision_requested"} /><ActionButton label={t("exec.reject")} tone="danger" onClick={() => void handleDecision("rejected")} icon={XCircle} loading={acting === "rejected"} /></> : null}
{!isDecided ? <>
<ActionButton
label={isSocialPost ? "Approve & Post" : t("exec.approve")}
tone="accent"
onClick={() => void handleDecision("approved")}
icon={CheckCircle}
loading={acting === "approved"}
/>
<ActionButton label={t("exec.requestRevision")} onClick={() => void handleDecision("revision_requested")} icon={ClockCounterClockwise} loading={acting === "revision_requested"} />
<ActionButton label={t("exec.reject")} tone="danger" onClick={() => void handleDecision("rejected")} icon={XCircle} loading={acting === "rejected"} />
</> : null}
{isDecided ? <ActionButton label="Delete" onClick={() => setConfirmDelete(true)} icon={Trash} tone="danger" loading={acting === "delete"} /> : null}
</div>
{approval.decisionNote ? <div className="border-t border-[color:var(--line)] pt-4 text-[13px] text-[color:var(--muted-strong)]">Last note: {approval.decisionNote}</div> : null}
@@ -0,0 +1,131 @@
import { Clock, Link as LinkIcon } from "@phosphor-icons/react"
import { platformConfig } from "../social/social-config"
import type { SocialPlatform } from "@shared/types"
const PLATFORM_CHAR_LIMITS: Record<string, number> = {
twitter: 280,
linkedin: 3000,
reddit: 40000,
instagram: 2200,
}
const DEFAULT_CHAR_LIMIT = 5000
interface SocialDraftPreviewProps {
payloadJson: string
platform: string
isPending: boolean
onScheduleChange?: (scheduledAt: string | null) => void
}
interface DraftPayload {
content: string
mediaUrls: string[]
scheduledAt: string | null
socialAccountId: string
platform: string
}
function parseDraft(payloadJson: string): DraftPayload | null {
try {
const parsed = JSON.parse(payloadJson)
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return {
content: typeof parsed.content === "string" ? parsed.content : "",
mediaUrls: Array.isArray(parsed.mediaUrls) ? parsed.mediaUrls : [],
scheduledAt: typeof parsed.scheduledAt === "string" ? parsed.scheduledAt : null,
socialAccountId: typeof parsed.socialAccountId === "string" ? parsed.socialAccountId : "",
platform: typeof parsed.platform === "string" ? parsed.platform : "other",
}
}
} catch { /* invalid JSON */ }
return null
}
function formatScheduledDate(isoDate: string): string {
try {
const d = new Date(isoDate)
return d.toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
timeZoneName: "short",
})
} catch {
return isoDate
}
}
export function SocialDraftPreview({ payloadJson, platform, isPending: _isPending }: SocialDraftPreviewProps) {
const draft = parseDraft(payloadJson)
if (!draft) return null
const platformKey = (draft.platform || platform || "other") as SocialPlatform
const config = platformConfig[platformKey] ?? platformConfig.other
const PlatformIcon = config.icon
const platformColor = config.color
const charLimit = PLATFORM_CHAR_LIMITS[platformKey] ?? DEFAULT_CHAR_LIMIT
const charCount = draft.content.length
const isOverLimit = charCount > charLimit
return (
<div
role="region"
aria-label="Social post draft preview"
className="rounded-[8px] border px-4 py-3"
style={{ borderColor: `${platformColor}4D` }}
>
<div className="mb-2 flex items-center gap-2">
<span
className="text-[11px] font-semibold uppercase tracking-[0.06em]"
style={{ color: platformColor }}
>
SOCIAL POST DRAFT
</span>
</div>
<div className="mb-3 flex items-center gap-2">
<PlatformIcon size={16} style={{ color: platformColor }} />
<span className="text-[13px] font-medium text-[color:var(--text)]">
{config.label}
</span>
{draft.socialAccountId ? (
<span className="text-[13px] font-[family-name:var(--font-mono)] text-[color:var(--muted)]">
@{draft.socialAccountId.slice(0, 8)}
</span>
) : null}
</div>
<div className="mb-2 max-h-[200px] overflow-y-auto whitespace-pre-wrap text-[13px] leading-relaxed text-[color:var(--text)]">
{draft.content}
</div>
<div
className="mb-2 text-[10px] font-[family-name:var(--font-mono)]"
style={{ color: isOverLimit ? "var(--danger)" : "var(--muted)" }}
aria-live="polite"
>
{charCount} / {charLimit}{isOverLimit ? " -- over limit" : ""}
</div>
{draft.mediaUrls.length > 0 ? (
<div className="mb-2 space-y-1">
{draft.mediaUrls.map((url, idx) => (
<div key={idx} className="flex items-center gap-1.5 text-[11px] text-[color:var(--accent)]">
<LinkIcon size={12} />
<span className="truncate max-w-[300px]">{url}</span>
</div>
))}
</div>
) : null}
{draft.scheduledAt ? (
<div className="flex items-center gap-1.5 text-[13px] font-[family-name:var(--font-mono)] text-[color:var(--warn)]">
<Clock size={14} />
Scheduled: {formatScheduledDate(draft.scheduledAt)}
</div>
) : null}
</div>
)
}