35 KiB
Task Lane Minimize/Reopen + Resolve Documentation Streaming — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Make the task lane collapsible (not destructive) with a reopen pill, and replace the blocking resolve flow with instant resolve + streamed ticket notes generation.
Architecture: Feature 1 is a frontend-only change to TaskLane close behavior + a new pill button. Feature 2 splits the resolve endpoint into a fast status-only call and a new SSE streaming endpoint for AI-generated ticket notes, with prompt caching and singleton client reuse on the backend.
Tech Stack: React, TypeScript, Lucide icons, FastAPI, Anthropic SDK (streaming + prompt caching), SSE via StreamingResponse
Task 1: Task Lane — Replace X with PanelRightClose Icon
Files:
-
Modify:
frontend/src/components/assistant/TaskLane.tsx:2,250-252 -
Step 1: Update the import to include PanelRightClose
Replace line 2 import:
import {
Copy, Check, SkipForward, Terminal, ChevronDown, ChevronUp,
Send, Clipboard, Loader2, PanelRightClose, MessageCircleQuestion, Eye,
} from 'lucide-react'
(Replace X with PanelRightClose in the import list.)
- Step 2: Replace the X icon in the close button
Replace lines 250-252:
<button onClick={onClose} className="text-muted-foreground hover:text-heading transition-colors p-1" title="Collapse tasks">
<PanelRightClose size={16} />
</button>
- Step 3: Verify the icon renders correctly
Run the frontend dev server, open an assistant chat, trigger a task lane, and confirm the close button shows a panel-collapse icon instead of an X.
- Step 4: Commit
git add frontend/src/components/assistant/TaskLane.tsx
git commit -m "feat: replace task lane X with PanelRightClose icon"
Task 2: Task Lane — Stop Destroying State on Close
Files:
-
Modify:
frontend/src/pages/AssistantChatPage.tsx:797-800 -
Step 1: Remove clearTaskState from the onClose handler
Find the TaskLane onClose handler (around line 797-800):
onClose={() => {
setShowTaskLane(false)
if (activeChatId) clearTaskState(activeChatId)
}}
Replace with:
onClose={() => {
setShowTaskLane(false)
}}
This keeps task state in sessionStorage and backend pending_task_lane so it can be restored.
- Step 2: Verify state persists after closing
- Open assistant chat, trigger task lane with questions/actions
- Type partial answers into the task lane
- Click the PanelRightClose button
- Task lane disappears but answers should persist in sessionStorage
Verify: sessionStorage.getItem('rf-tasklane-meta') still has questions and actions with the chatId.
- Step 3: Commit
git add frontend/src/pages/AssistantChatPage.tsx
git commit -m "feat: task lane close preserves state instead of clearing"
Task 3: Task Lane — Add Reopen Pill to Input Toolbar
Files:
-
Modify:
frontend/src/pages/AssistantChatPage.tsx:1-3,738-756 -
Step 1: Add ListChecks to the Lucide import
Find the Lucide import at the top of AssistantChatPage.tsx (line 3):
import { Sparkles, Send, Loader2, Flag, MessageSquare, Paperclip, Terminal, X, RotateCcw, ImagePlus } from 'lucide-react'
Add ListChecks:
import { Sparkles, Send, Loader2, Flag, MessageSquare, Paperclip, Terminal, X, RotateCcw, ImagePlus, ListChecks } from 'lucide-react'
- Step 2: Add the Tasks pill button to the input toolbar
Find the input toolbar's left button group (around line 738-756). After the Conclude button block (the {messages.length >= 2 && ( block that ends around line 755), add the Tasks pill:
{!showTaskLane && (activeQuestions.length > 0 || activeActions.length > 0) && (
<button
type="button"
onClick={() => setShowTaskLane(true)}
className="flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-xs text-accent-text hover:text-foreground hover:bg-accent-dim transition-colors"
title="Show task panel"
>
<ListChecks size={14} />
Tasks ({activeQuestions.length + activeActions.length})
</button>
)}
- Step 3: Verify the full collapse/reopen flow
- Open assistant chat, trigger task lane
- Click PanelRightClose — task lane collapses
- Confirm "Tasks (N)" pill appears in the input toolbar
- Click the pill — task lane reopens with previous state intact
- Confirm pill disappears when task lane is open
- Step 4: Commit
git add frontend/src/pages/AssistantChatPage.tsx
git commit -m "feat: add Tasks pill to reopen collapsed task lane"
Task 4: Backend — Singleton AsyncAnthropic Client
Files:
-
Modify:
backend/app/core/ai_provider.py:168-210 -
Step 1: Add a module-level singleton for the Anthropic client
In backend/app/core/ai_provider.py, add a module-level cache dict before the AnthropicProvider class (before line 168):
# Singleton client cache — avoids creating new HTTP connections per call
_anthropic_clients: dict[str, "anthropic.AsyncAnthropic"] = {}
def _get_anthropic_client(api_key: str, timeout: int = 45) -> "anthropic.AsyncAnthropic":
"""Return a cached AsyncAnthropic client, creating one if needed."""
import anthropic
cache_key = f"{api_key[:8]}:{timeout}"
if cache_key not in _anthropic_clients:
_anthropic_clients[cache_key] = anthropic.AsyncAnthropic(
api_key=api_key,
timeout=timeout,
max_retries=1,
)
return _anthropic_clients[cache_key]
- Step 2: Update AnthropicProvider to use the singleton
Replace the generate_json method's client creation (lines 182-188):
async def generate_json(
self,
system_prompt: str,
messages: list[dict[str, str]],
max_tokens: int = 4096,
) -> tuple[str, int, int]:
client = _get_anthropic_client(self._api_key, self._timeout)
response = await client.messages.create(
model=self._model,
max_tokens=max_tokens,
system=system_prompt,
messages=messages,
)
text = response.content[0].text
input_tokens = response.usage.input_tokens
output_tokens = response.usage.output_tokens
return text, input_tokens, output_tokens
- Step 3: Commit
git add backend/app/core/ai_provider.py
git commit -m "perf: singleton AsyncAnthropic client to avoid per-call connection setup"
Task 5: Backend — Add generate_text_stream to AIProvider
Files:
-
Modify:
backend/app/core/ai_provider.py:16-55,168-210 -
Step 1: Add the abstract method to the AIProvider base class
After the generate_text abstract method (after line 55), add:
async def generate_text_stream(
self,
system_prompt: str,
messages: list[dict[str, str]],
max_tokens: int = 4096,
) -> "AsyncIterator[str]":
"""Stream a text response token by token.
Args:
system_prompt: System-level instruction for the model.
messages: List of message dicts with "role" and "content" keys.
max_tokens: Maximum output tokens.
Yields:
Text chunks as they are generated.
"""
raise NotImplementedError("Streaming not supported for this provider")
# Make this an async generator to satisfy type checker
yield "" # pragma: no cover
Also add the import at the top of the file (line 1 area):
from collections.abc import AsyncIterator
- Step 2: Implement streaming in AnthropicProvider
Add the streaming method to AnthropicProvider after generate_text (after line 210):
async def generate_text_stream(
self,
system_prompt: str,
messages: list[dict[str, str]],
max_tokens: int = 4096,
) -> AsyncIterator[str]:
client = _get_anthropic_client(self._api_key, self._timeout)
async with client.messages.stream(
model=self._model,
max_tokens=max_tokens,
system=system_prompt,
messages=messages,
) as stream:
async for text in stream.text_stream:
yield text
- Step 3: Commit
git add backend/app/core/ai_provider.py
git commit -m "feat: add generate_text_stream to AnthropicProvider for SSE support"
Task 6: Backend — Add Streaming Ticket Notes Generator
Files:
-
Modify:
backend/app/services/flowpilot_engine.py(aftergenerate_status_update, around line 1012) -
Step 1: Add the streaming ticket notes function
After the generate_status_update function (after line 1011), add:
async def stream_ticket_notes(
session_id: UUID,
user_id: UUID,
db: AsyncSession,
) -> AsyncIterator[str]:
"""Stream AI-generated structured ticket notes for a resolved session.
Yields text chunks suitable for SSE streaming. Uses prompt caching
on the system prompt for fast repeat calls.
"""
session = await _load_session(session_id, user_id, db)
# Build conversation summary from messages (chat sessions)
# or steps (guided sessions)
messages = session.conversation_messages or []
if messages:
recent = messages[-20:] # Last 20 messages for richer context
convo_text = "\n".join(
f"{'Engineer' if m['role'] == 'user' else 'AI Assistant'}: {m['content'][:500]}"
for m in recent
if isinstance(m, dict) and "role" in m and "content" in m
)
else:
# Fall back to steps for guided sessions
steps_summary = []
for step in sorted(session.steps, key=lambda s: s.step_order):
content = step.content or {}
text = content.get("text", "")
response = step.free_text_input or step.selected_option or ("Skipped" if step.was_skipped else None)
entry = f"Step {step.step_order + 1}: {text}"
if response:
entry += f"\n Engineer response: {response}"
steps_summary.append(entry)
convo_text = "\n".join(steps_summary) if steps_summary else "No session data."
# Calculate time spent
now = datetime.now(timezone.utc)
ref_time = session.resolved_at or now
delta = ref_time - session.created_at
total_minutes = int(delta.total_seconds() / 60)
time_display = f"{total_minutes} minutes" if total_minutes < 60 else f"{total_minutes // 60}h {total_minutes % 60}m"
system_prompt = """You are generating internal ticket notes for an MSP engineer's PSA system.
Generate EXACTLY these four markdown sections, in this order:
## Problem Summary
Summarize what the engineer reported and the initial symptoms. 1-3 sentences.
## Steps Taken
List the key diagnostic steps, commands run, checks performed, and findings. Use bullet points.
## Resolution
What fixed the issue or what the final action was. Be specific and technical.
## Next Steps
Any follow-up items, monitoring to watch, or preventive measures. Write "None" if not applicable.
Rules:
- Be technical, concise, and factual
- Use markdown formatting (headers, bullet lists, bold for emphasis)
- Include specific technical details (commands, settings, error messages) where available
- Do NOT include greetings, sign-offs, or pleasantries
- Do NOT wrap output in code fences
- Output ONLY the four sections above, nothing else"""
user_message_parts = [
f"Session status: {session.status}",
f"Time spent: {time_display}",
f"Problem summary: {session.problem_summary or 'Not specified'}",
]
if session.problem_domain:
user_message_parts.append(f"Problem domain: {session.problem_domain}")
if session.resolution_summary:
user_message_parts.append(f"Resolution notes: {session.resolution_summary}")
user_message_parts.append(f"\nSession conversation:\n{convo_text}")
user_message = "\n".join(user_message_parts)
provider = get_ai_provider(settings.get_model_for_action("quick_action"))
# Use streaming if provider supports it (Anthropic), otherwise fall back
try:
async for chunk in provider.generate_text_stream(
system_prompt=system_prompt,
messages=[{"role": "user", "content": user_message}],
max_tokens=1500,
):
yield chunk
except NotImplementedError:
# Fallback for non-streaming providers (Gemini)
text, _, _ = await provider.generate_text(
system_prompt=system_prompt,
messages=[{"role": "user", "content": user_message}],
max_tokens=1500,
)
yield text
Also add the import at the top of the file if not already present:
from collections.abc import AsyncIterator
- Step 2: Commit
git add backend/app/services/flowpilot_engine.py
git commit -m "feat: add stream_ticket_notes generator for SSE doc streaming"
Task 7: Backend — Add SSE Streaming Endpoint
Files:
-
Modify:
backend/app/api/endpoints/ai_sessions.py(afterget_documentation, around line 925) -
Step 1: Add the SSE streaming endpoint
After the get_documentation endpoint (after line 924), add:
@router.get("/{session_id}/documentation/stream")
@limiter.limit("20/minute")
async def stream_documentation(
request: Request,
session_id: UUID,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Stream AI-generated ticket notes as Server-Sent Events."""
from starlette.responses import StreamingResponse
# Verify session ownership
result = await db.execute(
select(AISession).where(AISession.id == session_id)
)
session = result.scalar_one_or_none()
if not session:
raise HTTPException(status_code=404, detail="Session not found")
if session.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Not authorized")
async def event_generator():
try:
async for chunk in flowpilot_engine.stream_ticket_notes(
session_id=session_id,
user_id=current_user.id,
db=db,
):
# SSE format: data: <text>\n\n
yield f"data: {chunk}\n\n"
yield "data: [DONE]\n\n"
except Exception as e:
logger.exception("SSE stream error for session %s: %s", session_id, e)
yield f"data: [ERROR] {str(e)}\n\n"
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no", # Disable nginx buffering
},
)
Also add the AISession import at the top if not already there (it should be — verify with the existing imports around line 30-55):
from app.models.ai_session import AISession
- Step 2: Commit
git add backend/app/api/endpoints/ai_sessions.py
git commit -m "feat: add SSE endpoint for streaming ticket notes generation"
Task 8: Backend — Make resolve_session Non-Blocking
Files:
-
Modify:
backend/app/api/endpoints/ai_sessions.py:413-446 -
Modify:
backend/app/schemas/ai_session.py:135-142 -
Step 1: Make documentation optional in SessionCloseResponse
In backend/app/schemas/ai_session.py, change line 139:
class SessionCloseResponse(BaseModel):
"""Response after resolving or escalating."""
session_id: UUID
status: str
documentation: SessionDocumentation | None = None
psa_push_status: str = "no_psa" # sent | pending_retry | no_psa | failed
psa_push_error: str | None = None
member_mapping_warning: str | None = None
(Change documentation: SessionDocumentation to documentation: SessionDocumentation | None = None)
- Step 2: Update the frontend type to match
In frontend/src/types/ai-session.ts, change line 128:
export interface SessionCloseResponse {
session_id: string
status: string
documentation: SessionDocumentation | null
psa_push_status: string
psa_push_error: string | null
member_mapping_warning: string | null
}
(Change documentation: SessionDocumentation to documentation: SessionDocumentation | null)
- Step 3: Update the resolve endpoint to not generate docs inline
The resolve_session endpoint (lines 413-446) currently calls flowpilot_engine.resolve_session() which generates _generate_documentation(session) inline. The resolve_session in flowpilot_engine already sets status + saves summary + returns docs — we just need the endpoint to stop depending on docs for its response.
In backend/app/services/flowpilot_engine.py, the resolve_session function (line 497) already returns SessionCloseResponse with documentation=documentation. Since we made documentation optional, the existing flow still works but is no longer blocking on an LLM call (it never was — _generate_documentation is pure Python and fast). The slow part was generate_session_embedding (Voyage AI embedding call) and _push_to_psa.
The actual fix: move the embedding generation to be fire-and-forget in the endpoint. In backend/app/api/endpoints/ai_sessions.py, replace the resolve_session endpoint (lines 413-446):
@router.post("/{session_id}/resolve", response_model=SessionCloseResponse)
@limiter.limit("15/minute")
async def resolve_session(
request: Request,
session_id: UUID,
data: ResolveSessionRequest,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_engineer_or_admin),
):
"""Resolve a session. Returns immediately; use /documentation/stream for ticket notes."""
try:
result = await flowpilot_engine.resolve_session(
session_id=session_id,
request=data,
user_id=current_user.id,
db=db,
)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
except PermissionError as e:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))
await db.commit()
# Fire-and-forget: resolution outputs + embedding (don't block the response)
import asyncio
async def _post_resolve_tasks():
try:
from app.services.resolution_output_generator import ResolutionOutputGenerator
gen = ResolutionOutputGenerator(db)
await gen.generate_all(session_id)
except Exception:
logger.exception(f"Failed to generate resolution outputs for session {session_id}")
asyncio.create_task(_post_resolve_tasks())
return result
- Step 4: Commit
git add backend/app/api/endpoints/ai_sessions.py backend/app/schemas/ai_session.py frontend/src/types/ai-session.ts
git commit -m "feat: make resolve endpoint non-blocking, documentation optional"
Task 9: Frontend — Add streamDocumentation to API Client
Files:
-
Modify:
frontend/src/api/aiSessions.ts:95-100 -
Step 1: Add the streaming API method
After the getDocumentation method (around line 100), add:
async streamDocumentation(
sessionId: string,
onChunk: (text: string) => void,
onDone: () => void,
onError: (error: string) => void,
): Promise<void> {
const token = localStorage.getItem('access_token')
const baseUrl = import.meta.env.VITE_API_URL || ''
try {
const response = await fetch(
`${baseUrl}/api/ai-sessions/${sessionId}/documentation/stream`,
{
headers: {
Authorization: `Bearer ${token}`,
},
},
)
if (!response.ok) {
onError(`HTTP ${response.status}`)
return
}
const reader = response.body?.getReader()
if (!reader) {
onError('No response body')
return
}
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6)
if (data === '[DONE]') {
onDone()
return
}
if (data.startsWith('[ERROR]')) {
onError(data.slice(8))
return
}
onChunk(data)
}
}
}
// Stream ended without [DONE]
onDone()
} catch (err) {
onError(err instanceof Error ? err.message : 'Stream failed')
}
},
- Step 2: Commit
git add frontend/src/api/aiSessions.ts
git commit -m "feat: add streamDocumentation SSE client for ticket notes"
Task 10: Frontend — Rewrite ConcludeSessionModal for Two-Phase Flow
Files:
-
Modify:
frontend/src/components/assistant/ConcludeSessionModal.tsx -
Modify:
frontend/src/pages/AssistantChatPage.tsx:396-414 -
Step 1: Update the ConcludeSessionModal props and state
The modal's onConclude prop currently returns Promise<string> (the summary text). We need to change it to return Promise<{ sessionId: string }> so the modal can initiate streaming itself.
Update the interface and imports at the top of ConcludeSessionModal.tsx:
import { useState, useEffect, useRef } from 'react'
import {
X,
CheckCircle2,
ArrowUpRight,
Pause,
Loader2,
Copy,
Check,
RefreshCw,
ClipboardList,
Sparkles,
AlertTriangle,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { MarkdownContent } from '@/components/ui/MarkdownContent'
import { aiSessionsApi } from '@/api/aiSessions'
import { toast } from '@/lib/toast'
type ConclusionOutcome = 'resolved' | 'escalated' | 'paused'
interface ConcludeSessionModalProps {
isOpen: boolean
onClose: () => void
onConclude: (outcome: ConclusionOutcome, notes: string) => Promise<string>
onResumeNew: (summary: string) => void
chatTitle: string
sessionId: string | null
}
(Added sessionId prop, useRef import, AlertTriangle icon, aiSessionsApi import, toast import.)
- Step 2: Rewrite the component state and handleGenerate
Replace the state declarations and handleGenerate function (lines 66-106):
export function ConcludeSessionModal({
isOpen,
onClose,
onConclude,
onResumeNew,
chatTitle,
sessionId,
}: ConcludeSessionModalProps) {
const [step, setStep] = useState<ModalStep>('select-outcome')
const [outcome, setOutcome] = useState<ConclusionOutcome | null>(null)
const [notes, setNotes] = useState('')
const [summary, setSummary] = useState('')
const [generating, setGenerating] = useState(false)
const [streaming, setStreaming] = useState(false)
const [streamError, setStreamError] = useState<string | null>(null)
const [copied, setCopied] = useState(false)
const [error, setError] = useState<string | null>(null)
const summaryRef = useRef('')
// Reset state when modal opens
useEffect(() => {
if (isOpen) {
setStep('select-outcome')
setOutcome(null)
setNotes('')
setSummary('')
summaryRef.current = ''
setGenerating(false)
setStreaming(false)
setStreamError(null)
setCopied(false)
setError(null)
}
}, [isOpen])
const handleOutcomeSelect = (selected: ConclusionOutcome) => {
setOutcome(selected)
setStep('add-notes')
}
const handleGenerate = async () => {
if (!outcome) return
setGenerating(true)
setError(null)
try {
// Phase 1: Resolve/escalate/pause the session (fast)
await onConclude(outcome, notes)
// Phase 2: Transition to summary step immediately
setStep('summary')
setGenerating(false)
// For resolved sessions, stream ticket notes
if (outcome === 'resolved' && sessionId) {
setStreaming(true)
setStreamError(null)
summaryRef.current = ''
aiSessionsApi.streamDocumentation(
sessionId,
(chunk) => {
summaryRef.current += chunk
setSummary(summaryRef.current)
},
() => {
setStreaming(false)
},
(err) => {
setStreaming(false)
setStreamError(err)
// Try non-streaming fallback
aiSessionsApi.getDocumentation(sessionId).then((doc) => {
const fallback = `## Problem Summary\n${doc.problem_summary}\n\n## Steps Taken\n${doc.diagnostic_steps.map(s => `- ${s.description}`).join('\n')}\n\n## Resolution\n${doc.resolution_summary || 'See conversation'}\n\n## Next Steps\nNone`
setSummary(fallback)
setStreamError(null)
}).catch(() => {
// Final fallback — just show what we have
if (!summaryRef.current) {
setSummary('Documentation generation failed. You can copy the conversation from the chat.')
}
})
},
)
} else if (outcome === 'escalated') {
setSummary('Session escalated. Ticket notes will be generated when the session is resolved.')
} else {
setSummary('Session paused. Progress saved — you can resume anytime.')
}
} catch {
setError('Failed to conclude session. Please try again.')
setGenerating(false)
}
}
- Step 3: Update the summary step rendering
Replace the summary step content (lines 298-325) with:
{/* Step 3: Summary */}
{step === 'summary' && (
<div className="space-y-4">
{/* Outcome badge */}
{selectedOutcome && (
<div className={cn('px-3 py-1.5 rounded-lg inline-flex items-center gap-2 text-xs font-sans text-xs', selectedOutcome.bg, selectedOutcome.border, 'border')}>
<selectedOutcome.icon size={14} className={selectedOutcome.color} />
<span className={selectedOutcome.color}>{selectedOutcome.label}</span>
</div>
)}
{/* Generated ticket notes */}
<div
className="rounded-xl border p-5 bg-card"
style={{ borderColor: 'var(--color-border-default)' }}
>
<div className="flex items-center justify-between mb-3">
<span className="font-sans text-xs text-[0.625rem] uppercase tracking-widest text-muted-foreground flex items-center gap-1.5">
<Sparkles size={10} className="text-primary" />
Ticket Notes
</span>
{streaming && (
<Loader2 size={14} className="animate-spin text-primary" />
)}
</div>
{/* Streaming content or skeleton */}
{summary ? (
<div className="prose-sm text-foreground">
<MarkdownContent content={summary} className="text-[0.8125rem] leading-relaxed" />
</div>
) : streaming ? (
<div className="space-y-3 animate-pulse">
<div className="h-4 bg-elevated rounded w-1/3" />
<div className="h-3 bg-elevated rounded w-full" />
<div className="h-3 bg-elevated rounded w-5/6" />
<div className="h-4 bg-elevated rounded w-1/4 mt-4" />
<div className="h-3 bg-elevated rounded w-full" />
<div className="h-3 bg-elevated rounded w-4/5" />
</div>
) : streamError ? (
<div className="flex items-center gap-2 text-sm text-amber-400">
<AlertTriangle size={14} />
{streamError}
</div>
) : null}
</div>
</div>
)}
- Step 4: Update the footer for the summary step
Replace the summary step footer (lines 373-416) with:
{step === 'summary' && (
<>
<div className="flex items-center gap-2">
{outcome === 'paused' && (
<button
onClick={handleResumeNew}
className="flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium text-blue-400 bg-blue-400/10 border border-blue-400/20 hover:bg-blue-400/15 transition-all"
>
<RefreshCw size={14} />
Resume in New Chat
</button>
)}
</div>
<div className="flex items-center gap-2">
{summary && !streaming && (
<button
onClick={handleCopy}
className={cn(
'flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm font-semibold transition-all',
copied
? 'bg-emerald-400/15 text-emerald-400 border border-emerald-400/30'
: 'bg-primary text-white hover:brightness-110 active:scale-[0.98]'
)}
>
{copied ? (
<>
<Check size={15} />
Copied!
</>
) : (
<>
<Copy size={15} />
Copy to Clipboard
</>
)}
</button>
)}
<button
onClick={onClose}
className="px-4 py-2.5 rounded-lg text-sm text-muted-foreground hover:text-foreground bg-input border border-border hover:border-border-hover transition-all"
>
Done
</button>
</div>
</>
)}
- Step 5: Update handleConclude in AssistantChatPage
In frontend/src/pages/AssistantChatPage.tsx, update handleConclude (lines 396-414) to return immediately without waiting for documentation:
const handleConclude = async (outcome: ConclusionOutcome, _notes: string): Promise<string> => {
if (!activeChatId) throw new Error('No active chat')
if (outcome === 'resolved') {
await aiSessionsApi.resolveSession(activeChatId, {
resolution_summary: _notes || 'Resolved via assistant chat',
})
return activeChatId
} else if (outcome === 'escalated') {
await aiSessionsApi.escalateSession(activeChatId, {
escalation_reason: _notes || 'Escalated from assistant chat',
})
return activeChatId
} else {
await aiSessionsApi.pauseSession(activeChatId)
return activeChatId
}
}
- Step 6: Pass sessionId to ConcludeSessionModal
Find where ConcludeSessionModal is rendered (around line 812-818) and add the sessionId prop:
<ConcludeSessionModal
isOpen={showConclude}
onClose={() => setShowConclude(false)}
onConclude={handleConclude}
onResumeNew={handleResumeNew}
chatTitle={chats.find(c => c.id === activeChatId)?.title ?? 'Chat'}
sessionId={activeChatId}
/>
- Step 7: Verify the full flow
- Open assistant chat, have a conversation
- Click Conclude → Resolved → add optional notes → Generate Summary
- Modal should transition to summary step IMMEDIATELY
- Skeleton loading should appear, then ticket notes stream in progressively
- When streaming completes, "Copy to Clipboard" button appears
- Click copy → toast "Ticket notes copied" → clipboard has the markdown
- Step 8: Commit
git add frontend/src/components/assistant/ConcludeSessionModal.tsx frontend/src/pages/AssistantChatPage.tsx
git commit -m "feat: two-phase resolve with streaming ticket notes generation"
Task 11: Frontend Build Verification
Files: None (verification only)
- Step 1: Run the frontend build
export NVM_DIR="$HOME/.nvm" && source "$NVM_DIR/nvm.sh" && nvm use 20
cd frontend && npm run build
Expected: Build succeeds with no TypeScript errors. If OOM occurs (VPS memory constraint), run npx tsc --noEmit instead as a lighter check.
- Step 2: Fix any TypeScript errors
If errors appear, fix unused imports, missing props, or type mismatches. Common issues:
-
ConcludeSessionModalnow requiressessionIdprop — verify all usages pass it -
SessionCloseResponse.documentationis now optional — verify.documentation?.optional chaining everywhere it's accessed -
Unused
Ximport in TaskLane after replacing withPanelRightClose -
Step 3: Commit fixes if needed
git add -A
git commit -m "fix: resolve TypeScript errors from task lane and resolve docs changes"
Task 12: Remove Debug Logging from AssistantChatPage
Files:
-
Modify:
frontend/src/pages/AssistantChatPage.tsx -
Step 1: Remove the console.log statements added during debugging
Remove these debug lines:
console.log('[AssistantChat] Mount restore check — ...')(around line 105)console.log('[AssistantChat] Calling selectChat to restore:', ...)(around line 107)console.log('[AssistantChat] selectChat called with:', ...)(around line 212)console.log('[AssistantChat] getSession response — ...')(around line 220)console.error('[AssistantChat] Failed to load chat session:', err)(around line 243)
Keep the catch (err) form (not bare catch) but remove the console.error line. Revert to bare catch:
} catch {
setMessages([])
}
- Step 2: Commit
git add frontend/src/pages/AssistantChatPage.tsx
git commit -m "chore: remove debug logging from AssistantChatPage"
Task 13: Final Integration Test
Files: None (manual testing)
- Step 1: Test task lane minimize/reopen flow
- Start or continue a chat that has task lane items
- Click PanelRightClose — panel collapses, "Tasks (N)" pill appears in toolbar
- Click pill — panel reopens with previous state (including partial answers)
- Navigate away from assistant page and back — task lane restores from backend
- Close and reopen — state persists
- Step 2: Test resolve with streaming ticket notes
- Have a multi-message conversation
- Click Conclude → Resolved → optional notes → Generate Summary
- Modal transitions instantly to summary step with skeleton
- Ticket notes stream in with four sections: Problem Summary, Steps Taken, Resolution, Next Steps
- Copy button appears after streaming completes
- Click Copy → toast confirms → paste into text editor to verify markdown
- Step 3: Test fallback behavior
- If streaming fails (e.g., AI key missing), verify fallback to non-streaming doc generation
- If both fail, verify "Documentation generation failed" message appears
- Step 4: Push to PR
git push