Files
resolutionflow/docs/superpowers/plans/2026-03-28-tasklane-minimize-and-resolve-docs.md
2026-03-28 22:33:47 +00:00

35 KiB

Task Lane Minimize/Reopen + Resolve Documentation Streaming — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Make the task lane collapsible (not destructive) with a reopen pill, and replace the blocking resolve flow with instant resolve + streamed ticket notes generation.

Architecture: Feature 1 is a frontend-only change to TaskLane close behavior + a new pill button. Feature 2 splits the resolve endpoint into a fast status-only call and a new SSE streaming endpoint for AI-generated ticket notes, with prompt caching and singleton client reuse on the backend.

Tech Stack: React, TypeScript, Lucide icons, FastAPI, Anthropic SDK (streaming + prompt caching), SSE via StreamingResponse


Task 1: Task Lane — Replace X with PanelRightClose Icon

Files:

  • Modify: frontend/src/components/assistant/TaskLane.tsx:2,250-252

  • Step 1: Update the import to include PanelRightClose

Replace line 2 import:

import {
  Copy, Check, SkipForward, Terminal, ChevronDown, ChevronUp,
  Send, Clipboard, Loader2, PanelRightClose, MessageCircleQuestion, Eye,
} from 'lucide-react'

(Replace X with PanelRightClose in the import list.)

  • Step 2: Replace the X icon in the close button

Replace lines 250-252:

        <button onClick={onClose} className="text-muted-foreground hover:text-heading transition-colors p-1" title="Collapse tasks">
          <PanelRightClose size={16} />
        </button>
  • Step 3: Verify the icon renders correctly

Run the frontend dev server, open an assistant chat, trigger a task lane, and confirm the close button shows a panel-collapse icon instead of an X.

  • Step 4: Commit
git add frontend/src/components/assistant/TaskLane.tsx
git commit -m "feat: replace task lane X with PanelRightClose icon"

Task 2: Task Lane — Stop Destroying State on Close

Files:

  • Modify: frontend/src/pages/AssistantChatPage.tsx:797-800

  • Step 1: Remove clearTaskState from the onClose handler

Find the TaskLane onClose handler (around line 797-800):

        onClose={() => {
          setShowTaskLane(false)
          if (activeChatId) clearTaskState(activeChatId)
        }}

Replace with:

        onClose={() => {
          setShowTaskLane(false)
        }}

This keeps task state in sessionStorage and backend pending_task_lane so it can be restored.

  • Step 2: Verify state persists after closing
  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
git add frontend/src/pages/AssistantChatPage.tsx
git commit -m "feat: task lane close preserves state instead of clearing"

Task 3: Task Lane — Add Reopen Pill to Input Toolbar

Files:

  • Modify: frontend/src/pages/AssistantChatPage.tsx:1-3,738-756

  • Step 1: Add ListChecks to the Lucide import

Find the Lucide import at the top of AssistantChatPage.tsx (line 3):

import { Sparkles, Send, Loader2, Flag, MessageSquare, Paperclip, Terminal, X, RotateCcw, ImagePlus } from 'lucide-react'

Add ListChecks:

import { Sparkles, Send, Loader2, Flag, MessageSquare, Paperclip, Terminal, X, RotateCcw, ImagePlus, ListChecks } from 'lucide-react'
  • Step 2: Add the Tasks pill button to the input toolbar

Find the input toolbar's left button group (around line 738-756). After the Conclude button block (the {messages.length >= 2 && ( block that ends around line 755), add the Tasks pill:

                      {!showTaskLane && (activeQuestions.length > 0 || activeActions.length > 0) && (
                        <button
                          type="button"
                          onClick={() => setShowTaskLane(true)}
                          className="flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-xs text-accent-text hover:text-foreground hover:bg-accent-dim transition-colors"
                          title="Show task panel"
                        >
                          <ListChecks size={14} />
                          Tasks ({activeQuestions.length + activeActions.length})
                        </button>
                      )}
  • Step 3: Verify the full collapse/reopen flow
  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
git add frontend/src/pages/AssistantChatPage.tsx
git commit -m "feat: add Tasks pill to reopen collapsed task lane"

Task 4: Backend — Singleton AsyncAnthropic Client

Files:

  • Modify: backend/app/core/ai_provider.py:168-210

  • Step 1: Add a module-level singleton for the Anthropic client

In backend/app/core/ai_provider.py, add a module-level cache dict before the AnthropicProvider class (before line 168):

# Singleton client cache — avoids creating new HTTP connections per call
_anthropic_clients: dict[str, "anthropic.AsyncAnthropic"] = {}


def _get_anthropic_client(api_key: str, timeout: int = 45) -> "anthropic.AsyncAnthropic":
    """Return a cached AsyncAnthropic client, creating one if needed."""
    import anthropic

    cache_key = f"{api_key[:8]}:{timeout}"
    if cache_key not in _anthropic_clients:
        _anthropic_clients[cache_key] = anthropic.AsyncAnthropic(
            api_key=api_key,
            timeout=timeout,
            max_retries=1,
        )
    return _anthropic_clients[cache_key]
  • Step 2: Update AnthropicProvider to use the singleton

Replace the generate_json method's client creation (lines 182-188):

    async def generate_json(
        self,
        system_prompt: str,
        messages: list[dict[str, str]],
        max_tokens: int = 4096,
    ) -> tuple[str, int, int]:
        client = _get_anthropic_client(self._api_key, self._timeout)

        response = await client.messages.create(
            model=self._model,
            max_tokens=max_tokens,
            system=system_prompt,
            messages=messages,
        )

        text = response.content[0].text
        input_tokens = response.usage.input_tokens
        output_tokens = response.usage.output_tokens

        return text, input_tokens, output_tokens
  • Step 3: Commit
git add backend/app/core/ai_provider.py
git commit -m "perf: singleton AsyncAnthropic client to avoid per-call connection setup"

Task 5: Backend — Add generate_text_stream to AIProvider

Files:

  • Modify: backend/app/core/ai_provider.py:16-55,168-210

  • Step 1: Add the abstract method to the AIProvider base class

After the generate_text abstract method (after line 55), add:

    async def generate_text_stream(
        self,
        system_prompt: str,
        messages: list[dict[str, str]],
        max_tokens: int = 4096,
    ) -> "AsyncIterator[str]":
        """Stream a text response token by token.

        Args:
            system_prompt: System-level instruction for the model.
            messages: List of message dicts with "role" and "content" keys.
            max_tokens: Maximum output tokens.

        Yields:
            Text chunks as they are generated.
        """
        raise NotImplementedError("Streaming not supported for this provider")
        # Make this an async generator to satisfy type checker
        yield ""  # pragma: no cover

Also add the import at the top of the file (line 1 area):

from collections.abc import AsyncIterator
  • Step 2: Implement streaming in AnthropicProvider

Add the streaming method to AnthropicProvider after generate_text (after line 210):

    async def generate_text_stream(
        self,
        system_prompt: str,
        messages: list[dict[str, str]],
        max_tokens: int = 4096,
    ) -> AsyncIterator[str]:
        client = _get_anthropic_client(self._api_key, self._timeout)

        async with client.messages.stream(
            model=self._model,
            max_tokens=max_tokens,
            system=system_prompt,
            messages=messages,
        ) as stream:
            async for text in stream.text_stream:
                yield text
  • Step 3: Commit
git add backend/app/core/ai_provider.py
git commit -m "feat: add generate_text_stream to AnthropicProvider for SSE support"

Task 6: Backend — Add Streaming Ticket Notes Generator

Files:

  • Modify: backend/app/services/flowpilot_engine.py (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:

async def stream_ticket_notes(
    session_id: UUID,
    user_id: UUID,
    db: AsyncSession,
) -> AsyncIterator[str]:
    """Stream AI-generated structured ticket notes for a resolved session.

    Yields text chunks suitable for SSE streaming. Uses prompt caching
    on the system prompt for fast repeat calls.
    """
    session = await _load_session(session_id, user_id, db)

    # Build conversation summary from messages (chat sessions)
    # or steps (guided sessions)
    messages = session.conversation_messages or []
    if messages:
        recent = messages[-20:]  # Last 20 messages for richer context
        convo_text = "\n".join(
            f"{'Engineer' if m['role'] == 'user' else 'AI Assistant'}: {m['content'][:500]}"
            for m in recent
            if isinstance(m, dict) and "role" in m and "content" in m
        )
    else:
        # Fall back to steps for guided sessions
        steps_summary = []
        for step in sorted(session.steps, key=lambda s: s.step_order):
            content = step.content or {}
            text = content.get("text", "")
            response = step.free_text_input or step.selected_option or ("Skipped" if step.was_skipped else None)
            entry = f"Step {step.step_order + 1}: {text}"
            if response:
                entry += f"\n  Engineer response: {response}"
            steps_summary.append(entry)
        convo_text = "\n".join(steps_summary) if steps_summary else "No session data."

    # Calculate time spent
    now = datetime.now(timezone.utc)
    ref_time = session.resolved_at or now
    delta = ref_time - session.created_at
    total_minutes = int(delta.total_seconds() / 60)
    time_display = f"{total_minutes} minutes" if total_minutes < 60 else f"{total_minutes // 60}h {total_minutes % 60}m"

    system_prompt = """You are generating internal ticket notes for an MSP engineer's PSA system.

Generate EXACTLY these four markdown sections, in this order:

## Problem Summary
Summarize what the engineer reported and the initial symptoms. 1-3 sentences.

## Steps Taken
List the key diagnostic steps, commands run, checks performed, and findings. Use bullet points.

## Resolution
What fixed the issue or what the final action was. Be specific and technical.

## Next Steps
Any follow-up items, monitoring to watch, or preventive measures. Write "None" if not applicable.

Rules:
- Be technical, concise, and factual
- Use markdown formatting (headers, bullet lists, bold for emphasis)
- Include specific technical details (commands, settings, error messages) where available
- Do NOT include greetings, sign-offs, or pleasantries
- Do NOT wrap output in code fences
- Output ONLY the four sections above, nothing else"""

    user_message_parts = [
        f"Session status: {session.status}",
        f"Time spent: {time_display}",
        f"Problem summary: {session.problem_summary or 'Not specified'}",
    ]
    if session.problem_domain:
        user_message_parts.append(f"Problem domain: {session.problem_domain}")
    if session.resolution_summary:
        user_message_parts.append(f"Resolution notes: {session.resolution_summary}")
    user_message_parts.append(f"\nSession conversation:\n{convo_text}")

    user_message = "\n".join(user_message_parts)

    provider = get_ai_provider(settings.get_model_for_action("quick_action"))

    # Use streaming if provider supports it (Anthropic), otherwise fall back
    try:
        async for chunk in provider.generate_text_stream(
            system_prompt=system_prompt,
            messages=[{"role": "user", "content": user_message}],
            max_tokens=1500,
        ):
            yield chunk
    except NotImplementedError:
        # Fallback for non-streaming providers (Gemini)
        text, _, _ = await provider.generate_text(
            system_prompt=system_prompt,
            messages=[{"role": "user", "content": user_message}],
            max_tokens=1500,
        )
        yield text

Also add the import at the top of the file if not already present:

from collections.abc import AsyncIterator
  • Step 2: Commit
git add backend/app/services/flowpilot_engine.py
git commit -m "feat: add stream_ticket_notes generator for SSE doc streaming"

Task 7: Backend — Add SSE Streaming Endpoint

Files:

  • Modify: backend/app/api/endpoints/ai_sessions.py (after get_documentation, around line 925)

  • Step 1: Add the SSE streaming endpoint

After the get_documentation endpoint (after line 924), add:

@router.get("/{session_id}/documentation/stream")
@limiter.limit("20/minute")
async def stream_documentation(
    request: Request,
    session_id: UUID,
    current_user: Annotated[User, Depends(get_current_active_user)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Stream AI-generated ticket notes as Server-Sent Events."""
    from starlette.responses import StreamingResponse

    # Verify session ownership
    result = await db.execute(
        select(AISession).where(AISession.id == session_id)
    )
    session = result.scalar_one_or_none()
    if not session:
        raise HTTPException(status_code=404, detail="Session not found")
    if session.user_id != current_user.id:
        raise HTTPException(status_code=403, detail="Not authorized")

    async def event_generator():
        try:
            async for chunk in flowpilot_engine.stream_ticket_notes(
                session_id=session_id,
                user_id=current_user.id,
                db=db,
            ):
                # SSE format: data: <text>\n\n
                yield f"data: {chunk}\n\n"
            yield "data: [DONE]\n\n"
        except Exception as e:
            logger.exception("SSE stream error for session %s: %s", session_id, e)
            yield f"data: [ERROR] {str(e)}\n\n"

    return StreamingResponse(
        event_generator(),
        media_type="text/event-stream",
        headers={
            "Cache-Control": "no-cache",
            "Connection": "keep-alive",
            "X-Accel-Buffering": "no",  # Disable nginx buffering
        },
    )

Also add the AISession import at the top if not already there (it should be — verify with the existing imports around line 30-55):

from app.models.ai_session import AISession
  • Step 2: Commit
git add backend/app/api/endpoints/ai_sessions.py
git commit -m "feat: add SSE endpoint for streaming ticket notes generation"

Task 8: Backend — Make resolve_session Non-Blocking

Files:

  • Modify: backend/app/api/endpoints/ai_sessions.py:413-446

  • Modify: backend/app/schemas/ai_session.py:135-142

  • Step 1: Make documentation optional in SessionCloseResponse

In backend/app/schemas/ai_session.py, change line 139:

class SessionCloseResponse(BaseModel):
    """Response after resolving or escalating."""
    session_id: UUID
    status: str
    documentation: SessionDocumentation | None = None
    psa_push_status: str = "no_psa"  # sent | pending_retry | no_psa | failed
    psa_push_error: str | None = None
    member_mapping_warning: str | None = None

(Change documentation: SessionDocumentation to documentation: SessionDocumentation | None = None)

  • Step 2: Update the frontend type to match

In frontend/src/types/ai-session.ts, change line 128:

export interface SessionCloseResponse {
  session_id: string
  status: string
  documentation: SessionDocumentation | null
  psa_push_status: string
  psa_push_error: string | null
  member_mapping_warning: string | null
}

(Change documentation: SessionDocumentation to documentation: SessionDocumentation | null)

  • Step 3: Update the resolve endpoint to not generate docs inline

The resolve_session endpoint (lines 413-446) currently calls flowpilot_engine.resolve_session() which generates _generate_documentation(session) inline. The resolve_session in flowpilot_engine already sets status + saves summary + returns docs — we just need the endpoint to stop depending on docs for its response.

In backend/app/services/flowpilot_engine.py, the resolve_session function (line 497) already returns SessionCloseResponse with documentation=documentation. Since we made documentation optional, the existing flow still works but is no longer blocking on an LLM call (it never was — _generate_documentation is pure Python and fast). The slow part was generate_session_embedding (Voyage AI embedding call) and _push_to_psa.

The actual fix: move the embedding generation to be fire-and-forget in the endpoint. In backend/app/api/endpoints/ai_sessions.py, replace the resolve_session endpoint (lines 413-446):

@router.post("/{session_id}/resolve", response_model=SessionCloseResponse)
@limiter.limit("15/minute")
async def resolve_session(
    request: Request,
    session_id: UUID,
    data: ResolveSessionRequest,
    current_user: Annotated[User, Depends(get_current_active_user)],
    db: Annotated[AsyncSession, Depends(get_db)],
    _: None = Depends(require_engineer_or_admin),
):
    """Resolve a session. Returns immediately; use /documentation/stream for ticket notes."""
    try:
        result = await flowpilot_engine.resolve_session(
            session_id=session_id,
            request=data,
            user_id=current_user.id,
            db=db,
        )
    except ValueError as e:
        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
    except PermissionError as e:
        raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))

    await db.commit()

    # Fire-and-forget: resolution outputs + embedding (don't block the response)
    import asyncio

    async def _post_resolve_tasks():
        try:
            from app.services.resolution_output_generator import ResolutionOutputGenerator
            gen = ResolutionOutputGenerator(db)
            await gen.generate_all(session_id)
        except Exception:
            logger.exception(f"Failed to generate resolution outputs for session {session_id}")

    asyncio.create_task(_post_resolve_tasks())

    return result
  • Step 4: Commit
git add backend/app/api/endpoints/ai_sessions.py backend/app/schemas/ai_session.py frontend/src/types/ai-session.ts
git commit -m "feat: make resolve endpoint non-blocking, documentation optional"

Task 9: Frontend — Add streamDocumentation to API Client

Files:

  • Modify: frontend/src/api/aiSessions.ts:95-100

  • Step 1: Add the streaming API method

After the getDocumentation method (around line 100), add:

  async streamDocumentation(
    sessionId: string,
    onChunk: (text: string) => void,
    onDone: () => void,
    onError: (error: string) => void,
  ): Promise<void> {
    const token = localStorage.getItem('access_token')
    const baseUrl = import.meta.env.VITE_API_URL || ''

    try {
      const response = await fetch(
        `${baseUrl}/api/ai-sessions/${sessionId}/documentation/stream`,
        {
          headers: {
            Authorization: `Bearer ${token}`,
          },
        },
      )

      if (!response.ok) {
        onError(`HTTP ${response.status}`)
        return
      }

      const reader = response.body?.getReader()
      if (!reader) {
        onError('No response body')
        return
      }

      const decoder = new TextDecoder()
      let buffer = ''

      while (true) {
        const { done, value } = await reader.read()
        if (done) break

        buffer += decoder.decode(value, { stream: true })
        const lines = buffer.split('\n')
        buffer = lines.pop() || ''

        for (const line of lines) {
          if (line.startsWith('data: ')) {
            const data = line.slice(6)
            if (data === '[DONE]') {
              onDone()
              return
            }
            if (data.startsWith('[ERROR]')) {
              onError(data.slice(8))
              return
            }
            onChunk(data)
          }
        }
      }
      // Stream ended without [DONE]
      onDone()
    } catch (err) {
      onError(err instanceof Error ? err.message : 'Stream failed')
    }
  },
  • Step 2: Commit
git add frontend/src/api/aiSessions.ts
git commit -m "feat: add streamDocumentation SSE client for ticket notes"

Task 10: Frontend — Rewrite ConcludeSessionModal for Two-Phase Flow

Files:

  • Modify: frontend/src/components/assistant/ConcludeSessionModal.tsx

  • Modify: frontend/src/pages/AssistantChatPage.tsx:396-414

  • Step 1: Update the ConcludeSessionModal props and state

The modal's onConclude prop currently returns Promise<string> (the summary text). We need to change it to return Promise<{ sessionId: string }> so the modal can initiate streaming itself.

Update the interface and imports at the top of ConcludeSessionModal.tsx:

import { useState, useEffect, useRef } from 'react'
import {
  X,
  CheckCircle2,
  ArrowUpRight,
  Pause,
  Loader2,
  Copy,
  Check,
  RefreshCw,
  ClipboardList,
  Sparkles,
  AlertTriangle,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { MarkdownContent } from '@/components/ui/MarkdownContent'
import { aiSessionsApi } from '@/api/aiSessions'
import { toast } from '@/lib/toast'

type ConclusionOutcome = 'resolved' | 'escalated' | 'paused'

interface ConcludeSessionModalProps {
  isOpen: boolean
  onClose: () => void
  onConclude: (outcome: ConclusionOutcome, notes: string) => Promise<string>
  onResumeNew: (summary: string) => void
  chatTitle: string
  sessionId: string | null
}

(Added sessionId prop, useRef import, AlertTriangle icon, aiSessionsApi import, toast import.)

  • Step 2: Rewrite the component state and handleGenerate

Replace the state declarations and handleGenerate function (lines 66-106):

export function ConcludeSessionModal({
  isOpen,
  onClose,
  onConclude,
  onResumeNew,
  chatTitle,
  sessionId,
}: ConcludeSessionModalProps) {
  const [step, setStep] = useState<ModalStep>('select-outcome')
  const [outcome, setOutcome] = useState<ConclusionOutcome | null>(null)
  const [notes, setNotes] = useState('')
  const [summary, setSummary] = useState('')
  const [generating, setGenerating] = useState(false)
  const [streaming, setStreaming] = useState(false)
  const [streamError, setStreamError] = useState<string | null>(null)
  const [copied, setCopied] = useState(false)
  const [error, setError] = useState<string | null>(null)
  const summaryRef = useRef('')

  // Reset state when modal opens
  useEffect(() => {
    if (isOpen) {
      setStep('select-outcome')
      setOutcome(null)
      setNotes('')
      setSummary('')
      summaryRef.current = ''
      setGenerating(false)
      setStreaming(false)
      setStreamError(null)
      setCopied(false)
      setError(null)
    }
  }, [isOpen])

  const handleOutcomeSelect = (selected: ConclusionOutcome) => {
    setOutcome(selected)
    setStep('add-notes')
  }

  const handleGenerate = async () => {
    if (!outcome) return
    setGenerating(true)
    setError(null)

    try {
      // Phase 1: Resolve/escalate/pause the session (fast)
      await onConclude(outcome, notes)

      // Phase 2: Transition to summary step immediately
      setStep('summary')
      setGenerating(false)

      // For resolved sessions, stream ticket notes
      if (outcome === 'resolved' && sessionId) {
        setStreaming(true)
        setStreamError(null)
        summaryRef.current = ''

        aiSessionsApi.streamDocumentation(
          sessionId,
          (chunk) => {
            summaryRef.current += chunk
            setSummary(summaryRef.current)
          },
          () => {
            setStreaming(false)
          },
          (err) => {
            setStreaming(false)
            setStreamError(err)
            // Try non-streaming fallback
            aiSessionsApi.getDocumentation(sessionId).then((doc) => {
              const fallback = `## Problem Summary\n${doc.problem_summary}\n\n## Steps Taken\n${doc.diagnostic_steps.map(s => `- ${s.description}`).join('\n')}\n\n## Resolution\n${doc.resolution_summary || 'See conversation'}\n\n## Next Steps\nNone`
              setSummary(fallback)
              setStreamError(null)
            }).catch(() => {
              // Final fallback — just show what we have
              if (!summaryRef.current) {
                setSummary('Documentation generation failed. You can copy the conversation from the chat.')
              }
            })
          },
        )
      } else if (outcome === 'escalated') {
        setSummary('Session escalated. Ticket notes will be generated when the session is resolved.')
      } else {
        setSummary('Session paused. Progress saved — you can resume anytime.')
      }
    } catch {
      setError('Failed to conclude session. Please try again.')
      setGenerating(false)
    }
  }
  • Step 3: Update the summary step rendering

Replace the summary step content (lines 298-325) with:

          {/* Step 3: Summary */}
          {step === 'summary' && (
            <div className="space-y-4">
              {/* Outcome badge */}
              {selectedOutcome && (
                <div className={cn('px-3 py-1.5 rounded-lg inline-flex items-center gap-2 text-xs font-sans text-xs', selectedOutcome.bg, selectedOutcome.border, 'border')}>
                  <selectedOutcome.icon size={14} className={selectedOutcome.color} />
                  <span className={selectedOutcome.color}>{selectedOutcome.label}</span>
                </div>
              )}

              {/* Generated ticket notes */}
              <div
                className="rounded-xl border p-5 bg-card"
                style={{ borderColor: 'var(--color-border-default)' }}
              >
                <div className="flex items-center justify-between mb-3">
                  <span className="font-sans text-xs text-[0.625rem] uppercase tracking-widest text-muted-foreground flex items-center gap-1.5">
                    <Sparkles size={10} className="text-primary" />
                    Ticket Notes
                  </span>
                  {streaming && (
                    <Loader2 size={14} className="animate-spin text-primary" />
                  )}
                </div>

                {/* Streaming content or skeleton */}
                {summary ? (
                  <div className="prose-sm text-foreground">
                    <MarkdownContent content={summary} className="text-[0.8125rem] leading-relaxed" />
                  </div>
                ) : streaming ? (
                  <div className="space-y-3 animate-pulse">
                    <div className="h-4 bg-elevated rounded w-1/3" />
                    <div className="h-3 bg-elevated rounded w-full" />
                    <div className="h-3 bg-elevated rounded w-5/6" />
                    <div className="h-4 bg-elevated rounded w-1/4 mt-4" />
                    <div className="h-3 bg-elevated rounded w-full" />
                    <div className="h-3 bg-elevated rounded w-4/5" />
                  </div>
                ) : streamError ? (
                  <div className="flex items-center gap-2 text-sm text-amber-400">
                    <AlertTriangle size={14} />
                    {streamError}
                  </div>
                ) : null}
              </div>
            </div>
          )}
  • Step 4: Update the footer for the summary step

Replace the summary step footer (lines 373-416) with:

          {step === 'summary' && (
            <>
              <div className="flex items-center gap-2">
                {outcome === 'paused' && (
                  <button
                    onClick={handleResumeNew}
                    className="flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium text-blue-400 bg-blue-400/10 border border-blue-400/20 hover:bg-blue-400/15 transition-all"
                  >
                    <RefreshCw size={14} />
                    Resume in New Chat
                  </button>
                )}
              </div>
              <div className="flex items-center gap-2">
                {summary && !streaming && (
                  <button
                    onClick={handleCopy}
                    className={cn(
                      'flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm font-semibold transition-all',
                      copied
                        ? 'bg-emerald-400/15 text-emerald-400 border border-emerald-400/30'
                        : 'bg-primary text-white hover:brightness-110 active:scale-[0.98]'
                    )}
                  >
                    {copied ? (
                      <>
                        <Check size={15} />
                        Copied!
                      </>
                    ) : (
                      <>
                        <Copy size={15} />
                        Copy to Clipboard
                      </>
                    )}
                  </button>
                )}
                <button
                  onClick={onClose}
                  className="px-4 py-2.5 rounded-lg text-sm text-muted-foreground hover:text-foreground bg-input border border-border hover:border-border-hover transition-all"
                >
                  Done
                </button>
              </div>
            </>
          )}
  • Step 5: Update handleConclude in AssistantChatPage

In frontend/src/pages/AssistantChatPage.tsx, update handleConclude (lines 396-414) to return immediately without waiting for documentation:

  const handleConclude = async (outcome: ConclusionOutcome, _notes: string): Promise<string> => {
    if (!activeChatId) throw new Error('No active chat')

    if (outcome === 'resolved') {
      await aiSessionsApi.resolveSession(activeChatId, {
        resolution_summary: _notes || 'Resolved via assistant chat',
      })
      return activeChatId
    } else if (outcome === 'escalated') {
      await aiSessionsApi.escalateSession(activeChatId, {
        escalation_reason: _notes || 'Escalated from assistant chat',
      })
      return activeChatId
    } else {
      await aiSessionsApi.pauseSession(activeChatId)
      return activeChatId
    }
  }
  • Step 6: Pass sessionId to ConcludeSessionModal

Find where ConcludeSessionModal is rendered (around line 812-818) and add the sessionId prop:

      <ConcludeSessionModal
        isOpen={showConclude}
        onClose={() => setShowConclude(false)}
        onConclude={handleConclude}
        onResumeNew={handleResumeNew}
        chatTitle={chats.find(c => c.id === activeChatId)?.title ?? 'Chat'}
        sessionId={activeChatId}
      />
  • Step 7: Verify the full flow
  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
git add frontend/src/components/assistant/ConcludeSessionModal.tsx frontend/src/pages/AssistantChatPage.tsx
git commit -m "feat: two-phase resolve with streaming ticket notes generation"

Task 11: Frontend Build Verification

Files: None (verification only)

  • Step 1: Run the frontend build
export NVM_DIR="$HOME/.nvm" && source "$NVM_DIR/nvm.sh" && nvm use 20
cd frontend && npm run build

Expected: Build succeeds with no TypeScript errors. If OOM occurs (VPS memory constraint), run npx tsc --noEmit instead as a lighter check.

  • Step 2: Fix any TypeScript errors

If errors appear, fix unused imports, missing props, or type mismatches. Common issues:

  • 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

git add -A
git commit -m "fix: resolve TypeScript errors from task lane and resolve docs changes"

Task 12: Remove Debug Logging from AssistantChatPage

Files:

  • Modify: frontend/src/pages/AssistantChatPage.tsx

  • Step 1: Remove the console.log statements added during debugging

Remove these debug lines:

  • console.log('[AssistantChat] Mount restore check — ...') (around line 105)
  • console.log('[AssistantChat] Calling selectChat to restore:', ...) (around line 107)
  • console.log('[AssistantChat] selectChat called with:', ...) (around line 212)
  • console.log('[AssistantChat] getSession response — ...') (around line 220)
  • console.error('[AssistantChat] Failed to load chat session:', err) (around line 243)

Keep the catch (err) form (not bare catch) but remove the console.error line. Revert to bare catch:

    } catch {
      setMessages([])
    }
  • Step 2: Commit
git add frontend/src/pages/AssistantChatPage.tsx
git commit -m "chore: remove debug logging from AssistantChatPage"

Task 13: Final Integration Test

Files: None (manual testing)

  • Step 1: Test task lane minimize/reopen flow
  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
git push