feat(l1): adhoc walker variant with debounced notes autosave
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>
This commit is contained in:
156
frontend/src/components/l1/L1WalkAdhocVariant.tsx
Normal file
156
frontend/src/components/l1/L1WalkAdhocVariant.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { PageMeta } from '@/components/common/PageMeta'
|
||||
import { l1Api } from '@/api/l1'
|
||||
import { L1WalkTreeVariant } from '@/components/l1/L1WalkTreeVariant'
|
||||
import { L1WalkAdhocVariant } from '@/components/l1/L1WalkAdhocVariant'
|
||||
import type { WalkSession } from '@/types/l1'
|
||||
|
||||
export default function L1WalkPage() {
|
||||
@@ -42,16 +43,17 @@ export default function L1WalkPage() {
|
||||
|
||||
const handleDone = () => navigate('/l1')
|
||||
|
||||
// Phase 1: adhoc variant (T23) handles session_kind='adhoc'. Tree variant handles flow/proposal.
|
||||
// For T22, only the tree variant is implemented. Adhoc sessions render a placeholder until T23 lands.
|
||||
// Phase 1: adhoc variant handles session_kind='adhoc'. Tree variant handles flow/proposal.
|
||||
if (session.session_kind === 'adhoc') {
|
||||
return (
|
||||
<div className="overflow-y-auto h-full">
|
||||
<>
|
||||
<PageMeta title="L1 Walk" />
|
||||
<div className="max-w-4xl mx-auto px-6 pt-12 text-muted-foreground">
|
||||
Ad-hoc walker pending (T23).
|
||||
</div>
|
||||
</div>
|
||||
<L1WalkAdhocVariant
|
||||
session={session}
|
||||
onSessionUpdate={setSession}
|
||||
onDone={handleDone}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user