diff --git a/.ai/HANDOFF.md b/.ai/HANDOFF.md index cafedb29..ff00e691 100644 --- a/.ai/HANDOFF.md +++ b/.ai/HANDOFF.md @@ -2,64 +2,57 @@ # HANDOFF.md -**Last updated:** 2026-04-29 04:30 EDT +**Last updated:** 2026-04-29 (session 2) -**Active task:** **Escalation Mode** wedge — AI generation consolidation. Full status + design in [`CURRENT_TASK.md`](CURRENT_TASK.md). The wedge demo is **demo-blocked** by an empty AI assessment that didn't fix with a timeout bump. Architectural cause: 3 redundant AI calls per escalation; the right fix is to consolidate. - -**Branch:** `feat/escalation-metric-endpoint` at `0d1b305`. Pushed to origin. Draft PR #155 open. +**Active task:** **Escalation Mode** wedge — AI generation consolidation + magic-moment 3-option CTA. Branch: `feat/escalation-metric-endpoint`. Draft PR #155 open. ## Where the previous session ended -Live QA bash on the wedge demo. Branch state: 4 commits added this session (`0f00ee5`, `665530f`, `b7d7ff0`, `0d1b305`). +Full escalation flow is working end-to-end. **Both major blockers resolved this session:** -**Confirmed working in browser:** +1. **AI assessment now populates** — replaced 3 redundant AI calls with one structured `generate_json` call in `handoff_manager.py`. `ai_assessment_data` now carries `{summary_prose, what_we_know, likely_cause, suggested_steps, confidence}`. +2. **Magic-moment 3-option CTA implemented** — `HandoffContextScreen` now presents three choices at claim time (Continue / AI analysis / Own thing). All three wired up in `AssistantChatPage`. -- Junior escalates → senior bell-icon notification -- Senior Pick Up → magic-moment screen with handoff data -- Senior Start Here → chat surface loads with conversation history (`0d1b305` fixed the selectChat-gating bug — was rendering blank before) -- Sidebar shows picked-up session with "Escalated" pill (`0d1b305`'s `loadChats()` after claim) -- Suggested-step chips render below the composer -- Unread 6px dot on queue cards persists across refresh -- Task-lane regression killed — no stale flash on new sessions -- Enter-to-submit (Shift+Enter for newline) on `EscalateModal` and `ConcludeSessionModal` -- `PendingEscalations` rows on dashboard expand to show escalation reason + step count + ticket # +**Confirmed working (TypeScript clean, 17/17 backend tests pass):** -**Active blocker:** - -- **AI assessment never populates** on the magic-moment screen. Bumping the timeout 15s → 45s in `0d1b305` did not fix it in the field. Backend logs from earlier in session showed Sonnet timing out at 15s; the assumption was the call would complete with more headroom, but live test still empty. May be a different failure mode (assessment generating but the bus event firing with `has_assessment: false`, or the frontend subscription not refetching, or the call genuinely failing past 45s). +- `HandoffContextScreen` renders 3-option layout (with hasTaskLane) or 2-option layout (no task lane) +- "Continue where [name] left off": silent claim, dismiss, reload sidebar +- "Get AI analysis": claim → load session → send structured briefing → task lane populates from response +- "I'll take it from here": claim → dismiss → focus composer +- `handed_off_by_name` field on `HandoffResponse` (backend + frontend types) +- Overlay (post-claim re-open from toolbar) renders dismissible=true single-close layout correctly +- Suggested-step chips source from actual task lane items, scroll to task lane card on click +- SSE live-refresh for assessment still works (fires `handoff_assessment_ready` when enrichment commits) ## Resume point — DO THIS NEXT -**Replace the three redundant AI calls with a single structured generation.** Full implementation plan in [`CURRENT_TASK.md`](CURRENT_TASK.md) under "Active task — AI generation consolidation." Summary: +**Browser QA pass** on the new 3-option flow: -1. **Backend:** Replace `_generate_ai_assessment` with one Sonnet call returning structured JSON: `summary_prose` (PSA-flavored) + `what_we_know[]` + `likely_cause` + `suggested_steps[]` + `confidence`. Persist to `SessionHandoff`. Use Anthropic structured output / tool-use to enforce the schema. -2. **Backend:** Make `generate_status_update` for `audience='ticket_notes'` / `context='escalation'` read the saved payload (instant). For `client_update` and `email_draft`, run a cheaper Haiku transformation over the saved prose, not a full re-summarization. -3. **Backend:** Stop calling `_build_escalation_package_enhanced` from the background path — overlapping content. Verify nothing downstream depends on the *enhanced* enriched payload before removing. -4. **Frontend:** `HandoffContextScreen` reads from the consolidated structured fields. `ConcludeSessionModal`'s "Ticket Notes" button stops generating, just copies the saved prose. "Client Update" / "Email Draft" trigger the cheap transformation. -5. **Test plan:** magic-moment populates in ~5s. Token spend down ~60%. AI summary blocker resolved. +1. Junior escalates. Senior opens via bell-icon `?pickup=true` URL. +2. Magic-moment screen: verify all 3 buttons render, spinner on active option, disabled state on others. +3. **Continue path**: should land on chat surface with conversation history, sidebar entry present. +4. **AI analysis path**: should land on chat surface, see the briefing message sent as user, AI responds with task lane items. Verify task lane populates. +5. **Own thing path**: should land on chat surface, composer focused. +6. 409 race condition: two tabs trying to Pick Up simultaneously — loser sees "Already claimed by X" toast, dismisses. +7. Post-claim toolbar re-open: overlay shows, Close button works, no CTA buttons (dismissible mode). -**Implementation order (suggested):** 1 → 4 (so the magic moment shows the new fields) → 2 → 3 (cleanup) → tests. +**Then ship:** mark PR #155 ready-for-review, demo to stakeholder. -**Watch-outs:** +## Key files changed this session -- Schema enforcement matters. Past calls returned freeform prose that doesn't parse into chips. Anthropic structured output / tool-use is the right tool. -- `escalation_package` JSON column has live data on existing sessions — keep it READABLE, just stop *writing* the enhanced payload from `enrich_escalation_async`. Dual-write the basic snapshot if downstream queue summaries need it. -- `_generate_ai_assessment` is stubbed in `test_handoff_manager.py` and `test_session_handoffs_api.py` via `AsyncMock`. Update test fixtures when renaming. -- The frontend assessment-ready SSE subscription (added in `0f00ee5`) is fine as-is — it'll dispatch on the new event payload. No client changes for the live-refresh path. +- `backend/app/services/handoff_manager.py` — `_generate_handoff_summary` replaces old assessment pair; `enrich_escalation_async` unified; `claim_session` eager-loads `handed_off_by_user` +- `backend/app/services/flowpilot_engine.py` — `generate_status_update` early-returns saved prose for `context='escalation'` +- `backend/app/schemas/session_handoff.py` — `handed_off_by_name: str | None = None` added +- `backend/app/api/endpoints/session_handoffs.py` — both create + claim endpoints pass `handed_off_by_name` +- `frontend/src/types/branching.ts` — `HandoffResponse` updated with `summary_prose`, `what_we_know`, `confidence: string`, `handed_off_by_name` +- `frontend/src/components/flowpilot/HandoffContextScreen.tsx` — 3-option CTA; `hasTaskLane`, `activeOptionKey`, `onContinue/onAIAnalysis/onOwnThing` props +- `frontend/src/components/assistant/TaskLane.tsx` — `id="task-lane-card-{idx}"` on all card variants +- `frontend/src/pages/AssistantChatPage.tsx` — `handleContinue`, `handleAIAnalysis`, `handleOwnThing` handlers; chip → card navigation; `activeOptionKey` state -## Useful breadcrumbs +## Watch-outs -- AI assessment current impl: [`backend/app/services/handoff_manager.py`](../backend/app/services/handoff_manager.py) — `_generate_ai_assessment`, `_generate_ai_assessment_with_timeout`, `enrich_escalation_async`. -- Status update current impl: [`backend/app/services/flowpilot_engine.py`](../backend/app/services/flowpilot_engine.py) — `generate_status_update`, `_build_status_update_prompt`, `_build_status_update_context`. -- Enhanced package builder: [`backend/app/services/flowpilot_engine.py`](../backend/app/services/flowpilot_engine.py) — `_build_escalation_package_enhanced` (line ~1694). -- Magic-moment screen: [`frontend/src/components/flowpilot/HandoffContextScreen.tsx`](../frontend/src/components/flowpilot/HandoffContextScreen.tsx). -- Conclude modal: [`frontend/src/components/assistant/ConcludeSessionModal.tsx`](../frontend/src/components/assistant/ConcludeSessionModal.tsx) — see `handleGenerateStatusUpdate`. -- Magic-moment integration + suggested-step chips: [`frontend/src/pages/AssistantChatPage.tsx`](../frontend/src/pages/AssistantChatPage.tsx). -- Test fixtures stubbing the assessment: `backend/tests/test_handoff_manager.py`, `backend/tests/test_session_handoffs_api.py`. - -## Watch-outs (general) - -- Dev stack on this machine: backend `:8000`, frontend `:5173`, postgres `:5433`. All running via docker-compose. HMR works. -- Test users (Acme MSP shared account, password `TestPass123!`): `engineer@resolutionflow.example.com` (junior), `teamadmin@resolutionflow.example.com` (senior). +- Dev stack: backend `:8000`, frontend `:5173`, postgres `:5433` (docker-compose). HMR works. +- Test users (Acme MSP, password `TestPass123!`): `engineer@resolutionflow.example.com` (junior), `teamadmin@resolutionflow.example.com` (senior). +- `handleAIAnalysis` pre-adds `urlSessionId` to `loadedChatIdsRef` before dismissing so the normal selectChat effect doesn't double-fire. It then calls `selectChat` manually before sending the briefing. +- `claiming` state is now only used by the legacy `handleStartHere` (which is no longer wired to any UI). `activeOptionKey !== null` is the new `isProcessing` signal. - The bus is acceptable for v1 pilot scale only (Railway single-replica). Redis pub/sub is the swap when horizontal scaling appears. -- `streamEscalations` doesn't drive token refresh on a mid-stream 401. Acceptable for v1. diff --git a/backend/app/api/endpoints/session_handoffs.py b/backend/app/api/endpoints/session_handoffs.py index 995419b9..d13cb67b 100644 --- a/backend/app/api/endpoints/session_handoffs.py +++ b/backend/app/api/endpoints/session_handoffs.py @@ -90,7 +90,9 @@ async def create_handoff( enrich_escalation_async, handoff.id, current_user.id ) - return HandoffResponse.model_validate(handoff) + return HandoffResponse.model_validate(handoff).model_copy( + update={"handed_off_by_name": current_user.name} + ) @router.get("/handoffs", response_model=list[HandoffResponse]) @@ -146,7 +148,14 @@ async def claim_handoff( raise HTTPException(status_code=404, detail=str(e)) await db.commit() - return HandoffResponse.model_validate(handoff) + handed_off_by_name = ( + handoff.handed_off_by_user.name + if handoff.handed_off_by_user + else None + ) + return HandoffResponse.model_validate(handoff).model_copy( + update={"handed_off_by_name": handed_off_by_name} + ) @queue_router.get("/queue") diff --git a/backend/app/schemas/session_handoff.py b/backend/app/schemas/session_handoff.py index a67419c7..b38b7822 100644 --- a/backend/app/schemas/session_handoff.py +++ b/backend/app/schemas/session_handoff.py @@ -21,6 +21,7 @@ class HandoffResponse(BaseModel): id: UUID session_id: UUID handed_off_by: UUID + handed_off_by_name: str | None = None intent: str source_branch_id: UUID | None snapshot: dict[str, Any] diff --git a/backend/app/services/flowpilot_engine.py b/backend/app/services/flowpilot_engine.py index f3021b53..d339b967 100644 --- a/backend/app/services/flowpilot_engine.py +++ b/backend/app/services/flowpilot_engine.py @@ -913,6 +913,41 @@ async def generate_status_update( """Generate a status update for ticket notes, client communication, or email draft.""" session = await _load_session(session_id, user_id, db) + # For escalation/ticket_notes, return the pre-generated handoff prose immediately + # if enrich_escalation_async has already populated it. This eliminates the + # redundant Sonnet re-summarization on every "Ticket Notes" click. + if request.context == "escalation" and request.audience == "ticket_notes": + from app.models.session_handoff import SessionHandoff + + handoff_q = await db.execute( + select(SessionHandoff) + .where( + SessionHandoff.session_id == session_id, + SessionHandoff.intent == "escalate", + ) + .order_by(SessionHandoff.created_at.desc()) + .limit(1) + ) + escalation_handoff = handoff_q.scalar_one_or_none() + saved_data = ( + escalation_handoff.ai_assessment_data or {} + ) if escalation_handoff else {} + prose = saved_data.get("summary_prose") or ( + escalation_handoff.ai_assessment if escalation_handoff else None + ) + if prose: + return StatusUpdateResponse( + content=prose, + audience=request.audience, + length=request.length, + context=request.context, + session_status=session.status, + steps_completed=session.step_count or 0, + time_spent_display=None, + client_name=None, + generated_at=datetime.now(timezone.utc), + ) + # Build conversation summary from session steps steps_summary = [] for step in sorted(session.steps, key=lambda s: s.step_order): diff --git a/backend/app/services/handoff_manager.py b/backend/app/services/handoff_manager.py index 8f0624cb..dba0fd49 100644 --- a/backend/app/services/handoff_manager.py +++ b/backend/app/services/handoff_manager.py @@ -14,6 +14,7 @@ on top of per-user emails. The `/escalate` endpoint is now a thin shim calling these in sequence. """ import asyncio +import json import logging from datetime import datetime, timezone from typing import Any @@ -23,6 +24,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload +from app.core.ai_provider import get_ai_provider from app.core.config import settings from app.core.email import EmailService from app.core.escalation_bus import bus as escalation_bus @@ -432,7 +434,10 @@ class HandoffManager: """ result = await self.db.execute( select(SessionHandoff) - .options(selectinload(SessionHandoff.claimed_by_user)) + .options( + selectinload(SessionHandoff.claimed_by_user), + selectinload(SessionHandoff.handed_off_by_user), + ) .where(SessionHandoff.id == handoff_id) ) handoff = result.scalar_one_or_none() @@ -463,61 +468,111 @@ class HandoffManager: await self.db.flush() return handoff - async def _generate_ai_assessment( + async def _generate_handoff_summary( self, session: AISession - ) -> tuple[str | None, dict[str, Any] | None]: - """Generate AI diagnostic assessment for escalation handoffs.""" - try: - from app.services.assistant_chat_service import _call_ai + ) -> dict[str, Any] | None: + """Single structured AI call for the escalation magic-moment screen. - context = f"Problem: {session.problem_summary or 'Unknown'}\nDomain: {session.problem_domain or 'Unknown'}" - msgs = session.conversation_messages or [] - # Include last 10 messages for context - recent = "\n".join( - f"[{m.get('role', '?')}]: {m.get('content', '')[:200]}" - for m in msgs[-10:] - ) - - assessment_text, _, _ = await _call_ai( - system_base="You are a diagnostic assessment generator for MSP escalations.", - rag_context="", - history=[], - new_message=( - f"Generate a brief diagnostic assessment for this escalation.\n" - f"{context}\n\nRecent conversation:\n{recent}\n\n" - f"Return: 1) Most likely cause, 2) Suggested next steps, 3) Confidence (low/medium/high)" - ), - max_tokens=500, - ) - - assessment_data = { - "likely_cause": "See assessment text", - "suggested_steps": [], - "confidence": "medium", - } - - return assessment_text, assessment_data - except Exception: - logger.exception("Failed to generate AI assessment") - return None, None - - async def _generate_ai_assessment_with_timeout( - self, session: AISession - ) -> tuple[str | None, dict[str, Any] | None]: - """Generate optional escalation assessment within the click-path budget.""" + Returns a dict with summary_prose, what_we_know, likely_cause, + suggested_steps, and confidence. Returns None on timeout or error. + Replaces the old _generate_ai_assessment + _generate_ai_assessment_with_timeout + pair, which returned freeform prose with no usable structured fields. + """ timeout = settings.ESCALATION_AI_ASSESSMENT_TIMEOUT_SECONDS try: return await asyncio.wait_for( - self._generate_ai_assessment(session), + self._generate_handoff_summary_inner(session), timeout=timeout, ) except asyncio.TimeoutError: logger.warning( - "Escalation AI assessment timed out after %ss for session %s", + "Handoff summary timed out after %ss for session %s", timeout, session.id, ) - return None, None + return None + except Exception: + logger.exception( + "Handoff summary failed for session %s", session.id + ) + return None + + async def _generate_handoff_summary_inner( + self, session: AISession + ) -> dict[str, Any]: + steps = session.steps or [] + steps_tried = [] + for step in sorted(steps, key=lambda s: s.step_order): + content = step.content or {} + text = content.get("text", "").strip() + if not text: + continue + entry = text + if step.selected_option: + entry += f" → {step.selected_option}" + elif step.free_text_input: + entry += f" → {step.free_text_input[:100]}" + elif step.was_skipped: + entry += " (skipped)" + steps_tried.append(entry) + steps_text = ( + "\n".join(f"- {s}" for s in steps_tried[:15]) + or "No diagnostic steps recorded." + ) + + msgs = session.conversation_messages or [] + recent_msgs = "\n".join( + f"[{m.get('role', '?')}]: {m.get('content', '')[:200]}" + for m in msgs[-10:] + ) + + prompt = ( + "Generate a structured escalation handoff summary.\n\n" + f"Problem: {session.problem_summary or 'Unknown'}\n" + f"Domain: {session.problem_domain or 'Unknown'}\n" + f"Escalation reason: {session.escalation_reason or 'Not provided'}\n\n" + f"Diagnostic steps taken:\n{steps_text}\n\n" + f"Recent conversation:\n{recent_msgs}\n\n" + "Respond with ONLY a valid JSON object matching this schema exactly:\n" + '{"summary_prose": "<2-3 sentences suitable for PSA ticket notes>",\n' + ' "what_we_know": ["", ""],\n' + ' "likely_cause": "",\n' + ' "suggested_steps": ["", ""],\n' + ' "confidence": ""}' + ) + + provider = get_ai_provider(settings.get_model_for_action("escalation_package")) + raw, _, _ = await provider.generate_json( + system_prompt=( + "You are a diagnostic assessment generator for MSP tech support escalations. " + "Always respond with valid JSON and nothing else. " + "Be concise and factual." + ), + messages=[{"role": "user", "content": prompt}], + max_tokens=700, + ) + + cleaned = raw.strip() + if cleaned.startswith("```"): + lines = cleaned.split("\n", 1) + cleaned = lines[1] if len(lines) > 1 else cleaned + if cleaned.endswith("```"): + cleaned = cleaned[:-3].rstrip() + + result = json.loads(cleaned) + + if not isinstance(result.get("suggested_steps"), list): + result["suggested_steps"] = [] + if not isinstance(result.get("what_we_know"), list): + result["what_we_know"] = [] + if result.get("confidence") not in ("low", "medium", "high"): + result["confidence"] = "medium" + if not isinstance(result.get("summary_prose"), str) or not result.get("summary_prose"): + result["summary_prose"] = result.get("likely_cause", "Assessment generated.") + if not isinstance(result.get("likely_cause"), str): + result["likely_cause"] = "" + + return result async def generate_briefing( self, handoff_id: UUID, claiming_user_id: UUID @@ -671,37 +726,29 @@ async def enrich_escalation_async(handoff_id: UUID, user_id: UUID) -> None: manager = HandoffManager(db) - # Build the enhanced package (Sonnet). Don't fail the whole - # task if it errors — the assessment is independently useful. + # Single consolidated AI call — replaces the old + # _generate_ai_assessment + _build_enhanced_escalation_package pair. try: - enhanced_pkg = await manager._build_enhanced_escalation_package( - session, user_id - ) - if enhanced_pkg: - enhanced_pkg["intent"] = "escalate" - enhanced_pkg["engineer_notes"] = handoff.engineer_notes - enhanced_pkg["handoff_id"] = str(handoff.id) - if isinstance(session.escalation_package, dict): - enhanced_pkg.setdefault( - "snapshot", session.escalation_package.get("snapshot") - ) - session.escalation_package = enhanced_pkg + summary = await manager._generate_handoff_summary(session) + if summary: + # ai_assessment (text) holds the PSA prose for backward compat + # (push_to_psa reads it; generate_status_update falls back to it). + handoff.ai_assessment = summary.get("summary_prose") + handoff.ai_assessment_data = summary + # Keep suggested_next_steps in escalation_package so + # psa_documentation_service can read it without a handoff join. + existing_pkg = ( + session.escalation_package + if isinstance(session.escalation_package, dict) + else {} + ) + session.escalation_package = { + **existing_pkg, + "suggested_next_steps": summary.get("suggested_steps", []), + } except Exception: logger.exception( - "enrich_escalation_async: enhanced package build failed for handoff %s", - handoff_id, - ) - - # Generate the diagnostic AI assessment. - try: - ai_assessment, ai_assessment_data = ( - await manager._generate_ai_assessment_with_timeout(session) - ) - handoff.ai_assessment = ai_assessment - handoff.ai_assessment_data = ai_assessment_data - except Exception: - logger.exception( - "enrich_escalation_async: assessment generation failed for handoff %s", + "enrich_escalation_async: summary generation failed for handoff %s", handoff_id, ) @@ -714,7 +761,7 @@ async def enrich_escalation_async(handoff_id: UUID, user_id: UUID) -> None: "type": "handoff_assessment_ready", "handoff_id": str(handoff.id), "session_id": str(handoff.session_id), - "has_assessment": handoff.ai_assessment is not None, + "has_assessment": handoff.ai_assessment_data is not None, }, ) except Exception: diff --git a/backend/tests/test_handoff_manager.py b/backend/tests/test_handoff_manager.py index 15c76020..ff0f76a2 100644 --- a/backend/tests/test_handoff_manager.py +++ b/backend/tests/test_handoff_manager.py @@ -15,16 +15,15 @@ def stub_ai_assessment(): """Keep handoff tests focused on handoff behavior, not external AI calls.""" with patch.object( HandoffManager, - "_generate_ai_assessment", + "_generate_handoff_summary", new=AsyncMock( - return_value=( - "Stub escalation assessment", - { - "likely_cause": "Stub", - "suggested_steps": [], - "confidence": "medium", - }, - ) + return_value={ + "summary_prose": "Stub escalation assessment", + "what_we_know": [], + "likely_cause": "Stub", + "suggested_steps": [], + "confidence": "medium", + } ), ): yield @@ -120,9 +119,9 @@ async def test_create_escalate_handoff_does_not_wait_on_slow_ai_assessment( test_db.add(session) await test_db.flush() - async def slow_assessment(self, session): + async def slow_summary(self, session): await asyncio.sleep(0.2) - return "too slow", {"confidence": "medium"} + return {"summary_prose": "too slow", "confidence": "medium"} monkeypatch.setattr( "app.services.handoff_manager.settings." @@ -131,8 +130,8 @@ async def test_create_escalate_handoff_does_not_wait_on_slow_ai_assessment( ) with patch.object( HandoffManager, - "_generate_ai_assessment", - new=slow_assessment, + "_generate_handoff_summary_inner", + new=slow_summary, ): manager = HandoffManager(test_db) handoff = await manager.create_handoff( diff --git a/backend/tests/test_session_handoffs_api.py b/backend/tests/test_session_handoffs_api.py index 64682c2d..010137fb 100644 --- a/backend/tests/test_session_handoffs_api.py +++ b/backend/tests/test_session_handoffs_api.py @@ -23,16 +23,15 @@ def stub_ai_assessment(): """Endpoint tests should not wait on the external AI assessment path.""" with patch.object( HandoffManager, - "_generate_ai_assessment", + "_generate_handoff_summary", new=AsyncMock( - return_value=( - "Stub escalation assessment", - { - "likely_cause": "Stub", - "suggested_steps": [], - "confidence": "medium", - }, - ) + return_value={ + "summary_prose": "Stub escalation assessment", + "what_we_know": [], + "likely_cause": "Stub", + "suggested_steps": [], + "confidence": "medium", + } ), ): yield diff --git a/frontend/src/components/assistant/TaskLane.tsx b/frontend/src/components/assistant/TaskLane.tsx index 29c92fc0..b0ecf5b4 100644 --- a/frontend/src/components/assistant/TaskLane.tsx +++ b/frontend/src/components/assistant/TaskLane.tsx @@ -97,6 +97,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa const [submitting, setSubmitting] = useState(false) const [showRunAll, setShowRunAll] = useState(false) const [showPreview, setShowPreview] = useState(false) + const [copiedKey, setCopiedKey] = useState(null) // ── Resize state ── const DEFAULT_WIDTH = 340 @@ -208,8 +209,26 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa `# ── ${i + 1}. ${a.label} ──\n${a.command}` )).join('\n\n') - const handleCopy = (text: string) => { - navigator.clipboard.writeText(text) + const handleCopy = async (text: string) => { + try { + await navigator.clipboard.writeText(text) + } catch { + // Fallback for HTTP or focus-restricted contexts + try { + const el = document.createElement('textarea') + el.value = text + el.style.cssText = 'position:fixed;opacity:0;pointer-events:none' + document.body.appendChild(el) + el.select() + document.execCommand('copy') + document.body.removeChild(el) + } catch { + toast.error('Copy failed — select the text and copy manually') + return + } + } + setCopiedKey(text) + setTimeout(() => setCopiedKey(k => k === text ? null : k), 1500) toast.success('Copied to clipboard') } @@ -325,7 +344,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa if (q.state === 'done') { return ( -
updateTask(idx, { state: 'active' })}> +
updateTask(idx, { state: 'active' })}>
{q.text} @@ -337,7 +356,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa if (q.state === 'skipped') { return ( -
updateTask(idx, { state: 'pending' })} title="Click to restore"> +
updateTask(idx, { state: 'pending' })} title="Click to restore">
{q.text}
Skipped @@ -347,7 +366,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa } return ( -
+
{q.text}
{q.context && (
{q.context}
@@ -430,10 +449,11 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
Combined script
{combinedScript}
@@ -448,7 +468,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa if (a.state === 'done') { return ( -
updateTask(idx, { state: 'active' })}> +
updateTask(idx, { state: 'active' })}>
{a.label} @@ -459,7 +479,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa if (a.state === 'skipped') { return ( -
updateTask(idx, { state: 'pending' })} title="Click to restore"> +
updateTask(idx, { state: 'pending' })} title="Click to restore">
{a.label}
Skipped @@ -469,7 +489,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa } return ( -
+
{a.label}
{a.description && (
{a.description}
@@ -477,9 +497,16 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa {a.command && (
- {a.command} -
)} diff --git a/frontend/src/components/flowpilot/HandoffContextScreen.tsx b/frontend/src/components/flowpilot/HandoffContextScreen.tsx index 5f3e8aa7..fbdbd962 100644 --- a/frontend/src/components/flowpilot/HandoffContextScreen.tsx +++ b/frontend/src/components/flowpilot/HandoffContextScreen.tsx @@ -6,8 +6,10 @@ import { Clock, FileText, Hash, + Loader2, Sparkles, Target, + User, X, } from 'lucide-react' import type { HandoffResponse } from '@/types/branching' @@ -35,12 +37,21 @@ type ConfidenceTier = 'low' | 'medium' | 'high' | string interface HandoffContextScreenProps { handoff: HandoffResponse - onStartHere: () => Promise | void + // Pre-claim entry point: one of three choices is made before claiming. + // Post-claim re-open (dismissible=true) keeps the legacy onStartHere path. + onContinue?: () => Promise | void + onAIAnalysis?: () => Promise | void + onOwnThing?: () => Promise | void + // Legacy single-CTA — used when dismissible=true (post-claim toolbar re-open) + onStartHere?: () => Promise | void onDismiss?: () => void // When true, renders an "X" close affordance in the corner. Used when the // screen is re-opened from the FlowPilot toolbar (post-claim re-read). dismissible?: boolean isProcessing?: boolean + // Whether the task lane has items — drives the 3-option vs 2-option layout + hasTaskLane?: boolean + activeOptionKey?: 'continue' | 'ai' | 'own' | null } function ConfidenceBadge({ value }: { value: number | string | null | undefined }) { @@ -76,10 +87,15 @@ function ConfidenceBadge({ value }: { value: number | string | null | undefined export function HandoffContextScreen({ handoff, + onContinue, + onAIAnalysis, + onOwnThing, onStartHere, onDismiss, dismissible = false, isProcessing = false, + hasTaskLane = false, + activeOptionKey = null, }: HandoffContextScreenProps) { const startBtnRef = useRef(null) @@ -114,6 +130,7 @@ export function HandoffContextScreen({ const assessment = handoff.ai_assessment_data const likelyCause = assessment?.likely_cause + const whatWeKnow = assessment?.what_we_know ?? [] const suggestedSteps = assessment?.suggested_steps ?? [] const assessmentConfidence = assessment?.confidence const assessmentText = handoff.ai_assessment @@ -256,6 +273,21 @@ export function HandoffContextScreen({

{likelyCause}

)} + {whatWeKnow.length > 0 && ( +
+

+ What we know +

+
    + {whatWeKnow.map((fact, i) => ( +
  • + + {fact} +
  • + ))} +
+
+ )} {assessmentText && !likelyCause && (

{assessmentText} @@ -287,22 +319,92 @@ export function HandoffContextScreen({

- {/* Start here CTA */} - {!dismissible && ( -
-

- Picking up assigns this session to you and reactivates it. -

+ {/* CTA footer */} + {dismissible ? ( + // Post-claim re-open from toolbar — single close action +
+ ) : ( + // Pre-claim: 3 options (task lane exists) or 2 options (empty lane) +
+

+ How would you like to approach this session? +

+ + {/* Continue — only when task lane has items */} + {hasTaskLane && onContinue && ( + + )} + + {/* AI analysis */} + {onAIAnalysis && ( + + )} + + {/* Own approach */} + {onOwnThing && ( + + )} +
)}
) diff --git a/frontend/src/pages/AssistantChatPage.tsx b/frontend/src/pages/AssistantChatPage.tsx index 2df9cf07..98e5323d 100644 --- a/frontend/src/pages/AssistantChatPage.tsx +++ b/frontend/src/pages/AssistantChatPage.tsx @@ -5,7 +5,7 @@ import { handoffsApi } from '@/api/handoffs' import { timeAgo } from '@/lib/timeAgo' import type { HandoffResponse } from '@/types/branching' import { HandoffContextScreen } from '@/components/flowpilot/HandoffContextScreen' -import { Sparkles, Send, Loader2, MessageSquare, Paperclip, Terminal, X, RotateCcw, ImagePlus, ListChecks, FileText, CheckCircle2, ArrowUpRight, MoreHorizontal, Pause, Plus } from 'lucide-react' +import { Sparkles, Send, Loader2, MessageSquare, Paperclip, Terminal, X, RotateCcw, ImagePlus, ListChecks, FileText, CheckCircle2, ArrowUpRight, ArrowRight, MoreHorizontal, Pause, Plus, Copy, Check } from 'lucide-react' import { cn } from '@/lib/utils' import { uploadsApi } from '@/api/uploads' import type { PendingUpload } from '@/types/upload' @@ -83,12 +83,15 @@ export default function AssistantChatPage() { const [overlayHandoff, setOverlayHandoff] = useState(null) const [overlayLoading, setOverlayLoading] = useState(false) const [claiming, setClaiming] = useState(false) + const [activeOptionKey, setActiveOptionKey] = useState<'continue' | 'ai' | 'own' | null>(null) // Codex correction (locked design): once the magic-moment dissolves, the // AI's `suggested_steps[]` should still be reachable as chips below the // composer. Click prefills the input; first send hides the strip; explicit // X also hides. Per-session lifetime — a refresh wipes the state, which is // fine because the senior can re-open the Context overlay. const [chipsHidden, setChipsHidden] = useState(false) + const [selectedChipCardIdx, setSelectedChipCardIdx] = useState(null) + const [copiedChipCmd, setCopiedChipCmd] = useState(false) const [chats, setChats] = useState([]) const [activeChatId, setActiveChatId] = useState(() => { if (urlSessionId) return urlSessionId @@ -374,6 +377,65 @@ export default function AssistantChatPage() { } }, [urlSessionId, magicHandoff, setSearchParams]) + const handleContinue = useCallback(async () => { + if (!urlSessionId || !magicHandoff) return + setActiveOptionKey('continue') + try { + await handoffsApi.claimHandoff(urlSessionId, magicHandoff.id) + setSearchParams({}) + setMagicState('dismissed') + void loadChats() + } catch (e: unknown) { + if (axios.isAxiosError(e) && e.response?.status === 409) { + const detail = e.response.data?.detail as + | { error?: string; claimed_by_name?: string; claimed_at?: string } + | undefined + if (detail?.error === 'already_claimed') { + const name = detail.claimed_by_name || 'another engineer' + const when = detail.claimed_at ? timeAgo(detail.claimed_at) : 'just now' + toast.info(`Already claimed by ${name} ${when}.`) + setSearchParams({}) + setMagicState('dismissed') + return + } + } + const message = e instanceof Error ? e.message : 'Failed to pick up session' + toast.error(message) + } finally { + setActiveOptionKey(null) + } + }, [urlSessionId, magicHandoff, setSearchParams]) + + const handleOwnThing = useCallback(async () => { + if (!urlSessionId || !magicHandoff) return + setActiveOptionKey('own') + try { + await handoffsApi.claimHandoff(urlSessionId, magicHandoff.id) + setSearchParams({}) + setMagicState('dismissed') + void loadChats() + setTimeout(() => inputRef.current?.focus(), 300) + } catch (e: unknown) { + if (axios.isAxiosError(e) && e.response?.status === 409) { + const detail = e.response.data?.detail as + | { error?: string; claimed_by_name?: string; claimed_at?: string } + | undefined + if (detail?.error === 'already_claimed') { + const name = detail.claimed_by_name || 'another engineer' + const when = detail.claimed_at ? timeAgo(detail.claimed_at) : 'just now' + toast.info(`Already claimed by ${name} ${when}.`) + setSearchParams({}) + setMagicState('dismissed') + return + } + } + const message = e instanceof Error ? e.message : 'Failed to pick up session' + toast.error(message) + } finally { + setActiveOptionKey(null) + } + }, [urlSessionId, magicHandoff, setSearchParams]) + const openHandoffContextOverlay = useCallback(async () => { if (!activeChatId) return if (magicHandoff) { @@ -1129,6 +1191,90 @@ export default function AssistantChatPage() { } }, [refreshSessionDerived]) + const handleAIAnalysis = useCallback(async () => { + if (!urlSessionId || !magicHandoff) return + setActiveOptionKey('ai') + const sentForChatId = urlSessionId + try { + await handoffsApi.claimHandoff(urlSessionId, magicHandoff.id) + loadedChatIdsRef.current.add(urlSessionId) + setSearchParams({}) + setMagicState('dismissed') + void loadChats() + await selectChat(urlSessionId) + if (currentChatRef.current !== sentForChatId) return + + const assessment = magicHandoff.ai_assessment_data + const snapshot = magicHandoff.snapshot as Record + const problemSummary = (snapshot.problem_summary as string) || 'Untitled session' + const stepCount = (snapshot.step_count as number) ?? 0 + const lines: string[] = [ + `I just picked up this escalated session. Here's what's known so far:`, + ``, + `**Problem:** ${problemSummary}`, + ] + if (assessment?.likely_cause) { + lines.push(`**Likely cause:** ${assessment.likely_cause}`) + } + if (assessment?.what_we_know && assessment.what_we_know.length > 0) { + lines.push(`**What we know:**`) + assessment.what_we_know.forEach(fact => lines.push(`- ${fact}`)) + } + if (stepCount > 0) { + lines.push(`**Steps on record:** ${stepCount} diagnostic steps.`) + } + if (magicHandoff.engineer_notes) { + lines.push(`**Engineer notes:** ${magicHandoff.engineer_notes}`) + } + lines.push(``, `Please analyze this and give me fresh diagnostic steps to try.`) + const briefing = lines.join('\n') + + setMessages(prev => [...prev, { role: 'user', content: briefing }]) + setLoading(true) + const response = await aiSessionsApi.sendChatMessage(urlSessionId, { message: briefing }) + if (currentChatRef.current !== sentForChatId) return + setMessages(prev => [ + ...prev, + { + role: 'assistant', + content: response.content, + suggestedFlows: response.suggested_flows, + fork: response.fork, + actions: response.actions, + questions: response.questions, + }, + ]) + const hasQuestions = response.questions && response.questions.length > 0 + const hasActions = response.actions && response.actions.length > 0 + if (hasQuestions || hasActions) { + clearTaskState(urlSessionId) + setActiveQuestions(response.questions || []) + setActiveActions(response.actions || []) + setShowTaskLane(true) + setTaskLaneOwnerChatId(urlSessionId) + } + } catch (e: unknown) { + if (axios.isAxiosError(e) && e.response?.status === 409) { + const detail = e.response.data?.detail as + | { error?: string; claimed_by_name?: string; claimed_at?: string } + | undefined + if (detail?.error === 'already_claimed') { + const name = detail.claimed_by_name || 'another engineer' + const when = detail.claimed_at ? timeAgo(detail.claimed_at) : 'just now' + toast.info(`Already claimed by ${name} ${when}.`) + setSearchParams({}) + setMagicState('dismissed') + return + } + } + const message = e instanceof Error ? e.message : 'Failed to start AI analysis' + toast.error(message) + } finally { + setActiveOptionKey(null) + setLoading(false) + } + }, [urlSessionId, magicHandoff, setSearchParams, selectChat]) + const handleNewChat = async () => { // Invalidate currentChatRef BEFORE the await so any in-flight handleSend/handleTaskSubmit // for the previous session sees a mismatch and bails — prevents stale task lane appearing @@ -1546,8 +1692,12 @@ export default function AssistantChatPage() {
0 || activeQuestions.length > 0} + activeOptionKey={activeOptionKey} />
@@ -1888,46 +2038,142 @@ export default function AssistantChatPage() { /> )} - {/* Suggested-step chips (Codex correction, locked design): - visible after the magic-moment dissolves (post-claim) so the - senior can pull the AI's suggested next steps into the - composer with one click. Hides on first send or explicit X. */} + {/* Task-lane shortcut chips: visible after the magic-moment + dissolves when the task lane has loaded items. Each card + links directly to the corresponding diagnostic card in the + task lane — clicking opens the lane (if closed) and scrolls + to that card. Sourced from actual task lane items, not the + AI's free-text suggested_steps, so the card the user lands + on has full detail (description, command, etc.). */} {!chipsHidden && - magicHandoff?.ai_assessment_data?.suggested_steps && - magicHandoff.ai_assessment_data.suggested_steps.length > 0 && - magicState === 'dismissed' && ( -
-
-

- Suggested -

-
- {magicHandoff.ai_assessment_data.suggested_steps.map((step, i) => ( + (activeActions.length > 0 || activeQuestions.length > 0) && + magicState === 'dismissed' && (() => { + const chipItems = [ + ...activeActions.slice(0, 4).map((a, ai) => ({ + label: a.label, + cardIdx: activeQuestions.length + ai, + description: a.description, + command: a.command ?? null, + type: 'action' as const, + })), + ...activeQuestions.slice(0, Math.max(0, 4 - Math.min(activeActions.length, 4))).map((q, qi) => ({ + label: q.text, + cardIdx: qi, + description: q.context ?? null, + command: null, + type: 'question' as const, + })), + ] + const selectedChip = chipItems.find(c => c.cardIdx === selectedChipCardIdx) ?? null + return ( +
+
+
+

+ Suggested checks +

- ))} +
+ + {/* Inline detail card — shown when a chip is selected */} + {selectedChip && ( +
+
+ {selectedChip.label} + +
+ {selectedChip.description && ( +

{selectedChip.description}

+ )} + {selectedChip.command && ( +
+ {selectedChip.command} + +
+ )} + +
+ )} + +
+ {chipItems.map((item) => { + const isSelected = item.cardIdx === selectedChipCardIdx + return ( + + ) + })} +
-
-
- )} + ) + })()} {/* Rich Input */}
@@ -2284,7 +2530,13 @@ export default function AssistantChatPage() { {/* Conclude Session Modal */} setShowConclude(false)} + onClose={() => { + setShowConclude(false) + if (activeSessionStatus === 'escalated') { + toast.info('Session escalated. Heading back to your dashboard.') + navigate('/') + } + }} onConclude={handleConclude} onResumeNew={handleResumeNew} chatTitle={chats.find(c => c.id === activeChatId)?.title ?? 'Chat'} @@ -2347,7 +2599,6 @@ export default function AssistantChatPage() { > {}} onDismiss={() => setOverlayHandoff(null)} dismissible /> diff --git a/frontend/src/types/branching.ts b/frontend/src/types/branching.ts index f01565a1..14fcf2f1 100644 --- a/frontend/src/types/branching.ts +++ b/frontend/src/types/branching.ts @@ -86,14 +86,17 @@ export interface HandoffResponse { id: string session_id: string handed_off_by: string + handed_off_by_name: string | null intent: 'park' | 'escalate' source_branch_id: string | null snapshot: Record ai_assessment: string | null ai_assessment_data: { + summary_prose?: string + what_we_know?: string[] likely_cause: string suggested_steps: string[] - confidence: number + confidence: string } | null artifacts: Array<{ name: string