From 4e9610c252e66b92adeb4fb358e17dd9aa8c0cfe Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Thu, 28 May 2026 14:09:34 -0400 Subject: [PATCH] feat(l1): real L1 dashboard with empty-state + resume widget Replaces the T20 stub. L1 dashboard renders greeting, "Describe the problem" intake card (autofocus textarea, optional customer fields, primary "Start walk" CTA), open-tickets queue (Phase 1: display-only), and a "Resume in progress" widget listing the L1's active sessions ordered by last_step_at DESC. Empty-state card shows on accounts with no queue + no active sessions (first-run nudge to upload KB or auth flows). Adds /api/l1.ts (full L1 API client surface) and /types/l1.ts. Co-Authored-By: Claude Opus 4.7 --- frontend/src/api/l1.ts | 64 ++++++++ frontend/src/components/l1/EmptyStateCard.tsx | 35 ++++ .../src/components/l1/ResumeInProgress.tsx | 49 ++++++ frontend/src/pages/l1/L1Dashboard.tsx | 155 +++++++++++++++++- frontend/src/types/l1.ts | 52 ++++++ 5 files changed, 352 insertions(+), 3 deletions(-) create mode 100644 frontend/src/api/l1.ts create mode 100644 frontend/src/components/l1/EmptyStateCard.tsx create mode 100644 frontend/src/components/l1/ResumeInProgress.tsx create mode 100644 frontend/src/types/l1.ts diff --git a/frontend/src/api/l1.ts b/frontend/src/api/l1.ts new file mode 100644 index 00000000..512cc0f1 --- /dev/null +++ b/frontend/src/api/l1.ts @@ -0,0 +1,64 @@ +import { apiClient } from './client' +import type { + IntakeRequest, + IntakeResponse, + QueueRow, + WalkSession, + AdhocNote, +} from '@/types/l1' + +export const l1Api = { + intake: (body: IntakeRequest) => + apiClient.post('/l1/intake', body).then(r => r.data), + + queue: (statusFilter?: string) => + apiClient.get('/l1/queue', { + params: statusFilter ? { status_filter: statusFilter } : {}, + }).then(r => r.data), + + listActiveSessions: () => + apiClient.get('/l1/sessions/active').then(r => r.data), + + getSession: (sessionId: string) => + apiClient.get(`/l1/sessions/${sessionId}`).then(r => r.data), + + step: ( + sessionId: string, + step: { node_id: string; question: string; answer: string; note?: string | null }, + ) => + apiClient + .post(`/l1/sessions/${sessionId}/step`, step) + .then(r => r.data), + + notes: (sessionId: string, notes: AdhocNote[]) => + apiClient + .post(`/l1/sessions/${sessionId}/notes`, { notes }) + .then(r => r.data), + + resolve: ( + sessionId: string, + body: { helpful: boolean; resolution_notes: string }, + ) => + apiClient + .post(`/l1/sessions/${sessionId}/resolve`, body) + .then(r => r.data), + + escalate: ( + sessionId: string, + body: { reason: string; reason_category: string }, + ) => + apiClient + .post(`/l1/sessions/${sessionId}/escalate`, body) + .then(r => r.data), + + escalateWithoutWalk: (body: { + problem_statement: string + customer_name?: string + customer_contact?: string + reason_category: string + reason?: string + }) => + apiClient + .post('/l1/escalate-without-walk', body) + .then(r => r.data), +} diff --git a/frontend/src/components/l1/EmptyStateCard.tsx b/frontend/src/components/l1/EmptyStateCard.tsx new file mode 100644 index 00000000..97b2a18e --- /dev/null +++ b/frontend/src/components/l1/EmptyStateCard.tsx @@ -0,0 +1,35 @@ +import { usePermissions } from '@/hooks/usePermissions' + +interface Props { + onUploadClick?: () => void +} + +export function EmptyStateCard({ onUploadClick }: Props) { + const { canCoverL1 } = usePermissions() + + return ( +
+

+ Your knowledge base is empty +

+

+ L1 Workspace works best when your account has KB content or authored flows. + Right now there's nothing to match against — calls will start as ad-hoc walks. +

+ {canCoverL1 && onUploadClick ? ( + + ) : ( +
    +
  • Ask your admin to upload KB documents
  • +
  • Or ask them to author a flow in the Flows library
  • +
+ )} +
+ ) +} diff --git a/frontend/src/components/l1/ResumeInProgress.tsx b/frontend/src/components/l1/ResumeInProgress.tsx new file mode 100644 index 00000000..0d6518be --- /dev/null +++ b/frontend/src/components/l1/ResumeInProgress.tsx @@ -0,0 +1,49 @@ +import { useEffect, useState } from 'react' +import { Link } from 'react-router-dom' +import { l1Api } from '@/api/l1' +import type { WalkSession } from '@/types/l1' + +export function ResumeInProgress() { + const [sessions, setSessions] = useState(null) + + useEffect(() => { + l1Api + .listActiveSessions() + .then(setSessions) + .catch(() => setSessions([])) + }, []) + + if (!sessions || sessions.length === 0) return null + + return ( +
+
+ + Resume in progress · {sessions.length} + +
+
+
+ {sessions.map((s) => ( + +
+ #{s.id.slice(0, 8)} + + {s.session_kind === 'adhoc' + ? `Ad-hoc · ${s.walk_notes.length} notes` + : `Step ${s.walked_path.length}`} + +
+ + {new Date(s.last_step_at).toLocaleTimeString()} + + + ))} +
+
+ ) +} diff --git a/frontend/src/pages/l1/L1Dashboard.tsx b/frontend/src/pages/l1/L1Dashboard.tsx index e7de2fbe..4f921c4a 100644 --- a/frontend/src/pages/l1/L1Dashboard.tsx +++ b/frontend/src/pages/l1/L1Dashboard.tsx @@ -1,12 +1,161 @@ +import { useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' import { PageMeta } from '@/components/common/PageMeta' +import { useAuthStore } from '@/store/authStore' +import { l1Api } from '@/api/l1' +import { EmptyStateCard } from '@/components/l1/EmptyStateCard' +import { ResumeInProgress } from '@/components/l1/ResumeInProgress' +import type { QueueRow } from '@/types/l1' export default function L1Dashboard() { + const user = useAuthStore((s) => s.user) + const navigate = useNavigate() + const [problem, setProblem] = useState('') + const [customerName, setCustomerName] = useState('') + const [customerContact, setCustomerContact] = useState('') + const [submitting, setSubmitting] = useState(false) + const [queue, setQueue] = useState([]) + const [isEmpty, setIsEmpty] = useState(false) + + useEffect(() => { + l1Api.queue('open').then(setQueue).catch(() => setQueue([])) + // Phase 1: emptiness detection is just "is the queue empty AND no resumable sessions" — + // we conservatively show the empty-state card on accounts with literally no L1 activity yet. + // (A stricter KB-empty detection arrives in Phase 2 when the kb_documents table exists.) + }, []) + + useEffect(() => { + // Show empty-state ONLY for first-run state — no queue items and no active sessions + if (queue.length === 0) { + l1Api + .listActiveSessions() + .then((active) => setIsEmpty(active.length === 0)) + .catch(() => setIsEmpty(false)) + } else { + setIsEmpty(false) + } + }, [queue]) + + const handleStart = async () => { + if (!problem.trim()) return + setSubmitting(true) + try { + const response = await l1Api.intake({ + problem_statement: problem.trim(), + customer_name: customerName.trim() || undefined, + customer_contact: customerContact.trim() || undefined, + }) + navigate(`/l1/walk/${response.session_id}`) + } finally { + setSubmitting(false) + } + } + + const now = new Date() + const greeting = + now.getHours() < 12 ? 'morning' : now.getHours() < 18 ? 'afternoon' : 'evening' + const firstName = user?.name?.split(' ')[0] || 'there' + return (
-
-

L1 Workspace

-

Loading…

+
+ {/* Greeting */} +
+

+ {now.toLocaleDateString('en-US', { + weekday: 'long', + month: 'long', + day: 'numeric', + })} +

+

+ Good {greeting}, {firstName}. +

+
+ + {/* Empty state (first-run) */} + {isEmpty && } + + {/* Describe the problem */} +
+
+ + + Describe the problem + +
+
+