fix(l1): resolve PR #193 frontend review findings (2a,2b,3,4,5,7)

Mounts L1EscalationsSection on EscalationQueuePage (Finding 2a — it was never
rendered) and renders the correct fields: step.question ?? step.text, timeAgo,
and the session problem_text (Finding 2b). ProposalDetail gates the /pilot link
on source_session_id and shows an L1-source block for l1_session_id-sourced
proposals (Finding 3 — was a broken /pilot/null link). Collapses the three
near-identical intake handlers into one runIntake: "Use this flow" now passes
near_miss.flow_id (Finding 4 — it previously re-suggested forever) and a
navigate guard prevents /l1/walk/undefined; out_of_scope gains a "Walk it
ad-hoc" button (Finding 5). Aligns L1-category permissions to owner+admin:
usePermissions.canManageAccount includes account admins, User.account_role TS
type gains 'admin', and a new ProtectedRoute requireAccountManager guard fronts
the route (Finding 7). Drops the unused NextNodeRequest.acknowledged field.

tsc -b + eslint + vite build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-09 15:55:55 -04:00
parent ac89e7b2fa
commit 9afaf37fb3
10 changed files with 127 additions and 84 deletions

View File

@@ -88,18 +88,35 @@ export function ProposalDetail({ proposal, onReview }: ProposalDetailProps) {
{/* Content */}
<div className="flex-1 overflow-y-auto p-6 space-y-5">
{/* Source session link */}
<div className="card-flat p-4">
<h4 className="font-sans text-xs text-[0.625rem] uppercase tracking-wider text-text-muted mb-2">Source Session</h4>
<Link
to={`/pilot/${proposal.source_session_id}`}
target="_blank"
className="flex items-center gap-2 text-sm text-primary hover:underline"
>
<ExternalLink size={12} />
View session that generated this proposal
</Link>
</div>
{/* Source — exactly one of a FlowPilot session XOR an L1 walk is set
(DB CHECK). Never link to /pilot for an L1-sourced proposal:
source_session_id is NULL there, so the old unconditional link
rendered a broken /pilot/null. */}
{proposal.source_session_id ? (
<div className="card-flat p-4">
<h4 className="font-sans text-xs text-[0.625rem] uppercase tracking-wider text-text-muted mb-2">Source Session</h4>
<Link
to={`/pilot/${proposal.source_session_id}`}
target="_blank"
className="flex items-center gap-2 text-sm text-primary hover:underline"
>
<ExternalLink size={12} />
View session that generated this proposal
</Link>
</div>
) : proposal.l1_session_id ? (
<div className="card-flat p-4">
<h4 className="font-sans text-xs text-[0.625rem] uppercase tracking-wider text-text-muted mb-2">Source L1 AI walkthrough</h4>
<p className="text-sm text-muted-foreground">
Captured from an L1 technician's AI-guided walk and validated by a
successful resolution. The proposed flow is the path that resolved the ticket.
</p>
<p className="mt-2 flex items-center gap-1.5 text-xs text-text-muted">
<Hash size={11} />
<span className="font-mono">L1 session {proposal.l1_session_id.slice(0, 8)}</span>
</p>
</div>
) : null}
{/* Proposed diff (for enhancements) */}
{proposal.proposed_diff && (() => {

View File

@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'
import { l1Api } from '@/api/l1'
import { timeAgo } from '@/lib/timeAgo'
import type { WalkSession } from '@/types/l1'
/**
@@ -43,11 +44,13 @@ export function L1EscalationsSection() {
<div className="flex items-center gap-3 min-w-0">
<span className="font-mono text-xs text-text-muted">#{s.id.slice(0, 8)}</span>
<span className="text-sm text-text-primary truncate">
{s.walked_path.length} step{s.walked_path.length === 1 ? '' : 's'} walked
{s.problem_text
? s.problem_text
: `${s.walked_path.length} step${s.walked_path.length === 1 ? '' : 's'} walked`}
</span>
</div>
<span className="text-xs text-text-muted whitespace-nowrap">
{new Date(s.last_step_at).toLocaleString()}
{timeAgo(s.last_step_at)}
</span>
</button>
{isOpen && (
@@ -58,7 +61,7 @@ export function L1EscalationsSection() {
<ol className="space-y-1.5 text-sm">
{s.walked_path.map((step, i) => (
<li key={i} className="flex flex-col">
<span className="text-text-muted text-xs">{step.question}</span>
<span className="text-text-muted text-xs">{step.question ?? step.text}</span>
{step.answer && (
<span className="font-medium text-text-primary"> {step.answer}</span>
)}

View File

@@ -44,7 +44,7 @@ export function L1WalkTreeVariant({ session, onSessionUpdate, onDone }: Props) {
}, [isAiBuild, session.id, session.status])
const advanceNode = useCallback(
async (body: { answer?: 'yes' | 'no'; acknowledged?: boolean }) => {
async (body: { answer?: 'yes' | 'no' }) => {
if (!node) return
setNodeLoading(true)
setNodeError(null)
@@ -183,7 +183,7 @@ export function L1WalkTreeVariant({ session, onSessionUpdate, onDone }: Props) {
<>
<p className="text-lg">{node.text}</p>
<button
onClick={() => advanceNode({ acknowledged: true })}
onClick={() => advanceNode({})}
className="rounded-md bg-accent text-white px-5 py-3 text-base font-medium hover:bg-accent/90 min-h-[44px] transition-colors"
>
Done next step
@@ -251,8 +251,8 @@ export function L1WalkTreeVariant({ session, onSessionUpdate, onDone }: Props) {
<ol className="space-y-3 text-sm">
{session.walked_path.map((step, i) => (
<li key={i} className="flex flex-col">
<span className="text-muted-foreground text-xs">{step.question}</span>
<span className="font-medium"> {step.answer}</span>
<span className="text-muted-foreground text-xs">{step.question ?? step.text}</span>
{step.answer && <span className="font-medium"> {step.answer}</span>}
{step.l1_note && <span className="text-muted-foreground text-xs italic mt-0.5">{step.l1_note}</span>}
</li>
))}

View File

@@ -5,13 +5,18 @@ import { Spinner } from '@/components/common/Spinner'
interface ProtectedRouteProps {
requiredRole?: EffectiveRole
// Gate on account-management capability (owner OR account-admin OR super_admin),
// mirroring backend require_account_owner_or_admin. Use instead of
// requiredRole="owner" when account admins must also pass — the role hierarchy
// has no 'admin' rung, so requiredRole alone wrongly bounces admins.
requireAccountManager?: boolean
children: React.ReactNode
}
export function ProtectedRoute({ requiredRole, children }: ProtectedRouteProps) {
export function ProtectedRoute({ requiredRole, requireAccountManager, children }: ProtectedRouteProps) {
const { isAuthenticated, isLoading, user } = useAuthStore()
const location = useLocation()
const { effectiveRole } = usePermissions()
const { effectiveRole, canManageAccount } = usePermissions()
if (isLoading) {
return (
@@ -48,6 +53,10 @@ export function ProtectedRoute({ requiredRole, children }: ProtectedRouteProps)
}
}
if (requireAccountManager && !canManageAccount) {
return <Navigate to="/trees" replace />
}
if (requiredRole) {
const ROLE_HIERARCHY: Record<EffectiveRole, number> = {
super_admin: 5,

View File

@@ -88,7 +88,13 @@ export function usePermissions() {
// Management permissions
canManageCategories: hasMinimumRole(user, 'owner'),
canManageGlobalCategories: effectiveRole === 'super_admin',
canManageAccount: effectiveRole === 'super_admin' || effectiveRole === 'owner',
// Mirrors backend User.can_manage_account (super_admin OR owner OR admin).
// account_role 'admin' isn't in the effectiveRole hierarchy, so check it
// directly — otherwise account admins map to 'viewer' and are wrongly excluded.
canManageAccount:
effectiveRole === 'super_admin' ||
effectiveRole === 'owner' ||
user?.account_role === 'admin',
canManageScriptTemplate: (template: { created_by: string | null; team_id?: string | null }) => {
if (!user) return false

View File

@@ -1,13 +1,14 @@
import { useState } from 'react'
import { AlertTriangle } from 'lucide-react'
import { EscalationQueue, EscalationMetricCard } from '@/components/flowpilot'
import { L1EscalationsSection } from '@/components/l1/L1EscalationsSection'
export default function EscalationQueuePage() {
const [count, setCount] = useState<number | null>(null)
return (
<div className="mx-auto max-w-4xl p-6">
<div className="flex items-center gap-3 mb-6">
<div className="mx-auto max-w-4xl p-6 space-y-6">
<div className="flex items-center gap-3">
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-warning-dim">
<AlertTriangle size={16} className="text-warning" />
</span>
@@ -24,6 +25,10 @@ export default function EscalationQueuePage() {
<EscalationMetricCard period="30d" />
<EscalationQueue onCountChange={setCount} />
{/* L1 AI-build handoffs (GET /l1/escalations). Renders nothing when empty,
so engineers without L1 escalations see no change. */}
<L1EscalationsSection />
</div>
)
}

View File

@@ -6,7 +6,7 @@ import { l1Api } from '@/api/l1'
import { toast } from '@/lib/toast'
import { EmptyStateCard } from '@/components/l1/EmptyStateCard'
import { ResumeInProgress } from '@/components/l1/ResumeInProgress'
import type { NearMiss, QueueRow } from '@/types/l1'
import type { IntakeRequest, NearMiss, QueueRow } from '@/types/l1'
export default function L1Dashboard() {
const user = useAuthStore((s) => s.user)
@@ -44,23 +44,42 @@ export default function L1Dashboard() {
setOutOfScope(null)
}
const handleStart = async () => {
// Single intake entry point — `opts` selects the variant:
// {} → normal match-or-build
// { flow_id } → "Use this flow" (bypass matcher, walk that flow)
// { force_build: true } → "Build new" (skip match, still category-gated)
// { adhoc: true } → out-of-scope "Walk it ad-hoc"
// Collapsing the old three near-identical handlers removes the drift that let
// "Use this flow" silently re-suggest forever (it never passed the flow_id).
const runIntake = async (opts: Partial<IntakeRequest> = {}) => {
if (!problem.trim()) return
setSubmitting(true)
resetPrompts()
try {
// Phase 2A: intake dispatches via match_or_build and returns an `outcome`.
const response = await l1Api.intake({
problem_statement: problem.trim(),
customer_name: customerName.trim() || undefined,
customer_contact: customerContact.trim() || undefined,
...opts,
})
if (response.outcome === 'matched' || response.outcome === 'build') {
navigate(`/l1/walk/${response.session_id}`)
} else if (response.outcome === 'suggest') {
setSuggestion(response.near_miss ?? null)
} else if (response.outcome === 'out_of_scope') {
setOutOfScope(response.category ?? 'unknown')
switch (response.outcome) {
case 'matched':
case 'build':
case 'adhoc':
if (response.session_id) {
navigate(`/l1/walk/${response.session_id}`)
} else {
// Backend guarantees session_id on these outcomes; guard so a
// regression never navigates to /l1/walk/undefined.
toast.error('Walk started but no session was returned. Try again.')
}
break
case 'suggest':
setSuggestion(response.near_miss ?? null)
break
case 'out_of_scope':
setOutOfScope(response.category ?? 'unknown')
break
}
} catch (err) {
const detail = (err as { response?: { data?: { detail?: string } } }).response?.data?.detail
@@ -72,47 +91,14 @@ export default function L1Dashboard() {
}
}
// "Use this flow" — re-run intake with the same text; it matches again and
// returns a `matched` outcome with a started flow session (acceptable Phase 2A).
const useSuggestedFlow = async () => {
setSubmitting(true)
try {
const response = await l1Api.intake({
problem_statement: problem.trim(),
customer_name: customerName.trim() || undefined,
customer_contact: customerContact.trim() || undefined,
})
if (response.session_id) navigate(`/l1/walk/${response.session_id}`)
else resetPrompts()
} catch {
toast.error('Could not start the matched flow. Try again.')
} finally {
setSubmitting(false)
}
}
// "Build new" — skip the match pass (force_build); still gated by enabled categories.
const buildNew = async () => {
setSubmitting(true)
resetPrompts()
try {
const response = await l1Api.intake({
problem_statement: problem.trim(),
customer_name: customerName.trim() || undefined,
customer_contact: customerContact.trim() || undefined,
force_build: true,
})
if (response.outcome === 'build' && response.session_id) {
navigate(`/l1/walk/${response.session_id}`)
} else if (response.outcome === 'out_of_scope') {
setOutOfScope(response.category ?? 'unknown')
}
} catch {
toast.error('Failed to start walk. Try again.')
} finally {
setSubmitting(false)
}
}
const handleStart = () => runIntake()
// "Use this flow" — pass the near-miss flow_id so intake walks it directly
// (the matcher can't reliably re-derive the same flow from the same text).
const useSuggestedFlow = () => runIntake({ flow_id: suggestion?.flow_id })
// "Build new" — skip the match pass (force_build); still gated by categories.
const buildNew = () => runIntake({ force_build: true })
// "Walk it ad-hoc" — out-of-scope fallback: a free-form walk (no AI tree).
const walkAdhoc = () => runIntake({ adhoc: true })
// out-of-scope fallback: escalate straight to engineering (no walk).
const escalateOutOfScope = async () => {
@@ -272,14 +258,23 @@ export default function L1Dashboard() {
<p className="text-sm text-primary">
This problem isnt in your accounts enabled L1 categories
{outOfScope !== 'unknown' ? ` (${outOfScope.replace(/_/g, ' ')})` : ''}, so
theres no AI-built walk for it. You can escalate it to engineering.
theres no AI-built walk for it. You can still walk it ad-hoc (free-form
notes, no AI tree), or escalate it to engineering.
</p>
<div className="flex gap-2">
<button
type="button"
onClick={escalateOutOfScope}
onClick={walkAdhoc}
disabled={submitting}
className="rounded-md bg-accent text-white px-4 py-2 text-sm font-medium hover:bg-accent/90 transition-colors disabled:opacity-50"
>
Walk it ad-hoc
</button>
<button
type="button"
onClick={escalateOutOfScope}
disabled={submitting}
className="rounded-md border border-default px-4 py-2 text-sm hover:bg-elevated transition-colors disabled:opacity-50"
>
Escalate to engineering
</button>

View File

@@ -367,7 +367,7 @@ export const router = sentryCreateBrowserRouter([
{
path: 'l1-categories',
element: (
<ProtectedRoute requiredRole="owner">
<ProtectedRoute requireAccountManager>
{page(L1CategoriesPage)}
</ProtectedRoute>
),

View File

@@ -3,9 +3,15 @@ export type SessionStatus = 'active' | 'resolved' | 'escalated' | 'abandoned'
export type TicketKind = 'psa' | 'internal'
export interface WalkStep {
node_id: string
question: string
answer: string
// Two shapes coexist (segregated by session_kind): legacy flow/adhoc steps use
// node_id + question; ai_build steps use id + node_type + text. Render with
// `step.question ?? step.text`.
node_id?: string
id?: string
node_type?: string
question?: string
text?: string
answer: string | null
l1_note: string | null
}
@@ -17,6 +23,8 @@ export interface AdhocNote {
export interface WalkSession {
id: string
session_kind: SessionKind
category: string | null
problem_text: string | null
flow_id: string | null
flow_proposal_id: string | null
current_node_id: string | null
@@ -42,10 +50,11 @@ export interface IntakeRequest {
customer_name?: string
customer_contact?: string
flow_id?: string
adhoc?: boolean
force_build?: boolean
}
export type IntakeOutcome = 'matched' | 'suggest' | 'out_of_scope' | 'build'
export type IntakeOutcome = 'matched' | 'suggest' | 'out_of_scope' | 'build' | 'adhoc'
export interface NearMiss {
flow_id: string
@@ -77,8 +86,7 @@ export type TreeNode =
export interface NextNodeRequest {
node_id?: string
node_text?: string // rendered text of the node being answered
answer?: 'yes' | 'no'
acknowledged?: boolean
answer?: 'yes' | 'no' // omit to acknowledge an instruction node
note?: string
}

View File

@@ -9,7 +9,7 @@ export interface User {
is_active: boolean
must_change_password: boolean
account_id: string | null
account_role: 'owner' | 'engineer' | 'l1_tech' | 'viewer' | null
account_role: 'owner' | 'admin' | 'engineer' | 'l1_tech' | 'viewer' | null
can_cover_l1: boolean
team_id: string | null
created_at: string