diff --git a/frontend/src/components/flowpilot/ProposalDetail.tsx b/frontend/src/components/flowpilot/ProposalDetail.tsx
index 358310fd..530c9ae1 100644
--- a/frontend/src/components/flowpilot/ProposalDetail.tsx
+++ b/frontend/src/components/flowpilot/ProposalDetail.tsx
@@ -88,18 +88,35 @@ export function ProposalDetail({ proposal, onReview }: ProposalDetailProps) {
{/* Content */}
- {/* Source session link */}
-
-
Source Session
-
-
- View session that generated this proposal
-
-
+ {/* 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 ? (
+
+
Source Session
+
+
+ View session that generated this proposal
+
+
+ ) : proposal.l1_session_id ? (
+
+
Source — L1 AI walkthrough
+
+ 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.
+
+
+
+ L1 session {proposal.l1_session_id.slice(0, 8)}
+
+
+ ) : null}
{/* Proposed diff (for enhancements) */}
{proposal.proposed_diff && (() => {
diff --git a/frontend/src/components/l1/L1EscalationsSection.tsx b/frontend/src/components/l1/L1EscalationsSection.tsx
index 5640a7e8..97a7ee1f 100644
--- a/frontend/src/components/l1/L1EscalationsSection.tsx
+++ b/frontend/src/components/l1/L1EscalationsSection.tsx
@@ -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() {
#{s.id.slice(0, 8)}
- {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`}
- {new Date(s.last_step_at).toLocaleString()}
+ {timeAgo(s.last_step_at)}
{isOpen && (
@@ -58,7 +61,7 @@ export function L1EscalationsSection() {
{s.walked_path.map((step, i) => (
- {step.question}
+ {step.question ?? step.text}
{step.answer && (
→ {step.answer}
)}
diff --git a/frontend/src/components/l1/L1WalkTreeVariant.tsx b/frontend/src/components/l1/L1WalkTreeVariant.tsx
index 7d464284..e887cea8 100644
--- a/frontend/src/components/l1/L1WalkTreeVariant.tsx
+++ b/frontend/src/components/l1/L1WalkTreeVariant.tsx
@@ -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) {
<>
{node.text}
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) {
{session.walked_path.map((step, i) => (
- {step.question}
- → {step.answer}
+ {step.question ?? step.text}
+ {step.answer && → {step.answer} }
{step.l1_note && {step.l1_note} }
))}
diff --git a/frontend/src/components/layout/ProtectedRoute.tsx b/frontend/src/components/layout/ProtectedRoute.tsx
index 8b364518..879c5342 100644
--- a/frontend/src/components/layout/ProtectedRoute.tsx
+++ b/frontend/src/components/layout/ProtectedRoute.tsx
@@ -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
+ }
+
if (requiredRole) {
const ROLE_HIERARCHY: Record = {
super_admin: 5,
diff --git a/frontend/src/hooks/usePermissions.ts b/frontend/src/hooks/usePermissions.ts
index 34f39bfe..235d7948 100644
--- a/frontend/src/hooks/usePermissions.ts
+++ b/frontend/src/hooks/usePermissions.ts
@@ -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
diff --git a/frontend/src/pages/EscalationQueuePage.tsx b/frontend/src/pages/EscalationQueuePage.tsx
index 5ae5a20e..5382810d 100644
--- a/frontend/src/pages/EscalationQueuePage.tsx
+++ b/frontend/src/pages/EscalationQueuePage.tsx
@@ -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(null)
return (
-
-
+
+
@@ -24,6 +25,10 @@ export default function EscalationQueuePage() {
+
+ {/* L1 AI-build handoffs (GET /l1/escalations). Renders nothing when empty,
+ so engineers without L1 escalations see no change. */}
+
)
}
diff --git a/frontend/src/pages/l1/L1Dashboard.tsx b/frontend/src/pages/l1/L1Dashboard.tsx
index a7cc9895..573bf242 100644
--- a/frontend/src/pages/l1/L1Dashboard.tsx
+++ b/frontend/src/pages/l1/L1Dashboard.tsx
@@ -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
= {}) => {
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() {
This problem isn’t in your account’s enabled L1 categories
{outOfScope !== 'unknown' ? ` (${outOfScope.replace(/_/g, ' ')})` : ''}, so
- there’s no AI-built walk for it. You can escalate it to engineering.
+ there’s no AI-built walk for it. You can still walk it ad-hoc (free-form
+ notes, no AI tree), or escalate it to engineering.
+ Walk it ad-hoc
+
+
Escalate to engineering
diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx
index 61f07f00..c92bbbce 100644
--- a/frontend/src/router.tsx
+++ b/frontend/src/router.tsx
@@ -367,7 +367,7 @@ export const router = sentryCreateBrowserRouter([
{
path: 'l1-categories',
element: (
-
+
{page(L1CategoriesPage)}
),
diff --git a/frontend/src/types/l1.ts b/frontend/src/types/l1.ts
index ea74a577..69fa25e5 100644
--- a/frontend/src/types/l1.ts
+++ b/frontend/src/types/l1.ts
@@ -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
}
diff --git a/frontend/src/types/user.ts b/frontend/src/types/user.ts
index df756e4e..e26e510a 100644
--- a/frontend/src/types/user.ts
+++ b/frontend/src/types/user.ts
@@ -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