In Tailwind v4, text-secondary resolves to --color-secondary (#2e3140), a dark surface color — NOT --color-text-secondary (#848b9b). This made all secondary text invisible on dark backgrounds. The correct class is text-muted-foreground which maps to #848b9b. This matches the pattern used by existing FlowPilot components. Also reverts the unnecessary index.css variable bump from prior commit. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
578 lines
21 KiB
TypeScript
578 lines
21 KiB
TypeScript
/**
|
|
* Dev test page for Conversational Branching components.
|
|
* Route: /dev/branching
|
|
*
|
|
* Renders all branching components with mock data — no API calls.
|
|
* Use this to validate visual design before wiring into FlowPilotSessionPage.
|
|
*/
|
|
import { useState } from 'react'
|
|
import { BranchMap } from '@/components/session/BranchMap'
|
|
import { ForkCard } from '@/components/session/ForkCard'
|
|
import { BranchTransitionBar } from '@/components/session/BranchTransitionBar'
|
|
import { BranchRevivalCard } from '@/components/session/BranchRevivalCard'
|
|
import { HandoffModal } from '@/components/session/HandoffModal'
|
|
import type {
|
|
BranchResponse,
|
|
ForkPointResponse,
|
|
ResolutionOutputResponse,
|
|
ResolutionOutputType,
|
|
} from '@/types/branching'
|
|
import { cn } from '@/lib/utils'
|
|
import { toast } from '@/lib/toast'
|
|
import {
|
|
FileText,
|
|
BookOpen,
|
|
MessageSquare,
|
|
Pencil,
|
|
Copy,
|
|
Send,
|
|
Check,
|
|
} from 'lucide-react'
|
|
|
|
// ── Mock Data ──
|
|
|
|
const SESSION_ID = 'mock-session-001'
|
|
const ROOT_ID = 'branch-root'
|
|
const BRANCH_NET = 'branch-network'
|
|
const BRANCH_DNS = 'branch-dns'
|
|
const BRANCH_DHCP = 'branch-dhcp'
|
|
const BRANCH_REVIVED = 'branch-revived-firewall'
|
|
|
|
const MOCK_BRANCHES: BranchResponse[] = [
|
|
{
|
|
id: ROOT_ID,
|
|
session_id: SESSION_ID,
|
|
parent_branch_id: null,
|
|
fork_point_step_id: null,
|
|
branch_order: 1,
|
|
label: 'Root — Initial Investigation',
|
|
status: 'active',
|
|
status_reason: null,
|
|
status_changed_at: null,
|
|
context_summary: {
|
|
tried: ['Checked event logs', 'Ran ipconfig'],
|
|
concluded: 'Multiple possible causes identified',
|
|
artifacts: [],
|
|
},
|
|
evidence_from_branch_id: null,
|
|
evidence_description: null,
|
|
step_count: 4,
|
|
created_at: '2026-03-24T10:00:00Z',
|
|
updated_at: '2026-03-24T10:05:00Z',
|
|
},
|
|
{
|
|
id: BRANCH_NET,
|
|
session_id: SESSION_ID,
|
|
parent_branch_id: ROOT_ID,
|
|
fork_point_step_id: null,
|
|
branch_order: 1,
|
|
label: 'Network Connectivity',
|
|
status: 'dead_end',
|
|
status_reason: 'Ping to gateway successful, NIC is healthy',
|
|
status_changed_at: '2026-03-24T10:12:00Z',
|
|
context_summary: {
|
|
tried: ['Ping gateway', 'Check NIC status', 'Traceroute'],
|
|
concluded: 'Network layer is not the issue',
|
|
artifacts: ['traceroute.txt'],
|
|
},
|
|
evidence_from_branch_id: null,
|
|
evidence_description: null,
|
|
step_count: 3,
|
|
created_at: '2026-03-24T10:05:00Z',
|
|
updated_at: '2026-03-24T10:12:00Z',
|
|
},
|
|
{
|
|
id: BRANCH_DNS,
|
|
session_id: SESSION_ID,
|
|
parent_branch_id: ROOT_ID,
|
|
fork_point_step_id: null,
|
|
branch_order: 2,
|
|
label: 'DNS Resolution',
|
|
status: 'solved',
|
|
status_reason: 'DNS cache was poisoned — flushed and resolved',
|
|
status_changed_at: '2026-03-24T10:20:00Z',
|
|
context_summary: {
|
|
tried: ['nslookup', 'ipconfig /flushdns', 'Checked DNS server config'],
|
|
concluded: 'Root cause: stale DNS cache entry for internal domain',
|
|
artifacts: ['nslookup-output.txt'],
|
|
},
|
|
evidence_from_branch_id: null,
|
|
evidence_description: null,
|
|
step_count: 5,
|
|
created_at: '2026-03-24T10:05:00Z',
|
|
updated_at: '2026-03-24T10:20:00Z',
|
|
},
|
|
{
|
|
id: BRANCH_DHCP,
|
|
session_id: SESSION_ID,
|
|
parent_branch_id: ROOT_ID,
|
|
fork_point_step_id: null,
|
|
branch_order: 3,
|
|
label: 'DHCP Lease Issue',
|
|
status: 'untried',
|
|
status_reason: null,
|
|
status_changed_at: null,
|
|
context_summary: null,
|
|
evidence_from_branch_id: null,
|
|
evidence_description: null,
|
|
step_count: 0,
|
|
created_at: '2026-03-24T10:05:00Z',
|
|
updated_at: '2026-03-24T10:05:00Z',
|
|
},
|
|
{
|
|
id: BRANCH_REVIVED,
|
|
session_id: SESSION_ID,
|
|
parent_branch_id: BRANCH_NET,
|
|
fork_point_step_id: null,
|
|
branch_order: 1,
|
|
label: 'Firewall Rules',
|
|
status: 'revived',
|
|
status_reason: null,
|
|
status_changed_at: '2026-03-24T10:25:00Z',
|
|
context_summary: {
|
|
tried: ['Checked Windows Firewall'],
|
|
concluded: 'Revisiting after DNS fix revealed blocked outbound on port 443',
|
|
artifacts: [],
|
|
},
|
|
evidence_from_branch_id: BRANCH_DNS,
|
|
evidence_description: 'DNS fix revealed that port 443 is being blocked by a new firewall rule pushed via GPO',
|
|
step_count: 2,
|
|
created_at: '2026-03-24T10:15:00Z',
|
|
updated_at: '2026-03-24T10:25:00Z',
|
|
},
|
|
]
|
|
|
|
const MOCK_FORK: ForkPointResponse = {
|
|
id: 'fork-001',
|
|
session_id: SESSION_ID,
|
|
parent_branch_id: ROOT_ID,
|
|
trigger_step_id: null,
|
|
fork_reason:
|
|
"Based on the symptoms (intermittent connectivity loss affecting only this workstation), I see three possible causes. Let's investigate each:",
|
|
options: [
|
|
{
|
|
label: 'Network Connectivity',
|
|
description: 'NIC driver issue, cable fault, or switch port problem',
|
|
branch_id: BRANCH_NET,
|
|
status: 'dead_end',
|
|
},
|
|
{
|
|
label: 'DNS Resolution',
|
|
description: 'Stale cache, wrong DNS server, or poisoned entry',
|
|
branch_id: BRANCH_DNS,
|
|
status: 'solved',
|
|
},
|
|
{
|
|
label: 'DHCP Lease Issue',
|
|
description: 'Lease expiry, scope exhaustion, or relay agent failure',
|
|
branch_id: BRANCH_DHCP,
|
|
status: 'untried',
|
|
},
|
|
],
|
|
created_at: '2026-03-24T10:05:00Z',
|
|
}
|
|
|
|
const MOCK_OUTPUTS: ResolutionOutputResponse[] = [
|
|
{
|
|
id: 'output-psa',
|
|
session_id: SESSION_ID,
|
|
output_type: 'psa_ticket_notes',
|
|
generated_content: `## Problem
|
|
User reported intermittent connectivity loss on workstation WS-1042 in the Accounting department.
|
|
|
|
## Diagnostic Steps
|
|
1. **Network Connectivity** [Dead End] — Ping to gateway successful (avg 2ms), NIC healthy, traceroute clean. Network layer ruled out.
|
|
2. **DNS Resolution** [Solved] — nslookup for internal domain \`erp.contoso.local\` returned stale IP (10.0.1.50 instead of 10.0.2.50). DNS cache poisoned by a cached entry from before the ERP server migration last Tuesday.
|
|
3. **Firewall Rules** [Revived] — DNS fix revealed that a new GPO-pushed firewall rule was blocking outbound port 443. Rule was added by the security team on 3/22 without change advisory.
|
|
|
|
## Resolution
|
|
- Flushed DNS cache: \`ipconfig /flushdns\`
|
|
- Cleared stale DNS record on domain controller
|
|
- Firewall rule exception added for ERP traffic
|
|
- Verified connectivity stable for 15 minutes post-fix
|
|
|
|
## Recommendations
|
|
- Add DNS record validation to the ERP migration runbook
|
|
- Request change advisory process for GPO firewall rule changes`,
|
|
structured_data: null,
|
|
edited_content: null,
|
|
status: 'draft',
|
|
pushed_to: null,
|
|
pushed_at: null,
|
|
pushed_reference: null,
|
|
generated_by_model: 'claude-sonnet-4-6',
|
|
created_at: '2026-03-24T10:22:00Z',
|
|
updated_at: '2026-03-24T10:22:00Z',
|
|
},
|
|
{
|
|
id: 'output-kb',
|
|
session_id: SESSION_ID,
|
|
output_type: 'knowledge_base',
|
|
generated_content: `# Intermittent Connectivity After Server Migration
|
|
|
|
## Symptoms
|
|
- Workstation loses connectivity to internal applications intermittently
|
|
- External sites work normally
|
|
- Affects single workstation or small group
|
|
|
|
## Root Cause
|
|
Stale DNS cache entries pointing to pre-migration IP addresses. Often occurs 1-7 days after internal server migrations when DNS TTLs haven't expired.
|
|
|
|
## Things to Rule Out First
|
|
- **Network layer** — If ping to gateway succeeds and traceroute is clean, skip to DNS
|
|
- **DHCP** — Only investigate if ipconfig shows APIPA address (169.254.x.x)
|
|
|
|
## Resolution Steps
|
|
1. Run \`nslookup <affected-hostname>\` — compare returned IP with current server IP
|
|
2. If mismatched: \`ipconfig /flushdns\` on the workstation
|
|
3. Check AD DNS for stale records: \`dnscmd /enumrecords contoso.local <hostname>\`
|
|
4. Delete stale record if found
|
|
5. Verify: \`nslookup <hostname>\` returns correct IP
|
|
|
|
## Tags
|
|
DNS, migration, connectivity, cache, stale-record`,
|
|
structured_data: {
|
|
symptoms: ['Intermittent connectivity to internal apps', 'External sites work'],
|
|
root_cause: 'Stale DNS cache from server migration',
|
|
steps: ['nslookup', 'flushdns', 'Check AD DNS', 'Delete stale record'],
|
|
tags: ['DNS', 'migration', 'connectivity'],
|
|
},
|
|
edited_content: null,
|
|
status: 'draft',
|
|
pushed_to: null,
|
|
pushed_at: null,
|
|
pushed_reference: null,
|
|
generated_by_model: 'claude-sonnet-4-6',
|
|
created_at: '2026-03-24T10:22:00Z',
|
|
updated_at: '2026-03-24T10:22:00Z',
|
|
},
|
|
{
|
|
id: 'output-client',
|
|
session_id: SESSION_ID,
|
|
output_type: 'client_summary',
|
|
generated_content: `Hi,
|
|
|
|
We've resolved the connectivity issue on your workstation. Here's what happened:
|
|
|
|
After your company's ERP server was moved to a new address last week, your computer was still trying to reach it at the old address. This caused the intermittent connection drops you were experiencing when accessing internal applications.
|
|
|
|
We've updated the address records and cleared the old cached information on your machine. We also found and fixed a secondary issue where a recently applied security policy was blocking some web traffic.
|
|
|
|
Your connectivity should now be stable. If you experience any further issues, please don't hesitate to reach out.`,
|
|
structured_data: null,
|
|
edited_content: null,
|
|
status: 'draft',
|
|
pushed_to: null,
|
|
pushed_at: null,
|
|
pushed_reference: null,
|
|
generated_by_model: 'claude-sonnet-4-6',
|
|
created_at: '2026-03-24T10:22:00Z',
|
|
updated_at: '2026-03-24T10:22:00Z',
|
|
},
|
|
]
|
|
|
|
// ── Mock Resolution Panel (self-contained, no API calls) ──
|
|
|
|
const TABS: Array<{ type: ResolutionOutputType; label: string; icon: React.ElementType }> = [
|
|
{ type: 'psa_ticket_notes', label: 'PSA Notes', icon: FileText },
|
|
{ type: 'knowledge_base', label: 'KB Article', icon: BookOpen },
|
|
{ type: 'client_summary', label: 'Client Summary', icon: MessageSquare },
|
|
]
|
|
|
|
function MockResolutionPanel({ outputs }: { outputs: ResolutionOutputResponse[] }) {
|
|
const [activeType, setActiveType] = useState<ResolutionOutputType>('psa_ticket_notes')
|
|
const [isEditing, setIsEditing] = useState(false)
|
|
const [editValue, setEditValue] = useState('')
|
|
const [copied, setCopied] = useState(false)
|
|
|
|
const activeOutput = outputs.find(o => o.output_type === activeType) ?? null
|
|
const displayContent = activeOutput?.edited_content ?? activeOutput?.generated_content ?? ''
|
|
|
|
function handleEditToggle() {
|
|
if (!isEditing) {
|
|
setEditValue(displayContent)
|
|
setIsEditing(true)
|
|
} else {
|
|
setIsEditing(false)
|
|
setEditValue('')
|
|
}
|
|
}
|
|
|
|
async function handleCopy() {
|
|
try {
|
|
await navigator.clipboard.writeText(displayContent)
|
|
setCopied(true)
|
|
setTimeout(() => setCopied(false), 2000)
|
|
} catch {
|
|
toast.error('Failed to copy')
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col bg-card border border-default rounded-lg overflow-hidden">
|
|
{/* Tab bar */}
|
|
<div className="flex border-b border-default shrink-0">
|
|
{TABS.map(tab => {
|
|
const Icon = tab.icon
|
|
const isActive = tab.type === activeType
|
|
return (
|
|
<button
|
|
key={tab.type}
|
|
type="button"
|
|
onClick={() => { setActiveType(tab.type); setIsEditing(false) }}
|
|
className={cn(
|
|
'flex items-center gap-1.5 px-3 py-2.5 text-xs font-medium transition-colors border-b-2 -mb-px',
|
|
isActive
|
|
? 'border-accent text-accent-text'
|
|
: 'border-transparent text-muted-foreground hover:text-primary hover:border-hover'
|
|
)}
|
|
>
|
|
<Icon size={13} />
|
|
<span>{tab.label}</span>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="flex-1 overflow-auto p-3 min-h-0 max-h-[400px]">
|
|
{!activeOutput ? (
|
|
<div className="flex items-center justify-center h-24">
|
|
<span className="text-xs text-muted">No output generated yet.</span>
|
|
</div>
|
|
) : isEditing ? (
|
|
<textarea
|
|
value={editValue}
|
|
onChange={e => setEditValue(e.target.value)}
|
|
className={cn(
|
|
'w-full h-full min-h-[240px] resize-none rounded-[5px] border border-default bg-input',
|
|
'px-3 py-2 text-sm text-primary font-mono leading-relaxed',
|
|
'focus:outline-none focus:border-accent focus:shadow-[0_0_0_2px_var(--color-accent-dim)]',
|
|
)}
|
|
/>
|
|
) : (
|
|
<pre className="text-sm text-primary font-mono whitespace-pre-wrap leading-relaxed">
|
|
{displayContent}
|
|
</pre>
|
|
)}
|
|
</div>
|
|
|
|
{/* Action bar */}
|
|
<div className="flex items-center gap-2 px-3 py-2.5 border-t border-default shrink-0">
|
|
{isEditing ? (
|
|
<>
|
|
<button
|
|
type="button"
|
|
onClick={() => { toast.success('Saved (mock)'); setIsEditing(false) }}
|
|
className="rounded-[5px] bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent-hover transition-colors"
|
|
>
|
|
Save
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={handleEditToggle}
|
|
className="rounded-[5px] border border-default px-3 py-1.5 text-xs text-muted-foreground hover:bg-elevated hover:text-primary transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<button
|
|
type="button"
|
|
onClick={handleEditToggle}
|
|
disabled={!activeOutput}
|
|
className="flex items-center gap-1.5 rounded-[5px] border border-default px-3 py-1.5 text-xs text-muted-foreground hover:bg-elevated hover:text-primary transition-colors disabled:opacity-40"
|
|
>
|
|
<Pencil size={12} /> Edit
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={handleCopy}
|
|
disabled={!activeOutput}
|
|
className={cn(
|
|
'flex items-center gap-1.5 rounded-[5px] border border-default px-3 py-1.5 text-xs transition-colors disabled:opacity-40',
|
|
copied ? 'border-success text-success' : 'text-muted-foreground hover:bg-elevated hover:text-primary'
|
|
)}
|
|
>
|
|
{copied ? <Check size={12} /> : <Copy size={12} />}
|
|
{copied ? 'Copied' : 'Copy'}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => toast.success('Pushed to PSA (mock)')}
|
|
disabled={!activeOutput}
|
|
className="flex items-center gap-1.5 rounded-[5px] border border-default px-3 py-1.5 text-xs text-muted-foreground hover:bg-elevated hover:text-primary transition-colors disabled:opacity-40"
|
|
>
|
|
<Send size={12} /> Push to PSA
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── Page ──
|
|
|
|
export default function DevBranchingPage() {
|
|
const [activeBranchId, setActiveBranchId] = useState<string>(BRANCH_DNS)
|
|
const [selectedForkBranch, setSelectedForkBranch] = useState<string | null>(BRANCH_DNS)
|
|
const [showHandoff, setShowHandoff] = useState(false)
|
|
const [previousBranchId, setPreviousBranchId] = useState<string | null>(null)
|
|
const [showTransition, setShowTransition] = useState(false)
|
|
|
|
const activeBranch = MOCK_BRANCHES.find(b => b.id === activeBranchId)!
|
|
const previousBranch = previousBranchId ? MOCK_BRANCHES.find(b => b.id === previousBranchId) ?? null : null
|
|
const revivedBranch = MOCK_BRANCHES.find(b => b.id === BRANCH_REVIVED)!
|
|
const evidenceSource = MOCK_BRANCHES.find(b => b.id === revivedBranch.evidence_from_branch_id) ?? null
|
|
|
|
function handleSwitchBranch(branchId: string) {
|
|
if (branchId !== activeBranchId) {
|
|
setPreviousBranchId(activeBranchId)
|
|
setActiveBranchId(branchId)
|
|
setShowTransition(true)
|
|
setTimeout(() => setShowTransition(false), 3000)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="flex h-full">
|
|
{/* Branch Map Sidebar */}
|
|
<aside className="w-[260px] shrink-0 border-r border-default bg-sidebar p-3 overflow-y-auto">
|
|
<BranchMap
|
|
branches={MOCK_BRANCHES}
|
|
activeBranchId={activeBranchId}
|
|
onSelectBranch={handleSwitchBranch}
|
|
/>
|
|
</aside>
|
|
|
|
{/* Main Content */}
|
|
<main className="flex-1 overflow-y-auto p-6 flex flex-col gap-6">
|
|
<div className="max-w-3xl mx-auto w-full flex flex-col gap-6">
|
|
{/* Page header */}
|
|
<div>
|
|
<h1 className="text-lg font-heading font-semibold text-heading">
|
|
Conversational Branching — Component Test
|
|
</h1>
|
|
<p className="text-sm text-primary mt-1">
|
|
Mock scenario: Intermittent connectivity on WS-1042. 5 branches, 1 fork, 1 revival.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Branch Transition Bar */}
|
|
<section>
|
|
<SectionLabel>Branch Transition Bar</SectionLabel>
|
|
{showTransition ? (
|
|
<BranchTransitionBar
|
|
fromBranch={previousBranch}
|
|
toBranch={activeBranch}
|
|
/>
|
|
) : (
|
|
<p className="text-xs text-muted-foreground">Click a branch in the sidebar to see the transition bar.</p>
|
|
)}
|
|
</section>
|
|
|
|
{/* Fork Card */}
|
|
<section>
|
|
<SectionLabel>Fork Card</SectionLabel>
|
|
<ForkCard
|
|
fork={MOCK_FORK}
|
|
selectedBranchId={selectedForkBranch}
|
|
onSelectOption={(branchId) => {
|
|
setSelectedForkBranch(branchId)
|
|
handleSwitchBranch(branchId)
|
|
}}
|
|
/>
|
|
</section>
|
|
|
|
{/* Revival Card */}
|
|
<section>
|
|
<SectionLabel>Branch Revival Card</SectionLabel>
|
|
<BranchRevivalCard
|
|
branch={revivedBranch}
|
|
evidenceSource={evidenceSource}
|
|
/>
|
|
</section>
|
|
|
|
{/* Handoff Modal Trigger */}
|
|
<section>
|
|
<SectionLabel>Handoff Modal</SectionLabel>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowHandoff(true)}
|
|
className="rounded-[5px] bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent-hover transition-colors"
|
|
>
|
|
Open Handoff Modal
|
|
</button>
|
|
</section>
|
|
|
|
{/* Resolution Output Panel */}
|
|
<section>
|
|
<SectionLabel>Resolution Output Panel</SectionLabel>
|
|
<MockResolutionPanel outputs={MOCK_OUTPUTS} />
|
|
</section>
|
|
|
|
{/* Current branch info */}
|
|
<section>
|
|
<SectionLabel>Active Branch Details</SectionLabel>
|
|
<div className="bg-card border border-default rounded-lg p-4 text-sm">
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<div>
|
|
<span className="text-muted-foreground">Label:</span>{' '}
|
|
<span className="text-heading">{activeBranch.label}</span>
|
|
</div>
|
|
<div>
|
|
<span className="text-muted-foreground">Status:</span>{' '}
|
|
<span className="text-heading">{activeBranch.status}</span>
|
|
</div>
|
|
<div>
|
|
<span className="text-muted-foreground">Steps:</span>{' '}
|
|
<span className="text-heading">{activeBranch.step_count}</span>
|
|
</div>
|
|
<div>
|
|
<span className="text-muted-foreground">Order:</span>{' '}
|
|
<span className="text-heading">{activeBranch.branch_order}</span>
|
|
</div>
|
|
</div>
|
|
{activeBranch.status_reason && (
|
|
<div className="mt-2 pt-2 border-t border-default">
|
|
<span className="text-muted-foreground">Reason:</span>{' '}
|
|
<span className="text-primary">{activeBranch.status_reason}</span>
|
|
</div>
|
|
)}
|
|
{activeBranch.context_summary && (
|
|
<div className="mt-2 pt-2 border-t border-default">
|
|
<span className="text-muted-foreground">Tried:</span>{' '}
|
|
<span className="text-primary">{activeBranch.context_summary.tried.join(', ')}</span>
|
|
<br />
|
|
<span className="text-muted-foreground">Concluded:</span>{' '}
|
|
<span className="text-primary">{activeBranch.context_summary.concluded}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</main>
|
|
|
|
{/* Handoff Modal */}
|
|
{showHandoff && (
|
|
<HandoffModal
|
|
onClose={() => setShowHandoff(false)}
|
|
onSubmit={async (data) => {
|
|
toast.success(`Handoff submitted: ${data.intent} (${data.priority ?? 'normal'})`)
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function SectionLabel({ children }: { children: React.ReactNode }) {
|
|
return (
|
|
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground mb-2 block">
|
|
{children}
|
|
</span>
|
|
)
|
|
}
|