feat(pilot): Phase 2 — What we know (facts) with stable task-lane IDs
Adds the load-bearing structural feature of the FlowPilot migration: a
"What we know" panel that holds confirmed facts for a session, fed by AI
[PROMOTE] markers and engineer-added notes. Facts feed the resolution
note preview (Phase 3) and survive across turns via stable UUIDs assigned
to pending_task_lane items.
Backend:
- FactSynthesisService: create/update/soft-delete facts with atomic
state_version bumps; LLM-backed synthesize_from_question/check on the
fact_synthesis (Haiku) action tier per Section 6.6.
- /api/v1/ai-sessions/{id}/facts CRUD + /facts/promote (proposed_text or
via synthesis). PATCH returns 403 for question/diagnostic_check facts
(edit the source item instead, Section 7.3).
- unified_chat_service: [PROMOTE] marker parser (JSON-block per Section
8.1 spec drift note), stable-UUID assignment for pending_task_lane
questions/actions preserved by exact text/label match across turns.
- ASSISTANT_SYSTEM_PROMPT: documents [PROMOTE] format, when to/not to
emit, hallucination guardrails, source_ref handling.
- 17 tests covering parser, stable IDs, service validation, CRUD,
editability rule, both promote modes, 422 null-synthesis path,
state_version invariant.
Frontend:
- src/components/pilot/sections/{WhatWeKnow,WhatWeKnowItem,AddNoteButton}
— green-gradient section above Questions, dashed-circle check, inline
edit/delete gated by the server's editable flag.
- TaskLane gains a whatWeKnowSlot prop (existing assistant/ folder kept
per the doc's "rename is opportunistic" guidance).
- AssistantChatPage fetches facts on selectChat and refetches after each
chat send (so [PROMOTE]-synthesized facts appear immediately); auto-
opens the lane when facts exist.
Verification: end-to-end smoke against the local docker stack confirms
all five endpoints (list/create/patch/delete/promote) plus the 403
editability rule. pytest suite verifies the same with mocked LLM. Live
[PROMOTE] flow remains untested until used in the UI — the marker shape
is covered by parser tests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
89
frontend/src/api/sessionFacts.ts
Normal file
89
frontend/src/api/sessionFacts.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Session facts API — the "What we know" CRUD surface for a FlowPilot session.
|
||||
*
|
||||
* Mirrors backend endpoints at `/api/v1/ai-sessions/{id}/facts`.
|
||||
* See FLOWPILOT-MIGRATION.md Section 5.1.
|
||||
*/
|
||||
import apiClient from './client'
|
||||
|
||||
export type SessionFactSourceType =
|
||||
| 'question'
|
||||
| 'diagnostic_check'
|
||||
| 'user_note'
|
||||
| 'ai_synthesis'
|
||||
|
||||
export interface SessionFact {
|
||||
id: string
|
||||
session_id: string
|
||||
text: string
|
||||
source_type: SessionFactSourceType
|
||||
source_ref: string | null
|
||||
source_summary: string | null
|
||||
created_by: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
// Server-computed: false for question/diagnostic_check (PATCH returns 403),
|
||||
// true for user_note/ai_synthesis. Drives the edit affordance in the UI.
|
||||
editable: boolean
|
||||
}
|
||||
|
||||
export interface SessionFactCreateRequest {
|
||||
text: string
|
||||
summary?: string | null
|
||||
}
|
||||
|
||||
export interface SessionFactUpdateRequest {
|
||||
text?: string | null
|
||||
summary?: string | null
|
||||
}
|
||||
|
||||
export interface SessionFactPromoteRequest {
|
||||
source_type: 'question' | 'diagnostic_check' | 'ai_synthesis'
|
||||
source_ref?: string | null
|
||||
proposed_text?: string | null
|
||||
proposed_summary?: string | null
|
||||
raw_input?: string | null
|
||||
}
|
||||
|
||||
export const sessionFactsApi = {
|
||||
async list(sessionId: string): Promise<SessionFact[]> {
|
||||
const r = await apiClient.get<{ facts: SessionFact[] }>(
|
||||
`/ai-sessions/${sessionId}/facts`,
|
||||
)
|
||||
return r.data.facts
|
||||
},
|
||||
|
||||
async create(sessionId: string, data: SessionFactCreateRequest): Promise<SessionFact> {
|
||||
const r = await apiClient.post<SessionFact>(
|
||||
`/ai-sessions/${sessionId}/facts`,
|
||||
data,
|
||||
)
|
||||
return r.data
|
||||
},
|
||||
|
||||
async update(
|
||||
sessionId: string,
|
||||
factId: string,
|
||||
data: SessionFactUpdateRequest,
|
||||
): Promise<SessionFact> {
|
||||
const r = await apiClient.patch<SessionFact>(
|
||||
`/ai-sessions/${sessionId}/facts/${factId}`,
|
||||
data,
|
||||
)
|
||||
return r.data
|
||||
},
|
||||
|
||||
async remove(sessionId: string, factId: string): Promise<void> {
|
||||
await apiClient.delete(`/ai-sessions/${sessionId}/facts/${factId}`)
|
||||
},
|
||||
|
||||
async promote(sessionId: string, data: SessionFactPromoteRequest): Promise<SessionFact> {
|
||||
const r = await apiClient.post<SessionFact>(
|
||||
`/ai-sessions/${sessionId}/facts/promote`,
|
||||
data,
|
||||
)
|
||||
return r.data
|
||||
},
|
||||
}
|
||||
|
||||
export default sessionFactsApi
|
||||
@@ -38,6 +38,11 @@ interface TaskLaneProps {
|
||||
onSubmit: (responses: TaskResponse[]) => void
|
||||
onClose: () => void
|
||||
loading?: boolean
|
||||
// Slot for the FlowPilot Phase 2 "What we know" section. Rendered above
|
||||
// Questions in the body (per FLOWPILOT-MIGRATION.md Section 3.1). The slot
|
||||
// shape lets the parent own fact-fetching and state-version polling without
|
||||
// pulling that concern into TaskLane.
|
||||
whatWeKnowSlot?: React.ReactNode
|
||||
}
|
||||
|
||||
// ── Storage helpers ──
|
||||
@@ -64,7 +69,7 @@ export function clearTaskState(sessionId: string) {
|
||||
|
||||
// ── Component ──
|
||||
|
||||
export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loading }: TaskLaneProps) {
|
||||
export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loading, whatWeKnowSlot }: TaskLaneProps) {
|
||||
const [tasks, setTasks] = useState<TaskResponse[]>(() => {
|
||||
// Try to restore saved state for this session (preserves user's in-progress answers)
|
||||
if (sessionId) {
|
||||
@@ -269,6 +274,9 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-4">
|
||||
|
||||
{/* ── What we know (Phase 2) ── */}
|
||||
{whatWeKnowSlot}
|
||||
|
||||
{/* ── Questions Section ── */}
|
||||
{questionTasks.length > 0 && (
|
||||
<section>
|
||||
|
||||
87
frontend/src/components/pilot/sections/AddNoteButton.tsx
Normal file
87
frontend/src/components/pilot/sections/AddNoteButton.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* "+ Add a note" affordance for the What-we-know section.
|
||||
*
|
||||
* Inline composer that posts a `user_note` fact when the engineer wants to
|
||||
* record something the AI didn't surface (a hunch, an observation, a piece
|
||||
* of customer context). Per FLOWPILOT-MIGRATION.md Section 3.1.
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import { Plus, Check } from 'lucide-react'
|
||||
|
||||
interface AddNoteButtonProps {
|
||||
onAdd: (text: string, summary: string | null) => Promise<void> | void
|
||||
}
|
||||
|
||||
export function AddNoteButton({ onAdd }: AddNoteButtonProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [text, setText] = useState('')
|
||||
const [summary, setSummary] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const reset = () => {
|
||||
setText('')
|
||||
setSummary('')
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!text.trim()) return
|
||||
setBusy(true)
|
||||
try {
|
||||
await onAdd(text.trim(), summary.trim() || null)
|
||||
reset()
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="flex items-center gap-1.5 text-[0.75rem] text-muted-foreground hover:text-accent-text transition-colors pl-1 mt-1"
|
||||
>
|
||||
<Plus size={12} />
|
||||
Add a note
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-default bg-card p-3 mb-2">
|
||||
<textarea
|
||||
autoFocus
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder="What did you observe or confirm?"
|
||||
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-[44px] max-h-[140px] focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30"
|
||||
rows={2}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={summary}
|
||||
onChange={(e) => setSummary(e.target.value)}
|
||||
placeholder="Short label (optional)"
|
||||
className="mt-1.5 w-full rounded-md border border-default bg-input px-2.5 py-1 text-[0.75rem] text-heading placeholder:text-muted-foreground focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30"
|
||||
/>
|
||||
<div className="mt-1.5 flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={busy || !text.trim()}
|
||||
className="flex items-center gap-1 rounded-md bg-accent px-2.5 py-1 text-[0.75rem] font-medium text-white disabled:opacity-40 hover:bg-accent-hover transition-colors"
|
||||
>
|
||||
<Check size={11} /> Add
|
||||
</button>
|
||||
<button
|
||||
onClick={reset}
|
||||
disabled={busy}
|
||||
className="text-[0.75rem] text-muted-foreground hover:text-heading"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AddNoteButton
|
||||
74
frontend/src/components/pilot/sections/WhatWeKnow.tsx
Normal file
74
frontend/src/components/pilot/sections/WhatWeKnow.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* What-we-know section — the load-bearing structural feature of the FlowPilot
|
||||
* task lane (per FLOWPILOT-MIGRATION.md Section 0 architectural claim #2).
|
||||
*
|
||||
* Owns the list of confirmed facts for a session. Facts arrive via three paths:
|
||||
* 1. AI [PROMOTE] markers parsed in unified_chat_service (most common)
|
||||
* 2. Engineer "+ Add a note" (manual user_note)
|
||||
* 3. Engineer-initiated promotion of a question/check (future affordance)
|
||||
*
|
||||
* This component is the parent's contract: it takes a fact list + handlers
|
||||
* and renders the section. Loading/refresh logic lives in the parent
|
||||
* (AssistantChatPage) so it can coordinate with the chat send cycle.
|
||||
*/
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { SessionFact } from '@/api/sessionFacts'
|
||||
import { WhatWeKnowItem } from './WhatWeKnowItem'
|
||||
import { AddNoteButton } from './AddNoteButton'
|
||||
|
||||
interface WhatWeKnowProps {
|
||||
facts: SessionFact[]
|
||||
onAddNote: (text: string, summary: string | null) => Promise<void> | void
|
||||
onUpdateFact: (factId: string, text: string, summary: string | null) => Promise<void> | void
|
||||
onDeleteFact: (factId: string) => Promise<void> | void
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export function WhatWeKnow({
|
||||
facts,
|
||||
onAddNote,
|
||||
onUpdateFact,
|
||||
onDeleteFact,
|
||||
loading,
|
||||
}: WhatWeKnowProps) {
|
||||
const count = facts.length
|
||||
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
'rounded-lg p-3 -mx-1 mb-1',
|
||||
// Subtle green-to-transparent gradient distinguishes this section
|
||||
// from the rest of the lane (mockup 01-session-primary.png).
|
||||
'bg-gradient-to-b from-success/[0.05] to-transparent',
|
||||
)}
|
||||
>
|
||||
<div className="sticky top-0 z-10 pb-2" style={{ background: 'var(--color-bg-page)' }}>
|
||||
<div className="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-[1.2px] text-muted-foreground pl-0.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-success" />
|
||||
What we know
|
||||
<span className="text-muted-foreground">·</span>
|
||||
<span className="tabular-nums">{count}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{count === 0 && !loading && (
|
||||
<div className="text-[0.75rem] text-muted-foreground italic px-1 py-2">
|
||||
Nothing confirmed yet — facts appear here as the engineer answers questions and runs checks.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{facts.map((fact) => (
|
||||
<WhatWeKnowItem
|
||||
key={fact.id}
|
||||
fact={fact}
|
||||
onSave={(text, summary) => onUpdateFact(fact.id, text, summary)}
|
||||
onDelete={() => onDeleteFact(fact.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<AddNoteButton onAdd={onAddNote} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default WhatWeKnow
|
||||
152
frontend/src/components/pilot/sections/WhatWeKnowItem.tsx
Normal file
152
frontend/src/components/pilot/sections/WhatWeKnowItem.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Single fact card in the What-we-know section.
|
||||
*
|
||||
* Layout per FLOWPILOT-MIGRATION.md Section 3.1:
|
||||
* [✓] <fact text>
|
||||
* <provenance line: 'from question · rules out tenant/license'>
|
||||
*
|
||||
* Editability: the server marks `editable=false` for `question` and
|
||||
* `diagnostic_check` facts (those are edited at the source item, not here).
|
||||
* Manual notes and AI-synthesized facts are editable inline.
|
||||
*
|
||||
* Delete is allowed for all source types — even read-only facts may be
|
||||
* removed when the underlying question or check turned out to be wrong.
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import { Check, Pencil, Trash2, X } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { SessionFact, SessionFactSourceType } from '@/api/sessionFacts'
|
||||
|
||||
interface WhatWeKnowItemProps {
|
||||
fact: SessionFact
|
||||
onSave: (text: string, summary: string | null) => Promise<void> | void
|
||||
onDelete: () => Promise<void> | void
|
||||
}
|
||||
|
||||
const SOURCE_LABEL: Record<SessionFactSourceType, string> = {
|
||||
question: 'from question',
|
||||
diagnostic_check: 'from check',
|
||||
user_note: 'manual note',
|
||||
ai_synthesis: 'synthesized',
|
||||
}
|
||||
|
||||
export function WhatWeKnowItem({ fact, onSave, onDelete }: WhatWeKnowItemProps) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [draftText, setDraftText] = useState(fact.text)
|
||||
const [draftSummary, setDraftSummary] = useState(fact.source_summary ?? '')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!draftText.trim()) return
|
||||
setBusy(true)
|
||||
try {
|
||||
await onSave(draftText.trim(), draftSummary.trim() || null)
|
||||
setEditing(false)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
setDraftText(fact.text)
|
||||
setDraftSummary(fact.source_summary ?? '')
|
||||
setEditing(false)
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
setBusy(true)
|
||||
try {
|
||||
await onDelete()
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const provenanceLabel = SOURCE_LABEL[fact.source_type]
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<div className="rounded-lg border border-default bg-card p-3 mb-2">
|
||||
<textarea
|
||||
autoFocus
|
||||
value={draftText}
|
||||
onChange={(e) => setDraftText(e.target.value)}
|
||||
className="w-full rounded-md border border-default bg-input px-2.5 py-1.5 text-[0.8125rem] text-heading resize-y min-h-[44px] max-h-[140px] focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30"
|
||||
rows={2}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={draftSummary}
|
||||
onChange={(e) => setDraftSummary(e.target.value)}
|
||||
placeholder="Short label (e.g. 'rules out tenant/license')"
|
||||
className="mt-1.5 w-full rounded-md border border-default bg-input px-2.5 py-1 text-[0.75rem] text-heading placeholder:text-muted-foreground focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30"
|
||||
/>
|
||||
<div className="mt-1.5 flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={busy || !draftText.trim()}
|
||||
className="flex items-center gap-1 rounded-md bg-accent px-2.5 py-1 text-[0.75rem] font-medium text-white disabled:opacity-40 hover:bg-accent-hover transition-colors"
|
||||
>
|
||||
<Check size={11} /> Save
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
disabled={busy}
|
||||
className="text-[0.75rem] text-muted-foreground hover:text-heading"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group rounded-lg border-l-[3px] border-l-success border border-success/25 bg-success-dim/20 p-3 mb-2',
|
||||
busy && 'opacity-60',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full border border-dashed border-success">
|
||||
<Check size={9} className="text-success" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[0.8125rem] text-heading leading-relaxed">{fact.text}</div>
|
||||
<div className="mt-1 text-[0.6875rem] text-muted-foreground">
|
||||
<span>{provenanceLabel}</span>
|
||||
{fact.source_summary && (
|
||||
<>
|
||||
<span className="mx-1">·</span>
|
||||
<span className="italic">{fact.source_summary}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{fact.editable && (
|
||||
<button
|
||||
onClick={() => setEditing(true)}
|
||||
disabled={busy}
|
||||
className="p-1 rounded text-muted-foreground hover:text-heading hover:bg-elevated/40 transition-colors"
|
||||
title="Edit fact"
|
||||
>
|
||||
<Pencil size={11} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={busy}
|
||||
className="p-1 rounded text-muted-foreground hover:text-danger hover:bg-elevated/40 transition-colors"
|
||||
title="Remove fact"
|
||||
>
|
||||
{busy ? <X size={11} /> : <Trash2 size={11} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default WhatWeKnowItem
|
||||
@@ -13,6 +13,8 @@ import { toast } from '@/lib/toast'
|
||||
import { ChatSidebar, ChatSidebarCollapsedBar } from '@/components/assistant/ChatSidebar'
|
||||
import { ChatMessage } from '@/components/assistant/ChatMessage'
|
||||
import { TaskLane, clearTaskState } from '@/components/assistant/TaskLane'
|
||||
import { WhatWeKnow } from '@/components/pilot/sections/WhatWeKnow'
|
||||
import { sessionFactsApi, type SessionFact } from '@/api/sessionFacts'
|
||||
import { ConcludeSessionModal } from '@/components/assistant/ConcludeSessionModal'
|
||||
import { StatusUpdateModal } from '@/components/flowpilot/StatusUpdateModal'
|
||||
import type { ChatListItem, ConclusionOutcome } from '@/types/assistant-chat'
|
||||
@@ -74,6 +76,10 @@ export default function AssistantChatPage() {
|
||||
)
|
||||
const [activeSessionStatus, setActiveSessionStatus] = useState<string | null>(null)
|
||||
const [activePsaTicketId, setActivePsaTicketId] = useState<string | null>(null)
|
||||
// Phase 2: "What we know" facts for the active session. Refreshed on
|
||||
// selectChat and after each chat send (the AI may have emitted [PROMOTE]
|
||||
// markers that synthesized new facts server-side).
|
||||
const [facts, setFacts] = useState<SessionFact[]>([])
|
||||
const [showOverflow, setShowOverflow] = useState(false)
|
||||
const toggleSidebarCollapse = () => {
|
||||
const next = !sidebarCollapsed
|
||||
@@ -178,6 +184,8 @@ export default function AssistantChatPage() {
|
||||
setActiveActions(response.actions || [])
|
||||
setShowTaskLane(true)
|
||||
}
|
||||
// Refetch facts — the AI may have emitted [PROMOTE] markers.
|
||||
refreshFacts(session.session_id)
|
||||
} catch {
|
||||
toast.error('Failed to start AI conversation')
|
||||
} finally {
|
||||
@@ -222,6 +230,56 @@ export default function AssistantChatPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2 facts — fetch + handlers. `refreshFacts` is called from selectChat
|
||||
// and after each chat send, because the AI may have emitted [PROMOTE] markers
|
||||
// that synthesized new facts server-side (see unified_chat_service.
|
||||
// _persist_promote_items).
|
||||
const refreshFacts = useCallback(async (chatId: string) => {
|
||||
try {
|
||||
const list = await sessionFactsApi.list(chatId)
|
||||
// Guard: discard stale fetch if the user switched chats mid-flight.
|
||||
if (currentChatRef.current !== chatId) return
|
||||
setFacts(list)
|
||||
// Auto-open the task lane when the session has facts so the engineer
|
||||
// can see them — without this, a session with only facts (no open
|
||||
// questions) would hide the lane and the facts would be invisible.
|
||||
if (list.length > 0) setShowTaskLane(true)
|
||||
} catch {
|
||||
// Best-effort — facts are accessory state. Surfacing a toast on every
|
||||
// refetch failure would be noisy; the empty state explains the absence.
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleAddNote = async (text: string, summary: string | null) => {
|
||||
if (!activeChatId) return
|
||||
try {
|
||||
const fact = await sessionFactsApi.create(activeChatId, { text, summary })
|
||||
setFacts(prev => [...prev, fact])
|
||||
} catch {
|
||||
toast.error('Failed to add note')
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateFact = async (factId: string, text: string, summary: string | null) => {
|
||||
if (!activeChatId) return
|
||||
try {
|
||||
const updated = await sessionFactsApi.update(activeChatId, factId, { text, summary })
|
||||
setFacts(prev => prev.map(f => f.id === factId ? updated : f))
|
||||
} catch {
|
||||
toast.error('Failed to update fact')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteFact = async (factId: string) => {
|
||||
if (!activeChatId) return
|
||||
try {
|
||||
await sessionFactsApi.remove(activeChatId, factId)
|
||||
setFacts(prev => prev.filter(f => f.id !== factId))
|
||||
} catch {
|
||||
toast.error('Failed to remove fact')
|
||||
}
|
||||
}
|
||||
|
||||
const selectChat = useCallback(async (chatId: string) => {
|
||||
currentChatRef.current = chatId
|
||||
setActiveChatId(chatId)
|
||||
@@ -231,6 +289,9 @@ export default function AssistantChatPage() {
|
||||
setActiveActions([])
|
||||
setActiveSessionStatus(null)
|
||||
setActivePsaTicketId(null)
|
||||
setFacts([])
|
||||
// Fire facts fetch in parallel with session detail.
|
||||
refreshFacts(chatId)
|
||||
try {
|
||||
const detail = await aiSessionsApi.getSession(chatId)
|
||||
// Guard: if the user switched to a different chat while this API call was
|
||||
@@ -266,7 +327,7 @@ export default function AssistantChatPage() {
|
||||
} catch {
|
||||
setMessages([])
|
||||
}
|
||||
}, [])
|
||||
}, [refreshFacts])
|
||||
|
||||
const handleNewChat = async () => {
|
||||
// Invalidate currentChatRef BEFORE the await so any in-flight handleSend/handleTaskSubmit
|
||||
@@ -277,6 +338,7 @@ export default function AssistantChatPage() {
|
||||
setShowTaskLane(false)
|
||||
setActiveQuestions([])
|
||||
setActiveActions([])
|
||||
setFacts([])
|
||||
setMessages([])
|
||||
setActiveSessionStatus('active')
|
||||
setActivePsaTicketId(null)
|
||||
@@ -366,6 +428,8 @@ export default function AssistantChatPage() {
|
||||
setActiveActions(response.actions || [])
|
||||
setShowTaskLane(true)
|
||||
}
|
||||
// Refetch facts — [PROMOTE] markers may have synthesized new ones.
|
||||
refreshFacts(sentForChatId)
|
||||
} catch (err: unknown) {
|
||||
console.error('[AssistantChat] sendChatMessage failed:', err)
|
||||
const status = (err as { response?: { status?: number } })?.response?.status
|
||||
@@ -434,6 +498,8 @@ export default function AssistantChatPage() {
|
||||
setActiveQuestions([])
|
||||
setActiveActions([])
|
||||
}
|
||||
// Refetch facts — answering tasks is the primary [PROMOTE] trigger.
|
||||
refreshFacts(sentForChatId)
|
||||
} catch (err: unknown) {
|
||||
console.error('[AssistantChat] handleTaskSubmit failed:', err)
|
||||
const status = (err as { response?: { status?: number } })?.response?.status
|
||||
@@ -523,6 +589,8 @@ export default function AssistantChatPage() {
|
||||
setActiveActions(response.actions || [])
|
||||
setShowTaskLane(true)
|
||||
}
|
||||
// Refetch facts — the resume turn may emit [PROMOTE] markers.
|
||||
refreshFacts(session.session_id)
|
||||
} catch {
|
||||
toast.error('Failed to create resume chat')
|
||||
} finally {
|
||||
@@ -1011,8 +1079,11 @@ export default function AssistantChatPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Task lane — slides in when AI sends questions or actions */}
|
||||
{showTaskLane && (activeQuestions.length > 0 || activeActions.length > 0) && (
|
||||
{/* Task lane — slides in when AI sends questions/actions OR when the
|
||||
session has any "What we know" facts. Phase 2 makes the lane the
|
||||
structural home of session diagnostic state, not a transient
|
||||
questions panel. */}
|
||||
{showTaskLane && (activeQuestions.length > 0 || activeActions.length > 0 || facts.length > 0) && (
|
||||
<TaskLane
|
||||
questions={activeQuestions}
|
||||
actions={activeActions}
|
||||
@@ -1022,6 +1093,14 @@ export default function AssistantChatPage() {
|
||||
setShowTaskLane(false)
|
||||
}}
|
||||
loading={loading}
|
||||
whatWeKnowSlot={
|
||||
<WhatWeKnow
|
||||
facts={facts}
|
||||
onAddNote={handleAddNote}
|
||||
onUpdateFact={handleUpdateFact}
|
||||
onDeleteFact={handleDeleteFact}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user