From af2a41830ce6a5a96f062817be7538d4cf8f6236 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Sat, 28 Mar 2026 22:33:47 +0000 Subject: [PATCH] docs: add spec and implementation plan for task lane minimize + resolve streaming Co-Authored-By: Claude Opus 4.6 (1M context) --- ...3-28-tasklane-minimize-and-resolve-docs.md | 1066 +++++++++++++++++ ...sklane-minimize-and-resolve-docs-design.md | 158 +++ 2 files changed, 1224 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-28-tasklane-minimize-and-resolve-docs.md create mode 100644 docs/superpowers/specs/2026-03-28-tasklane-minimize-and-resolve-docs-design.md diff --git a/docs/superpowers/plans/2026-03-28-tasklane-minimize-and-resolve-docs.md b/docs/superpowers/plans/2026-03-28-tasklane-minimize-and-resolve-docs.md new file mode 100644 index 00000000..42a7d66c --- /dev/null +++ b/docs/superpowers/plans/2026-03-28-tasklane-minimize-and-resolve-docs.md @@ -0,0 +1,1066 @@ +# 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 + +``` + +- [ ] **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) && ( + + )} +``` + +- [ ] **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: \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 { + 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` (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 + 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('select-outcome') + const [outcome, setOutcome] = useState(null) + const [notes, setNotes] = useState('') + const [summary, setSummary] = useState('') + const [generating, setGenerating] = useState(false) + const [streaming, setStreaming] = useState(false) + const [streamError, setStreamError] = useState(null) + const [copied, setCopied] = useState(false) + const [error, setError] = useState(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' && ( +
+ {/* Outcome badge */} + {selectedOutcome && ( +
+ + {selectedOutcome.label} +
+ )} + + {/* Generated ticket notes */} +
+
+ + + Ticket Notes + + {streaming && ( + + )} +
+ + {/* Streaming content or skeleton */} + {summary ? ( +
+ +
+ ) : streaming ? ( +
+
+
+
+
+
+
+
+ ) : streamError ? ( +
+ + {streamError} +
+ ) : null} +
+
+ )} +``` + +- [ ] **Step 4: Update the footer for the summary step** + +Replace the summary step footer (lines 373-416) with: + +```tsx + {step === 'summary' && ( + <> +
+ {outcome === 'paused' && ( + + )} +
+
+ {summary && !streaming && ( + + )} + +
+ + )} +``` + +- [ ] **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 => { + 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 + 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 +``` diff --git a/docs/superpowers/specs/2026-03-28-tasklane-minimize-and-resolve-docs-design.md b/docs/superpowers/specs/2026-03-28-tasklane-minimize-and-resolve-docs-design.md new file mode 100644 index 00000000..ac1e5b09 --- /dev/null +++ b/docs/superpowers/specs/2026-03-28-tasklane-minimize-and-resolve-docs-design.md @@ -0,0 +1,158 @@ +# Task Lane Minimize/Reopen + Resolve Documentation Streaming + +**Date:** 2026-03-28 +**Status:** Approved + +--- + +## Problem + +Two UX issues in the Assistant Chat page: + +1. **Task Lane close destroys state.** The X button calls `clearTaskState()` and hides the panel. There's no way to bring it back without getting a new AI response with markers. Engineers lose in-progress task responses. + +2. **Conclude → Resolved hangs.** The resolve flow blocks on an LLM call to generate documentation. The modal shows "Generating..." for 2-5s+ with no progressive feedback. The generated output needs to be structured ticket notes engineers can copy into their PSA. + +--- + +## Feature 1: Task Lane Minimize/Reopen + +### Close Button Change + +- Replace the X icon (`X` from Lucide) with `PanelRightClose` to signal "collapse" not "destroy." +- On click: set `showTaskLane = false`. Do NOT call `clearTaskState(sessionId)`. +- Task state (questions, actions, user responses) remains in sessionStorage and backend `pending_task_lane`. + +### Reopen Pill on Input Toolbar + +- New pill button in the chat input toolbar row, alongside Attach / Paste Logs / Conclude. +- **Visibility condition:** `(activeQuestions.length > 0 || activeActions.length > 0) && !showTaskLane` +- **Label:** `Tasks (N)` where N = `activeQuestions.length + activeActions.length` +- **Icon:** `ListChecks` from Lucide +- **Action:** `setShowTaskLane(true)` +- **Style:** Same ghost button style as Attach/Paste Logs — muted text, hover highlight. + +### Files Changed + +| File | Change | +|------|--------| +| `frontend/src/components/assistant/TaskLane.tsx` | Replace `X` icon with `PanelRightClose` in header | +| `frontend/src/pages/AssistantChatPage.tsx` | Remove `clearTaskState()` from onClose handler; add Tasks pill to input toolbar | + +--- + +## Feature 2: Resolve with Streaming Documentation + +### Two-Phase Resolve Flow + +**Phase 1 — Instant resolve:** + +1. User clicks Resolve in `ConcludeSessionModal`, enters optional notes, confirms. +2. Frontend calls `POST /ai-sessions/{id}/resolve` with `{ resolution_summary }`. +3. Backend sets session status to `resolved`, saves summary, returns immediately. No LLM call on this path. +4. Modal transitions to the summary step instantly, showing a skeleton loading state for "Ticket Notes." + +**Phase 2 — Streamed doc generation:** + +1. Immediately after phase 1, frontend opens an SSE connection to `GET /ai-sessions/{id}/documentation/stream`. +2. Backend streams the structured ticket notes as they generate, token by token. +3. Frontend renders chunks progressively into the summary step using `MarkdownContent`. +4. When stream completes, show a "Copy to Clipboard" button. + +### Ticket Notes Format + +The LLM generates four structured sections: + +```markdown +## Problem Summary +[What the engineer reported / intake context] + +## Steps Taken +[Key diagnostic steps and findings from conversation] + +## Resolution +[What fixed it / final action taken] + +## Next Steps +[Follow-up items, if any — or "None"] +``` + +### Fallback Behavior + +- If SSE stream fails or times out (30s): fall back to non-streaming `GET /ai-sessions/{id}/documentation`. +- If that also fails: show "Documentation unavailable" with a "Copy Conversation" button that formats the raw `conversation_messages` into a basic structured summary (no LLM, pure template). + +### Copy to Clipboard + +- Prominent button below the rendered ticket notes. +- Copies the full markdown text. +- Toast confirmation: "Ticket notes copied." + +### AI Optimizations + +**1. Streaming (SSE endpoint):** +- New endpoint: `GET /ai-sessions/{id}/documentation/stream` +- Returns `text/event-stream` via FastAPI `StreamingResponse`. +- Uses Anthropic's `client.messages.stream()` for token-level streaming. +- Time-to-first-byte drops from ~2-5s to ~200ms. + +**2. Prompt caching:** +- Apply `cache_control: {"type": "ephemeral"}` to the system prompt and conversation context prefix in the documentation generation call. +- Pattern already exists in `assistant_chat_service.py` (`_call_anthropic_cached`). +- Repeat calls for the same session (regenerate, different doc types) hit cache — up to 85% faster, 90% cheaper on input tokens. + +**3. Client reuse:** +- Singleton `AsyncAnthropic` client instead of creating a new one per call. +- Eliminates connection setup overhead. + +**Model tier:** Haiku (already configured as `quick_action` tier) — fastest model, appropriate for summarization. + +### Backend Changes + +| File | Change | +|------|--------| +| `backend/app/api/endpoints/ai_sessions.py` | Modify `resolve_session` to only set status + save summary (remove the blocking `get_session_documentation` call); add new SSE streaming endpoint for doc generation | +| `backend/app/services/flowpilot_engine.py` | Add `stream_session_documentation()` generator function using `client.messages.stream()` | +| `backend/app/core/ai_provider.py` | Add `generate_text_stream()` method returning async iterator; singleton client | +| `backend/app/services/flowpilot_engine.py` | Add prompt caching to `_build_status_update_prompt` call path | + +### Frontend Changes + +| File | Change | +|------|--------| +| `frontend/src/components/assistant/ConcludeSessionModal.tsx` | Two-phase flow: instant resolve → streaming doc render with skeleton → copy button | +| `frontend/src/api/aiSessions.ts` | Add `streamDocumentation(sessionId)` using fetch + ReadableStream | +| `frontend/src/pages/AssistantChatPage.tsx` | Update `handleConclude` to not await documentation | + +### ConcludeSessionModal Summary Step Layout + +``` +┌─────────────────────────────────────┐ +│ Session Resolved │ +│ │ +│ ┌─ Ticket Notes ─────────────────┐ │ +│ │ ## Problem Summary │ │ +│ │ [streaming text...] │ │ +│ │ │ │ +│ │ ## Steps Taken │ │ +│ │ [streaming text...] │ │ +│ │ │ │ +│ │ ## Resolution │ │ +│ │ [streaming text...] │ │ +│ │ │ │ +│ │ ## Next Steps │ │ +│ │ [streaming text...] │ │ +│ └────────────────────────────────┘ │ +│ │ +│ [ Copy to Clipboard ] [ Done ] │ +└─────────────────────────────────────┘ +``` + +--- + +## Out of Scope + +- Client-facing update generation (future feature, different audience prompt) +- Direct PSA posting (requires ConnectWise integration to be wired to sessions) +- Task lane drag-to-reorder +- Task lane persistence across browser tabs (sessionStorage is per-tab by design)