The session variant that Phase 1 L1 users actually hit (intake creates adhoc sessions when no flow_id is provided). Single-pane note-taking surface with 300ms-debounced autosave to walk_notes. Shares header shape + Resolve/Escalate modals with the tree variant. Splits the notes textarea by paragraph and persists each as a structured AdhocNote entry. Stops saving once status leaves 'active'. L1WalkPage now dispatches both variants. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
157 lines
5.7 KiB
TypeScript
157 lines
5.7 KiB
TypeScript
import { useEffect, useRef, useState } from 'react'
|
|
import { ChevronLeft } from 'lucide-react'
|
|
import { Link } from 'react-router-dom'
|
|
import { l1Api } from '@/api/l1'
|
|
import type { AdhocNote, WalkSession } from '@/types/l1'
|
|
import { EscalateModal, ResolveModal } from '@/components/l1/WalkModals'
|
|
|
|
interface Props {
|
|
session: WalkSession
|
|
onSessionUpdate: (s: WalkSession) => void
|
|
onDone: () => void
|
|
}
|
|
|
|
export function L1WalkAdhocVariant({ session, onSessionUpdate, onDone }: Props) {
|
|
const [showResolve, setShowResolve] = useState(false)
|
|
const [showEscalate, setShowEscalate] = useState(false)
|
|
// Show prior notes as joined paragraphs so the L1 sees an editable timeline.
|
|
const [notesText, setNotesText] = useState(() =>
|
|
session.walk_notes.map((n) => n.content).join('\n\n')
|
|
)
|
|
const [savedAt, setSavedAt] = useState<Date | null>(null)
|
|
const [saving, setSaving] = useState(false)
|
|
const saveTimer = useRef<number | null>(null)
|
|
|
|
// Debounced autosave: 300ms after the last keystroke, send to the backend.
|
|
useEffect(() => {
|
|
if (session.status !== 'active') return
|
|
if (saveTimer.current) window.clearTimeout(saveTimer.current)
|
|
saveTimer.current = window.setTimeout(async () => {
|
|
// Split paragraphs into structured notes. Empty paragraphs are skipped.
|
|
const parts = notesText
|
|
.split('\n\n')
|
|
.map((c) => c.trim())
|
|
.filter(Boolean)
|
|
const notes: AdhocNote[] = parts.map((content) => ({
|
|
timestamp: new Date().toISOString(),
|
|
content,
|
|
}))
|
|
try {
|
|
setSaving(true)
|
|
const updated = await l1Api.notes(session.id, notes)
|
|
onSessionUpdate(updated)
|
|
setSavedAt(new Date())
|
|
} catch (err) {
|
|
console.error('notes save failed:', err)
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}, 300)
|
|
return () => {
|
|
if (saveTimer.current) window.clearTimeout(saveTimer.current)
|
|
}
|
|
}, [notesText, session.id, session.status, onSessionUpdate])
|
|
|
|
const savedAgo = savedAt ? Math.max(1, Math.round((Date.now() - savedAt.getTime()) / 1000)) : null
|
|
|
|
return (
|
|
<div className="flex flex-col h-full">
|
|
{/* Header */}
|
|
<header className="border-b border-default px-6 py-4 flex items-center justify-between bg-sidebar">
|
|
<Link
|
|
to="/l1"
|
|
className="flex items-center gap-2 text-muted-foreground hover:text-heading transition-colors"
|
|
>
|
|
<ChevronLeft className="w-4 h-4" />
|
|
<span className="font-mono text-xs">#{session.id.slice(0, 8)}</span>
|
|
<span className="ml-2 text-xs bg-info/10 text-info px-2 py-0.5 rounded">Ad-hoc walk</span>
|
|
</Link>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={() => setShowEscalate(true)}
|
|
disabled={session.status !== 'active'}
|
|
className="rounded-md border border-default px-3 py-1.5 text-sm hover:bg-elevated transition-colors disabled:opacity-50"
|
|
>
|
|
Escalate
|
|
</button>
|
|
<button
|
|
onClick={() => setShowResolve(true)}
|
|
disabled={session.status !== 'active'}
|
|
className="rounded-md bg-accent text-white px-3 py-1.5 text-sm hover:bg-accent/90 transition-colors disabled:opacity-50"
|
|
>
|
|
Resolve ✓
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
{/* Single-pane body */}
|
|
<main className="flex-1 p-6 overflow-y-auto min-h-0">
|
|
<div className="max-w-3xl mx-auto">
|
|
{session.status !== 'active' ? (
|
|
<div className="rounded-lg border border-default bg-card p-6">
|
|
<p className="text-sm text-muted-foreground">
|
|
This session is <span className="font-semibold">{session.status}</span>.
|
|
</p>
|
|
<button
|
|
onClick={onDone}
|
|
className="mt-3 rounded-md bg-accent text-white px-3 py-1.5 text-sm hover:bg-accent/90 transition-colors"
|
|
>
|
|
Back to workspace
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<p className="text-sm text-muted-foreground mb-3">
|
|
Take notes as you work through the call. They're auto-saved.
|
|
</p>
|
|
<textarea
|
|
value={notesText}
|
|
onChange={(e) => setNotesText(e.target.value)}
|
|
rows={20}
|
|
placeholder="What did the customer say? What did you check? What did you try?"
|
|
className="w-full bg-card border border-default rounded-md px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-accent/40 leading-relaxed font-sans"
|
|
/>
|
|
<p className="text-xs text-muted-foreground mt-2">
|
|
{saving
|
|
? 'Saving…'
|
|
: savedAgo !== null
|
|
? `Saved ${savedAgo}s ago`
|
|
: 'Not yet saved'}
|
|
</p>
|
|
</>
|
|
)}
|
|
</div>
|
|
</main>
|
|
|
|
{/* Modals */}
|
|
{showResolve && (
|
|
<ResolveModal
|
|
defaultNotes={notesText}
|
|
onClose={() => setShowResolve(false)}
|
|
onConfirm={async (helpful, resolutionNotes) => {
|
|
try {
|
|
await l1Api.resolve(session.id, { helpful, resolution_notes: resolutionNotes })
|
|
onDone()
|
|
} catch (err) {
|
|
console.error('resolve failed:', err)
|
|
}
|
|
}}
|
|
/>
|
|
)}
|
|
{showEscalate && (
|
|
<EscalateModal
|
|
onClose={() => setShowEscalate(false)}
|
|
onConfirm={async (category, reason) => {
|
|
try {
|
|
await l1Api.escalate(session.id, { reason, reason_category: category })
|
|
onDone()
|
|
} catch (err) {
|
|
console.error('escalate failed:', err)
|
|
}
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|