# 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
```
