All checks were successful
Mirror to GitHub / mirror (push) Successful in 11s
Wires the preview popover's Confirm & post action to ConnectWise (and,
via the provider pattern, any future PSA). Adds the parallel Escalate
flow with the handoff-oriented five-section markdown. Sessions without a
linked PSA ticket resolve/escalate locally — markdown stored, status
flipped, nothing posted externally.
Backend:
- EscalationPackageGeneratorService: Sonnet, five sections (Problem /
What we've confirmed / What we've tried / Current hypothesis /
Suggested next steps). Shares the preview_cache with a separate KIND
so Resolve and Escalate previews for the same state coexist.
- PSAWritebackService: post_resolution_note (RESOLUTION note type,
customer-visible), post_escalation_package (INTERNAL_ANALYSIS,
handoff for the next engineer only), transition_ticket_status with
mandatory re-fetch verification. PSAStatusVerificationError surfaces
loudly when CW silently rejects a status change — the
ConnectWise anti-pattern CLAUDE.md flags.
- Endpoints:
* POST /ai-sessions/{id}/escalation-package/preview
* POST /ai-sessions/{id}/resolution-note/post
* POST /ai-sessions/{id}/escalation-package/post
Outcomes: "resolved" / "escalated" with external_id + verified status,
"resolved_local" / "escalated_local" when no PSA linked.
- Target CW status IDs live in account_settings.preferences
(cw_resolved_status_id, cw_escalated_status_id). When unset, the post
proceeds without a status transition — response includes a
status_transition_skipped_reason rather than silently erroring.
- 7 tests: local-only path, PSA happy path with verified transition,
status verification failure → 502, skipped transition when
unconfigured, 409 on already-resolved re-post, escalate parallel path,
internal-analysis note type enforced.
Frontend:
- ResolutionNotePreview now kind-parameterized ('resolve' | 'escalate')
with inline edit + Confirm & post. Preview loads from the matching
backend endpoint; posting calls the matching endpoint; outcome toast
surfaces the verified CW status or the local-only result.
- AssistantChatPage: previewKind state replaces previewOpen; two toggle
buttons (Preview Resolve note / Escalate instead) in the lane's bottom
slot. handleConfirmPost dispatches by kind.
Verified 2026-04-22:
- Local-only Resolve + Escalate round-trip against the dev stack.
- Live Sonnet escalation-package preview; cache hit on repeat call
with no state change (separate cache kind from resolution-note).
- PSA post + status-verification paths covered by mocked-provider pytest
cases. Live CW round-trip pending a test CW instance.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
186 lines
6.8 KiB
TypeScript
186 lines
6.8 KiB
TypeScript
/**
|
|
* ResolutionNotePreview — Phase 3/4 popover for the Resolve AND Escalate flows.
|
|
*
|
|
* Persistent (not modal) popover showing the draft markdown that would be
|
|
* posted to the customer ticket on Confirm. Phase 3 was read-only; Phase 4
|
|
* adds inline editing + a "Confirm & post" action that writes to PSA and
|
|
* transitions the session state.
|
|
*
|
|
* Kind switches the labels, button colors, and confirm-CTA text — the
|
|
* underlying mechanics (preview fetch + edit + post) are identical.
|
|
*/
|
|
import { useState, useEffect } from 'react'
|
|
import { Loader2, RefreshCw, X, FileText, Pencil, Check, ArrowUpRight } from 'lucide-react'
|
|
import { MarkdownContent } from '@/components/ui/MarkdownContent'
|
|
import { cn } from '@/lib/utils'
|
|
import type { ResolutionNotePreview as PreviewData } from '@/api/sessionSuggestedFixes'
|
|
|
|
export type PreviewKind = 'resolve' | 'escalate'
|
|
|
|
interface ResolutionNotePreviewProps {
|
|
kind: PreviewKind
|
|
open: boolean
|
|
loading: boolean
|
|
preview: PreviewData | null
|
|
error: string | null
|
|
onClose: () => void
|
|
onRefresh: () => Promise<void> | void
|
|
onConfirm: (markdown: string) => Promise<void>
|
|
posting: boolean
|
|
}
|
|
|
|
export function ResolutionNotePreview({
|
|
kind,
|
|
open,
|
|
loading,
|
|
preview,
|
|
error,
|
|
onClose,
|
|
onRefresh,
|
|
onConfirm,
|
|
posting,
|
|
}: ResolutionNotePreviewProps) {
|
|
const [refreshing, setRefreshing] = useState(false)
|
|
const [editing, setEditing] = useState(false)
|
|
const [draft, setDraft] = useState('')
|
|
|
|
// Keep the draft textarea in sync whenever fresh markdown arrives and we
|
|
// aren't in the middle of editing. Once the engineer edits, their changes
|
|
// win — we don't blow them away on a refetch.
|
|
useEffect(() => {
|
|
if (!editing && preview?.markdown) {
|
|
setDraft(preview.markdown)
|
|
}
|
|
}, [preview?.markdown, editing])
|
|
|
|
if (!open) return null
|
|
|
|
const label = kind === 'resolve' ? 'Resolution note' : 'Escalation handoff package'
|
|
const confirmLabel = kind === 'resolve' ? 'Confirm & post to PSA' : 'Confirm & escalate'
|
|
const confirmButtonTone = kind === 'resolve'
|
|
? 'bg-success text-bg-page hover:bg-success/90'
|
|
: 'bg-warning text-bg-page hover:bg-warning/90'
|
|
const KindIcon = kind === 'resolve' ? FileText : ArrowUpRight
|
|
|
|
const handleRefresh = async () => {
|
|
setRefreshing(true)
|
|
try { await onRefresh() } finally { setRefreshing(false) }
|
|
}
|
|
|
|
const handleConfirm = async () => {
|
|
if (!draft.trim()) return
|
|
await onConfirm(draft)
|
|
}
|
|
|
|
return (
|
|
<div className="rounded-lg border border-default bg-elevated/30 mx-3 mb-3 overflow-hidden shadow-lg">
|
|
<div className="flex items-center justify-between px-3 py-2 border-b border-default bg-bg-page">
|
|
<div className="flex items-center gap-2">
|
|
<KindIcon size={13} className={kind === 'resolve' ? 'text-success' : 'text-warning'} />
|
|
<span className="text-[0.75rem] font-semibold text-heading">
|
|
{label} preview
|
|
</span>
|
|
{preview?.target_ticket_ref && (
|
|
<span className="text-[0.6875rem] font-mono text-accent-text">
|
|
→ {preview.target_ticket_ref}
|
|
</span>
|
|
)}
|
|
{!preview?.target_ticket_ref && (
|
|
<span className="text-[0.6875rem] italic text-muted-foreground">
|
|
local only · no PSA ticket linked
|
|
</span>
|
|
)}
|
|
{preview?.from_cache && (
|
|
<span className="text-[0.6875rem] text-muted-foreground italic">cached</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
{!editing && preview && (
|
|
<button
|
|
onClick={() => setEditing(true)}
|
|
className="p-1 rounded text-muted-foreground hover:text-heading hover:bg-elevated/40 transition-colors"
|
|
title="Edit before posting"
|
|
>
|
|
<Pencil size={11} />
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={handleRefresh}
|
|
disabled={refreshing || loading || editing || posting}
|
|
className="p-1 rounded text-muted-foreground hover:text-heading hover:bg-elevated/40 transition-colors disabled:opacity-40"
|
|
title="Refresh preview"
|
|
>
|
|
{refreshing ? <Loader2 size={11} className="animate-spin" /> : <RefreshCw size={11} />}
|
|
</button>
|
|
<button
|
|
onClick={onClose}
|
|
disabled={posting}
|
|
className="p-1 rounded text-muted-foreground hover:text-heading hover:bg-elevated/40 transition-colors"
|
|
title="Close preview"
|
|
>
|
|
<X size={11} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="px-3 py-3 max-h-[40vh] overflow-y-auto">
|
|
{loading && !preview && (
|
|
<div className="flex items-center gap-2 text-[0.75rem] text-muted-foreground">
|
|
<Loader2 size={12} className="animate-spin" />
|
|
Drafting {label.toLowerCase()} from session state...
|
|
</div>
|
|
)}
|
|
{error && <div className="text-[0.75rem] text-danger">{error}</div>}
|
|
{preview && editing && (
|
|
<textarea
|
|
value={draft}
|
|
onChange={(e) => setDraft(e.target.value)}
|
|
className="w-full rounded-md border border-default bg-input px-2.5 py-2 text-[0.8125rem] text-heading font-mono resize-y min-h-[240px] max-h-[40vh] focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30"
|
|
/>
|
|
)}
|
|
{preview && !editing && (
|
|
<div className="prose prose-invert prose-sm max-w-none text-[0.8125rem] leading-relaxed">
|
|
<MarkdownContent content={draft || preview.markdown} />
|
|
</div>
|
|
)}
|
|
{!loading && !error && !preview && (
|
|
<div className="text-[0.75rem] text-muted-foreground italic">
|
|
No preview yet — add a fact or accept a suggested fix to populate.
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{preview && (
|
|
<div className="flex items-center justify-between gap-2 px-3 py-2 border-t border-default bg-bg-page">
|
|
{editing ? (
|
|
<button
|
|
onClick={() => setEditing(false)}
|
|
disabled={posting}
|
|
className="text-[0.75rem] text-muted-foreground hover:text-heading"
|
|
>
|
|
Done editing
|
|
</button>
|
|
) : <span />}
|
|
<button
|
|
onClick={handleConfirm}
|
|
disabled={posting || !draft.trim()}
|
|
className={cn(
|
|
'flex items-center gap-1.5 rounded-md px-3 py-1.5 text-[0.75rem] font-semibold transition-colors',
|
|
confirmButtonTone,
|
|
(posting || !draft.trim()) && 'opacity-50 cursor-not-allowed',
|
|
)}
|
|
>
|
|
{posting ? (
|
|
<><Loader2 size={11} className="animate-spin" /> Posting...</>
|
|
) : (
|
|
<><Check size={11} /> {confirmLabel}</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default ResolutionNotePreview
|