Files
resolutionflow/frontend/src/pages/DevBranchingPage.tsx
chihlasm 01836d6a2d fix: replace text-secondary with text-muted-foreground in branching components
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>
2026-03-24 22:36:01 +00:00

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>
)
}