feat(pilot): Phase 6 — post-resolve templatize prompt + draft accept/reject
All checks were successful
Mirror to GitHub / mirror (push) Successful in 11s
All checks were successful
Mirror to GitHub / mirror (push) Successful in 11s
Closes the loop on the Phase 5 "Run now, templatize after resolve" path.
After a session resolves, drafts queued by the three-option dialog surface
as a modal that lets the engineer review the AI-proposed parameterization
and either save as a reusable team template or skip. A "don't ask again"
toggle writes to account_settings.preferences so the next resolve won't
pop the modal.
Backend:
- /api/v1/draft-templates:
* GET — list account drafts (pending_only default true; pass false for
audit view including accepted/rejected)
* GET /{id} — single draft
* POST /{id}/accept — promotes to a new script_templates row with
source_session_id / source_user_id / source_ticket_ref populated
(drives the Script Library "generated from CW #X · resolved by Y"
provenance chip). Draft flips to status=accepted,
promoted_template_id set, resolved_at stamped. 409 on re-accept /
already-rejected. 400 on unknown category_id.
* POST /{id}/reject — flips to status=rejected. 409 on re-reject.
- /api/v1/accounts/me/preferences (GET/PATCH) — thin wrapper over
AccountSettings.get_setting/set_setting. PATCH merges keys into the
JSONB column, preserving existing keys the client didn't touch.
Used by the "Don't ask again for this team" checkbox
(templatize_prompt_enabled=false) and, forward-looking, by
cw_resolved_status_id / cw_escalated_status_id from Phase 4.
- 13 tests: list filter, accept with/without edited_body, provenance
copy-through, reject, 409 on re-accept / re-reject, 400 on unknown
category, prefs round-trip with merge semantics.
Frontend:
- src/components/pilot/script/TemplatizePrompt.tsx — modal showing the
drafted script with proposed parameters in the Phase 5
ParameterizationPreview, editable name/category/description, an
individual-parameter remove button, and the "don't ask again" opt-out.
Accept posts to /draft-templates/{id}/accept + optionally PATCHes
preferences. Skip posts /reject.
- src/api/draftTemplates.ts — typed client plus accountPreferencesApi.
- AssistantChatPage: after a successful Resolve (external OR local),
fetches preferences + pending drafts for the session and queues the
modal one draft at a time. Escalate does not trigger this flow.
- Sidebar: Scripts nav shows the pending-draft count as a badge. Fetched
independently of the main sidebar stats so endpoint flakes don't
break the rest of the sidebar.
Verified live 2026-04-22: seed two drafts → GET sees both pending →
accept draft A (template created, provenance CW #99123 populated) →
reject draft B → pending count drops → PATCH opt-out → GET confirms
persistence.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
98
frontend/src/api/draftTemplates.ts
Normal file
98
frontend/src/api/draftTemplates.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Draft templates API — Phase 6 post-resolve templatization flow.
|
||||
*
|
||||
* A draft is produced when the engineer picks "Run now, templatize after
|
||||
* resolve" on the three-option dialog. After Resolve, the TemplatizePrompt
|
||||
* modal lists pending drafts and lets the engineer accept (→ real
|
||||
* script_templates row) or reject.
|
||||
*
|
||||
* Mirrors backend endpoints under /api/v1/draft-templates.
|
||||
*/
|
||||
import apiClient from './client'
|
||||
|
||||
export type DraftStatus = 'pending' | 'accepted' | 'rejected'
|
||||
|
||||
export interface DraftTemplate {
|
||||
id: string
|
||||
account_id: string
|
||||
source_session_id: string
|
||||
source_user_id: string
|
||||
script_body: string
|
||||
proposed_parameters: { parameters?: Array<Record<string, unknown>> } | Record<string, unknown>
|
||||
proposed_name: string | null
|
||||
proposed_category_id: string | null
|
||||
status: DraftStatus
|
||||
resolved_at: string | null
|
||||
promoted_template_id: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface DraftAcceptRequest {
|
||||
name: string
|
||||
category_id: string
|
||||
description?: string | null
|
||||
parameters_schema: { parameters: Array<Record<string, unknown>> } | Record<string, unknown>
|
||||
edited_body?: string | null
|
||||
}
|
||||
|
||||
export interface DraftAcceptResponse {
|
||||
draft_id: string
|
||||
promoted_template_id: string
|
||||
template_slug: string
|
||||
}
|
||||
|
||||
export interface DraftRejectResponse {
|
||||
draft_id: string
|
||||
status: 'rejected'
|
||||
}
|
||||
|
||||
export const draftTemplatesApi = {
|
||||
async list(pendingOnly = true): Promise<DraftTemplate[]> {
|
||||
const r = await apiClient.get<{ drafts: DraftTemplate[] }>('/draft-templates', {
|
||||
params: { pending_only: pendingOnly },
|
||||
})
|
||||
return r.data.drafts
|
||||
},
|
||||
|
||||
async get(id: string): Promise<DraftTemplate> {
|
||||
const r = await apiClient.get<DraftTemplate>(`/draft-templates/${id}`)
|
||||
return r.data
|
||||
},
|
||||
|
||||
async accept(id: string, data: DraftAcceptRequest): Promise<DraftAcceptResponse> {
|
||||
const r = await apiClient.post<DraftAcceptResponse>(
|
||||
`/draft-templates/${id}/accept`,
|
||||
data,
|
||||
)
|
||||
return r.data
|
||||
},
|
||||
|
||||
async reject(id: string): Promise<DraftRejectResponse> {
|
||||
const r = await apiClient.post<DraftRejectResponse>(
|
||||
`/draft-templates/${id}/reject`,
|
||||
)
|
||||
return r.data
|
||||
},
|
||||
}
|
||||
|
||||
// ── Account preferences (used by the "don't ask again" opt-out) ────────────
|
||||
|
||||
export interface AccountPreferences {
|
||||
preferences: Record<string, unknown>
|
||||
}
|
||||
|
||||
export const accountPreferencesApi = {
|
||||
async get(): Promise<AccountPreferences> {
|
||||
const r = await apiClient.get<AccountPreferences>('/accounts/me/preferences')
|
||||
return r.data
|
||||
},
|
||||
|
||||
async update(patch: Record<string, unknown>): Promise<AccountPreferences> {
|
||||
const r = await apiClient.patch<AccountPreferences>('/accounts/me/preferences', {
|
||||
preferences: patch,
|
||||
})
|
||||
return r.data
|
||||
},
|
||||
}
|
||||
|
||||
export default draftTemplatesApi
|
||||
@@ -44,6 +44,10 @@ export function Sidebar() {
|
||||
const toggleSidebarPinned = useUserPreferencesStore(s => s.toggleSidebarPinned)
|
||||
|
||||
const [stats, setStats] = useState<SidebarStatsResponse | null>(null)
|
||||
// Phase 6: pending-drafts badge on the Scripts nav. Fetched independently
|
||||
// of the main stats endpoint so backend changes aren't coupled — worst
|
||||
// case the badge doesn't show, rest of the sidebar still renders.
|
||||
const [pendingDraftCount, setPendingDraftCount] = useState<number>(0)
|
||||
const [flyoutIndex, setFlyoutIndex] = useState<string | null>(null)
|
||||
const flyoutTimeout = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const sidebarRef = useRef<HTMLElement>(null)
|
||||
@@ -56,6 +60,13 @@ export function Sidebar() {
|
||||
sidebarApi.getStats()
|
||||
.then(data => { if (requestId === statsRequestId.current) setStats(data) })
|
||||
.catch(() => {})
|
||||
// Phase 6: pending draft templates — soft-fail, optional import keeps
|
||||
// the sidebar robust if the endpoint is momentarily unavailable.
|
||||
import('@/api/draftTemplates').then(({ draftTemplatesApi }) => {
|
||||
draftTemplatesApi.list(true)
|
||||
.then(drafts => setPendingDraftCount(drafts.length))
|
||||
.catch(() => {})
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
useEffect(() => { refreshStats() }, [location.pathname, refreshStats])
|
||||
@@ -97,9 +108,10 @@ export function Sidebar() {
|
||||
},
|
||||
{
|
||||
href: '/scripts', icon: FileText, label: 'Scripts', shortLabel: 'Scripts',
|
||||
badge: pendingDraftCount || undefined,
|
||||
matchPaths: ['/scripts', '/script-builder'],
|
||||
children: [
|
||||
{ href: '/scripts', label: 'Script Library' },
|
||||
{ href: '/scripts', label: 'Script Library', count: pendingDraftCount || undefined },
|
||||
{ href: '/script-builder', label: 'Script Builder' },
|
||||
],
|
||||
},
|
||||
|
||||
308
frontend/src/components/pilot/script/TemplatizePrompt.tsx
Normal file
308
frontend/src/components/pilot/script/TemplatizePrompt.tsx
Normal file
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* TemplatizePrompt — Phase 6 post-resolve modal.
|
||||
*
|
||||
* Appears after a successful Resolve when ALL three of:
|
||||
* 1. account_settings.preferences.templatize_prompt_enabled !== false
|
||||
* (default true when absent — per FLOWPILOT-MIGRATION.md Section 14.2)
|
||||
* 2. The session has at least one pending draft_templates row
|
||||
* 3. (Implicit from #2) The engineer picked "Run now, templatize after"
|
||||
* on the original three-option dialog
|
||||
*
|
||||
* The engineer picks a category, optionally tweaks the name/description/
|
||||
* parameter list, and either:
|
||||
* - Saves → POST /draft-templates/{id}/accept → new script_templates row
|
||||
* - Skips → POST /draft-templates/{id}/reject
|
||||
* - Toggles "Don't ask me again for this team" → PATCH
|
||||
* /accounts/me/preferences with {templatize_prompt_enabled: false}
|
||||
*
|
||||
* Parent (AssistantChatPage) controls the open state and feeds one draft at
|
||||
* a time. When multiple drafts exist for the session, parent re-opens the
|
||||
* modal for the next one after save/skip.
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Loader2, Check, X, Trash2, Sparkles } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Modal } from '@/components/common/Modal'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { scriptsApi } from '@/api/scripts'
|
||||
import type { ScriptCategoryResponse } from '@/types'
|
||||
import {
|
||||
draftTemplatesApi,
|
||||
accountPreferencesApi,
|
||||
type DraftTemplate,
|
||||
} from '@/api/draftTemplates'
|
||||
import { ParameterizationPreview } from './ParameterizationPreview'
|
||||
|
||||
interface TemplatizePromptProps {
|
||||
draft: DraftTemplate
|
||||
sourceTicketRef: string | null // "CW #48307" or null for local-only sessions
|
||||
onResolved: (outcome: 'accepted' | 'rejected') => void
|
||||
}
|
||||
|
||||
interface ParamEntry {
|
||||
key: string
|
||||
label: string
|
||||
field_type: string
|
||||
inferred_from?: string
|
||||
}
|
||||
|
||||
function normalizeParams(proposed: DraftTemplate['proposed_parameters']): ParamEntry[] {
|
||||
const raw = (proposed as { parameters?: unknown[] })?.parameters
|
||||
if (!Array.isArray(raw)) return []
|
||||
return raw
|
||||
.filter((p): p is Record<string, unknown> => typeof p === 'object' && p !== null)
|
||||
.map((p) => ({
|
||||
key: String(p.key ?? p.variable_name ?? ''),
|
||||
label: String(p.label ?? ''),
|
||||
field_type: String(p.field_type ?? p.type ?? 'text'),
|
||||
inferred_from: typeof p.inferred_from === 'string' ? p.inferred_from : undefined,
|
||||
}))
|
||||
.filter((p) => p.key.length > 0)
|
||||
}
|
||||
|
||||
export function TemplatizePrompt({ draft, sourceTicketRef, onResolved }: TemplatizePromptProps) {
|
||||
const [name, setName] = useState(draft.proposed_name ?? '')
|
||||
const [description, setDescription] = useState('')
|
||||
const [categoryId, setCategoryId] = useState(draft.proposed_category_id ?? '')
|
||||
const [params, setParams] = useState<ParamEntry[]>(() => normalizeParams(draft.proposed_parameters))
|
||||
const [dontAskAgain, setDontAskAgain] = useState(false)
|
||||
const [categories, setCategories] = useState<ScriptCategoryResponse[]>([])
|
||||
const [categoriesLoading, setCategoriesLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setCategoriesLoading(true)
|
||||
scriptsApi
|
||||
.getCategories()
|
||||
.then((cats) => {
|
||||
setCategories(cats)
|
||||
if (!categoryId && cats.length > 0) setCategoryId(cats[0].id)
|
||||
})
|
||||
.catch(() => toast.error('Could not load script categories'))
|
||||
.finally(() => setCategoriesLoading(false))
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const canSave = useMemo(
|
||||
() => Boolean(name.trim() && categoryId && !saving),
|
||||
[name, categoryId, saving],
|
||||
)
|
||||
|
||||
const removeParam = (keyToRemove: string) => {
|
||||
setParams((prev) => prev.filter((p) => p.key !== keyToRemove))
|
||||
}
|
||||
|
||||
const persistOptOutIfNeeded = async () => {
|
||||
if (!dontAskAgain) return
|
||||
try {
|
||||
await accountPreferencesApi.update({ templatize_prompt_enabled: false })
|
||||
} catch {
|
||||
// Soft-fail: the save itself succeeded; opt-out can be retried from
|
||||
// settings. Don't block the engineer on this.
|
||||
toast.warning("Could not save 'don't ask again' preference — retry in Settings")
|
||||
}
|
||||
}
|
||||
|
||||
const handleAccept = async () => {
|
||||
if (!canSave) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const parameters_schema = {
|
||||
parameters: params.map((p) => ({
|
||||
key: p.key,
|
||||
label: p.label || p.key,
|
||||
field_type: p.field_type,
|
||||
})),
|
||||
}
|
||||
await draftTemplatesApi.accept(draft.id, {
|
||||
name: name.trim(),
|
||||
category_id: categoryId,
|
||||
description: description.trim() || null,
|
||||
parameters_schema,
|
||||
})
|
||||
await persistOptOutIfNeeded()
|
||||
toast.success('Saved as team template')
|
||||
onResolved('accepted')
|
||||
} catch (err: unknown) {
|
||||
const status = (err as { response?: { status?: number } })?.response?.status
|
||||
if (status === 409) toast.warning('This draft has already been handled')
|
||||
else toast.error('Could not save template')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSkip = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await draftTemplatesApi.reject(draft.id)
|
||||
await persistOptOutIfNeeded()
|
||||
onResolved('rejected')
|
||||
} catch (err: unknown) {
|
||||
const status = (err as { response?: { status?: number } })?.response?.status
|
||||
if (status === 409) {
|
||||
// Already resolved — treat as a no-op, not an error.
|
||||
onResolved('rejected')
|
||||
} else {
|
||||
toast.error('Could not skip draft')
|
||||
}
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
onClose={handleSkip}
|
||||
title="Save as team template?"
|
||||
size="xl"
|
||||
footer={
|
||||
<div className="flex items-center justify-between gap-3 w-full">
|
||||
<label className="flex items-center gap-2 text-[0.75rem] text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={dontAskAgain}
|
||||
onChange={(e) => setDontAskAgain(e.target.checked)}
|
||||
className="accent-accent"
|
||||
/>
|
||||
Don't ask me again for this team
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleSkip}
|
||||
disabled={saving}
|
||||
className="rounded-md px-3 py-1.5 text-[0.8125rem] text-muted-foreground hover:text-heading hover:bg-elevated/40 transition-colors disabled:opacity-40"
|
||||
>
|
||||
Skip
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAccept}
|
||||
disabled={!canSave}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded-md px-3 py-1.5 text-[0.8125rem] font-semibold transition-colors',
|
||||
canSave
|
||||
? 'bg-accent text-white hover:bg-accent-hover'
|
||||
: 'bg-elevated text-muted-foreground border border-default cursor-not-allowed',
|
||||
)}
|
||||
>
|
||||
{saving ? <Loader2 size={11} className="animate-spin" /> : <Check size={11} />}
|
||||
Save as team template
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4 text-[0.8125rem]">
|
||||
<div className="flex items-start gap-2 rounded-lg border border-accent/30 bg-accent-dim/15 px-3 py-2">
|
||||
<Sparkles size={13} className="text-accent mt-0.5 shrink-0" />
|
||||
<div className="text-[0.75rem] text-muted-foreground leading-relaxed">
|
||||
This script ran as a one-off during the resolved session
|
||||
{sourceTicketRef && (
|
||||
<> (<span className="font-mono text-accent-text">{sourceTicketRef}</span>)</>
|
||||
)}
|
||||
. Review the proposed parameters and save it to the Script Library
|
||||
so the team can reuse it.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-[0.6875rem] font-semibold text-heading mb-1">
|
||||
Template name
|
||||
</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full rounded-md border border-default bg-input px-2.5 py-1.5 text-[0.8125rem] text-heading focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30"
|
||||
placeholder="Short, descriptive"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[0.6875rem] font-semibold text-heading mb-1">
|
||||
Category
|
||||
</label>
|
||||
<select
|
||||
value={categoryId}
|
||||
onChange={(e) => setCategoryId(e.target.value)}
|
||||
disabled={categoriesLoading}
|
||||
className="w-full rounded-md border border-default bg-input px-2.5 py-1.5 text-[0.8125rem] text-heading focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30"
|
||||
>
|
||||
{categoriesLoading && <option>Loading…</option>}
|
||||
{!categoriesLoading && categories.length === 0 && (
|
||||
<option value="">No categories available</option>
|
||||
)}
|
||||
{categories.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[0.6875rem] font-semibold text-heading mb-1">
|
||||
Description (optional)
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="w-full rounded-md border border-default bg-input px-2.5 py-1.5 text-[0.8125rem] text-heading placeholder:text-muted-foreground resize-y min-h-[48px] max-h-[120px] focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30"
|
||||
rows={2}
|
||||
placeholder="What does this script do? When should someone reach for it?"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="text-[0.6875rem] font-semibold text-heading">
|
||||
Proposed parameters ({params.length})
|
||||
</label>
|
||||
<span className="text-[0.6875rem] text-muted-foreground italic">
|
||||
{params.length === 0
|
||||
? 'No parameters proposed — script will run as-is.'
|
||||
: 'Remove any that don\'t need to be parameterized.'}
|
||||
</span>
|
||||
</div>
|
||||
{params.length > 0 && (
|
||||
<div className="rounded-lg border border-default bg-card overflow-hidden">
|
||||
{params.map((p) => (
|
||||
<div
|
||||
key={p.key}
|
||||
className="flex items-center gap-2 px-2.5 py-1.5 border-b border-default last:border-b-0"
|
||||
>
|
||||
<span className="font-mono text-[0.75rem] text-accent-text">{`{{ ${p.key} }}`}</span>
|
||||
<span className="text-[0.75rem] text-heading">{p.label}</span>
|
||||
<span className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-semibold">
|
||||
{p.field_type}
|
||||
</span>
|
||||
{p.inferred_from && (
|
||||
<span className="text-[0.6875rem] text-muted-foreground italic truncate flex-1 min-w-0">
|
||||
from {p.inferred_from}
|
||||
</span>
|
||||
)}
|
||||
{!p.inferred_from && <span className="flex-1" />}
|
||||
<button
|
||||
onClick={() => removeParam(p.key)}
|
||||
disabled={saving}
|
||||
className="p-1 rounded text-muted-foreground hover:text-danger hover:bg-elevated/40 transition-colors"
|
||||
title="Remove parameter"
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[0.6875rem] font-semibold text-heading mb-1">
|
||||
Script preview
|
||||
</label>
|
||||
<ParameterizationPreview body={draft.script_body} />
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default TemplatizePrompt
|
||||
@@ -18,6 +18,12 @@ import { SuggestedFix } from '@/components/pilot/sections/SuggestedFix'
|
||||
import { ResolutionNotePreview as ResolutionNotePreviewPopover } from '@/components/pilot/ResolutionNotePreview'
|
||||
import { TemplateMatchPanel } from '@/components/pilot/script/TemplateMatchPanel'
|
||||
import { NoTemplateDialog } from '@/components/pilot/script/NoTemplateDialog'
|
||||
import { TemplatizePrompt } from '@/components/pilot/script/TemplatizePrompt'
|
||||
import {
|
||||
draftTemplatesApi,
|
||||
accountPreferencesApi,
|
||||
type DraftTemplate,
|
||||
} from '@/api/draftTemplates'
|
||||
import { PILOT_INLINE_SCRIPT_EVENT } from '@/lib/pilotEvents'
|
||||
import { sessionFactsApi, type SessionFact } from '@/api/sessionFacts'
|
||||
import {
|
||||
@@ -109,6 +115,11 @@ export default function AssistantChatPage() {
|
||||
// whether the active fix has a script_template_id.
|
||||
const [scriptPanelOpen, setScriptPanelOpen] = useState(false)
|
||||
const [scriptDecisionBusy, setScriptDecisionBusy] = useState(false)
|
||||
// Phase 6: post-resolve "save as template?" queue. After Resolve succeeds
|
||||
// we fetch pending drafts for this session and show the modal one at a
|
||||
// time; the user accepts, rejects, or toggles "don't ask again", and we
|
||||
// advance to the next pending draft.
|
||||
const [templatizeQueue, setTemplatizeQueue] = useState<DraftTemplate[]>([])
|
||||
const [showOverflow, setShowOverflow] = useState(false)
|
||||
const toggleSidebarCollapse = () => {
|
||||
const next = !sidebarCollapsed
|
||||
@@ -505,6 +516,27 @@ export default function AssistantChatPage() {
|
||||
toast.success('Session escalated locally (no PSA ticket linked)')
|
||||
}
|
||||
handleClosePreview()
|
||||
|
||||
// Phase 6: on a successful Resolve (either external or local), check
|
||||
// for pending draft_templates rows created by the Phase 5 three-option
|
||||
// dialog. Show the TemplatizePrompt modal iff:
|
||||
// - the account preference hasn't opted out
|
||||
// - the session has at least one pending draft
|
||||
// Escalate doesn't trigger this flow — only resolution.
|
||||
if (out.outcome === 'resolved' || out.outcome === 'resolved_local') {
|
||||
try {
|
||||
const prefs = await accountPreferencesApi.get()
|
||||
if (prefs.preferences.templatize_prompt_enabled === false) return
|
||||
const drafts = await draftTemplatesApi.list(true)
|
||||
const forThisSession = drafts.filter(
|
||||
(d) => d.source_session_id === activeChatId,
|
||||
)
|
||||
if (forThisSession.length > 0) setTemplatizeQueue(forThisSession)
|
||||
} catch {
|
||||
// Soft-fail: the Resolve itself succeeded. A missing preference
|
||||
// or list fetch is not worth blocking the success toast.
|
||||
}
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
console.error('[AssistantChat] confirm post failed:', err)
|
||||
const status = (err as { response?: { status?: number }; response?: { data?: { detail?: string } } })?.response?.status
|
||||
@@ -1431,6 +1463,16 @@ export default function AssistantChatPage() {
|
||||
context="status"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Phase 6: post-resolve "save as team template?" modal. Shown one draft
|
||||
at a time; onResolved advances the queue. */}
|
||||
{templatizeQueue.length > 0 && (
|
||||
<TemplatizePrompt
|
||||
draft={templatizeQueue[0]}
|
||||
sourceTicketRef={activePsaTicketId ? `CW #${activePsaTicketId}` : null}
|
||||
onResolved={() => setTemplatizeQueue((q) => q.slice(1))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user