1067 lines
35 KiB
Markdown
1067 lines
35 KiB
Markdown
# 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:
|
|
|
|
```tsx
|
|
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:
|
|
|
|
```tsx
|
|
<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**
|
|
|
|
```bash
|
|
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):
|
|
|
|
```tsx
|
|
onClose={() => {
|
|
setShowTaskLane(false)
|
|
if (activeChatId) clearTaskState(activeChatId)
|
|
}}
|
|
```
|
|
|
|
Replace with:
|
|
|
|
```tsx
|
|
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**
|
|
|
|
1. Open assistant chat, trigger task lane with questions/actions
|
|
2. Type partial answers into the task lane
|
|
3. Click the PanelRightClose button
|
|
4. 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**
|
|
|
|
```bash
|
|
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):
|
|
|
|
```tsx
|
|
import { Sparkles, Send, Loader2, Flag, MessageSquare, Paperclip, Terminal, X, RotateCcw, ImagePlus } from 'lucide-react'
|
|
```
|
|
|
|
Add `ListChecks`:
|
|
|
|
```tsx
|
|
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:
|
|
|
|
```tsx
|
|
{!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**
|
|
|
|
1. Open assistant chat, trigger task lane
|
|
2. Click PanelRightClose — task lane collapses
|
|
3. Confirm "Tasks (N)" pill appears in the input toolbar
|
|
4. Click the pill — task lane reopens with previous state intact
|
|
5. Confirm pill disappears when task lane is open
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
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):
|
|
|
|
```python
|
|
# 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):
|
|
|
|
```python
|
|
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**
|
|
|
|
```bash
|
|
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:
|
|
|
|
```python
|
|
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):
|
|
|
|
```python
|
|
from collections.abc import AsyncIterator
|
|
```
|
|
|
|
- [ ] **Step 2: Implement streaming in AnthropicProvider**
|
|
|
|
Add the streaming method to `AnthropicProvider` after `generate_text` (after line 210):
|
|
|
|
```python
|
|
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**
|
|
|
|
```bash
|
|
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` (after `generate_status_update`, around line 1012)
|
|
|
|
- [ ] **Step 1: Add the streaming ticket notes function**
|
|
|
|
After the `generate_status_update` function (after line 1011), add:
|
|
|
|
```python
|
|
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:
|
|
|
|
```python
|
|
from collections.abc import AsyncIterator
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
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` (after `get_documentation`, around line 925)
|
|
|
|
- [ ] **Step 1: Add the SSE streaming endpoint**
|
|
|
|
After the `get_documentation` endpoint (after line 924), add:
|
|
|
|
```python
|
|
@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):
|
|
|
|
```python
|
|
from app.models.ai_session import AISession
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
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:
|
|
|
|
```python
|
|
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:
|
|
|
|
```typescript
|
|
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):
|
|
|
|
```python
|
|
@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**
|
|
|
|
```bash
|
|
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:
|
|
|
|
```typescript
|
|
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**
|
|
|
|
```bash
|
|
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`:
|
|
|
|
```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):
|
|
|
|
```tsx
|
|
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:
|
|
|
|
```tsx
|
|
{/* 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:
|
|
|
|
```tsx
|
|
{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:
|
|
|
|
```tsx
|
|
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:
|
|
|
|
```tsx
|
|
<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**
|
|
|
|
1. Open assistant chat, have a conversation
|
|
2. Click Conclude → Resolved → add optional notes → Generate Summary
|
|
3. Modal should transition to summary step IMMEDIATELY
|
|
4. Skeleton loading should appear, then ticket notes stream in progressively
|
|
5. When streaming completes, "Copy to Clipboard" button appears
|
|
6. Click copy → toast "Ticket notes copied" → clipboard has the markdown
|
|
|
|
- [ ] **Step 8: Commit**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
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:
|
|
- `ConcludeSessionModal` now requires `sessionId` prop — verify all usages pass it
|
|
- `SessionCloseResponse.documentation` is now optional — verify `.documentation?.` optional chaining everywhere it's accessed
|
|
- Unused `X` import in TaskLane after replacing with `PanelRightClose`
|
|
|
|
- [ ] **Step 3: Commit fixes if needed**
|
|
|
|
```bash
|
|
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`:
|
|
|
|
```tsx
|
|
} catch {
|
|
setMessages([])
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
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**
|
|
|
|
1. Start or continue a chat that has task lane items
|
|
2. Click PanelRightClose — panel collapses, "Tasks (N)" pill appears in toolbar
|
|
3. Click pill — panel reopens with previous state (including partial answers)
|
|
4. Navigate away from assistant page and back — task lane restores from backend
|
|
5. Close and reopen — state persists
|
|
|
|
- [ ] **Step 2: Test resolve with streaming ticket notes**
|
|
|
|
1. Have a multi-message conversation
|
|
2. Click Conclude → Resolved → optional notes → Generate Summary
|
|
3. Modal transitions instantly to summary step with skeleton
|
|
4. Ticket notes stream in with four sections: Problem Summary, Steps Taken, Resolution, Next Steps
|
|
5. Copy button appears after streaming completes
|
|
6. Click Copy → toast confirms → paste into text editor to verify markdown
|
|
|
|
- [ ] **Step 3: Test fallback behavior**
|
|
|
|
1. If streaming fails (e.g., AI key missing), verify fallback to non-streaming doc generation
|
|
2. If both fail, verify "Documentation generation failed" message appears
|
|
|
|
- [ ] **Step 4: Push to PR**
|
|
|
|
```bash
|
|
git push
|
|
```
|