feat(escalations): magic-moment 3-option CTA + claim 500 fix

- HandoffContextScreen: 3-option layout (Continue/AI analysis/Own thing)
  with hasTaskLane, activeOptionKey, spinner/disabled states
- AssistantChatPage: wire up handleContinue, handleAIAnalysis, handleOwnThing
  handlers; chip detail expansion inline with copy-button fix; post-escalation
  redirect to dashboard on ConcludeSessionModal close
- TaskLane: fix async copy button (await + execCommand fallback + copiedKey
  visual feedback); whitespace-pre-wrap on command blocks
- Fix 500 on claim: Pydantic v2 model_validate() + model_copy(update={})
  (was passing update= kwarg directly which v2 rejects)
- HandoffResponse schema: handed_off_by_name field

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-30 00:05:02 -04:00
parent fb2dc222fd
commit db717b0b3f
11 changed files with 673 additions and 207 deletions

View File

@@ -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.

View File

@@ -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")

View File

@@ -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]

View File

@@ -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):

View File

@@ -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": ["<confirmed fact 1>", "<confirmed fact 2>"],\n'
' "likely_cause": "<one sentence root cause hypothesis>",\n'
' "suggested_steps": ["<next step 1>", "<next step 2>"],\n'
' "confidence": "<low or medium or high>"}'
)
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:

View File

@@ -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(

View File

@@ -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

View File

@@ -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<string | null>(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 (
<div key={idx} className="rounded-lg border-l-[3px] border-l-success border border-success/25 bg-success-dim/30 p-3 mb-2 cursor-pointer hover:border-success/40 transition-colors" onClick={() => updateTask(idx, { state: 'active' })}>
<div key={idx} id={`task-lane-card-${idx}`} className="rounded-lg border-l-[3px] border-l-success border border-success/25 bg-success-dim/30 p-3 mb-2 cursor-pointer hover:border-success/40 transition-colors" onClick={() => updateTask(idx, { state: 'active' })}>
<div className="flex items-center gap-1.5">
<Check size={12} className="text-success shrink-0" />
<span className="text-[0.8125rem] text-foreground">{q.text}</span>
@@ -337,7 +356,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
if (q.state === 'skipped') {
return (
<div key={idx} className="rounded-lg border border-default/50 bg-elevated/20 p-3 mb-2 opacity-60 cursor-pointer hover:opacity-80 hover:border-default transition-all" onClick={() => updateTask(idx, { state: 'pending' })} title="Click to restore">
<div key={idx} id={`task-lane-card-${idx}`} className="rounded-lg border border-default/50 bg-elevated/20 p-3 mb-2 opacity-60 cursor-pointer hover:opacity-80 hover:border-default transition-all" onClick={() => updateTask(idx, { state: 'pending' })} title="Click to restore">
<div className="flex justify-between">
<div className="text-[0.8125rem] text-muted-foreground line-through">{q.text}</div>
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Skipped</span>
@@ -347,7 +366,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
}
return (
<div key={idx} className="rounded-lg border border-default bg-card p-3 mb-2">
<div key={idx} id={`task-lane-card-${idx}`} className="rounded-lg border border-default bg-card p-3 mb-2">
<div className="text-[0.8125rem] text-heading leading-relaxed">{q.text}</div>
{q.context && (
<div className="text-[0.6875rem] text-muted-foreground mt-1">{q.context}</div>
@@ -430,10 +449,11 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
<div className="flex items-center justify-between mb-2">
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Combined script</span>
<button
onClick={() => handleCopy(combinedScript)}
className="flex items-center gap-1 text-[0.75rem] text-muted-foreground hover:text-heading"
onClick={() => void handleCopy(combinedScript)}
className="flex items-center gap-1 text-[0.75rem] text-muted-foreground hover:text-heading transition-colors"
>
<Copy size={11} /> Copy
{copiedKey === combinedScript ? <Check size={11} className="text-success" /> : <Copy size={11} />}
{copiedKey === combinedScript ? 'Copied' : 'Copy'}
</button>
</div>
<pre className="text-[0.75rem] font-mono text-heading whitespace-pre-wrap overflow-x-auto">{combinedScript}</pre>
@@ -448,7 +468,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
if (a.state === 'done') {
return (
<div key={idx} className="rounded-lg border-l-[3px] border-l-success border border-success/25 bg-success-dim/30 p-3 mb-2 cursor-pointer hover:border-success/40 transition-colors" onClick={() => updateTask(idx, { state: 'active' })}>
<div key={idx} id={`task-lane-card-${idx}`} className="rounded-lg border-l-[3px] border-l-success border border-success/25 bg-success-dim/30 p-3 mb-2 cursor-pointer hover:border-success/40 transition-colors" onClick={() => updateTask(idx, { state: 'active' })}>
<div className="flex items-center gap-1.5">
<Check size={12} className="text-success shrink-0" />
<span className="text-[0.8125rem] font-medium text-foreground flex-1">{a.label}</span>
@@ -459,7 +479,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
if (a.state === 'skipped') {
return (
<div key={idx} className="rounded-lg border border-default/50 bg-elevated/20 p-3 mb-2 opacity-60 cursor-pointer hover:opacity-80 hover:border-default transition-all" onClick={() => updateTask(idx, { state: 'pending' })} title="Click to restore">
<div key={idx} id={`task-lane-card-${idx}`} className="rounded-lg border border-default/50 bg-elevated/20 p-3 mb-2 opacity-60 cursor-pointer hover:opacity-80 hover:border-default transition-all" onClick={() => updateTask(idx, { state: 'pending' })} title="Click to restore">
<div className="flex justify-between">
<div className="text-[0.8125rem] text-muted-foreground line-through">{a.label}</div>
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Skipped</span>
@@ -469,7 +489,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
}
return (
<div key={idx} className="rounded-lg border border-default bg-card p-3 mb-2 hover:border-hover transition-colors">
<div key={idx} id={`task-lane-card-${idx}`} className="rounded-lg border border-default bg-card p-3 mb-2 hover:border-hover transition-colors">
<div className="text-[0.8125rem] font-medium text-heading">{a.label}</div>
{a.description && (
<div className="text-[0.6875rem] text-muted-foreground mt-0.5 leading-relaxed">{a.description}</div>
@@ -477,9 +497,16 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
{a.command && (
<div className="mt-2 flex items-center gap-2 rounded bg-code px-2.5 py-1.5">
<code className="flex-1 text-[0.6875rem] font-mono text-heading truncate">{a.command}</code>
<button onClick={() => handleCopy(a.command!)} className="shrink-0 text-muted-foreground hover:text-heading" title="Copy">
<Copy size={11} />
<code className="flex-1 text-[0.6875rem] font-mono text-heading whitespace-pre-wrap break-all">{a.command}</code>
<button
onClick={() => void handleCopy(a.command!)}
className="shrink-0 text-muted-foreground hover:text-heading transition-colors p-0.5 rounded"
title={copiedKey === a.command ? 'Copied!' : 'Copy command'}
>
{copiedKey === a.command
? <Check size={11} className="text-success" />
: <Copy size={11} />
}
</button>
</div>
)}

View File

@@ -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> | 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> | void
onAIAnalysis?: () => Promise<void> | void
onOwnThing?: () => Promise<void> | void
// Legacy single-CTA — used when dismissible=true (post-claim toolbar re-open)
onStartHere?: () => Promise<void> | 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<HTMLButtonElement>(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({
<p className="text-sm text-foreground">{likelyCause}</p>
</div>
)}
{whatWeKnow.length > 0 && (
<div>
<p className="font-sans text-[0.625rem] uppercase tracking-wider text-muted-foreground mb-1.5">
What we know
</p>
<ul className="space-y-1">
{whatWeKnow.map((fact, i) => (
<li key={i} className="text-sm text-foreground flex items-start gap-2">
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/50" />
<span>{fact}</span>
</li>
))}
</ul>
</div>
)}
{assessmentText && !likelyCause && (
<p className="text-sm text-foreground whitespace-pre-wrap">
{assessmentText}
@@ -287,22 +319,92 @@ export function HandoffContextScreen({
</section>
</div>
{/* Start here CTA */}
{!dismissible && (
<div className="mt-6 flex flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs text-muted-foreground">
Picking up assigns this session to you and reactivates it.
</p>
{/* CTA footer */}
{dismissible ? (
// Post-claim re-open from toolbar — single close action
<div className="mt-6 flex justify-end">
<button
ref={startBtnRef}
onClick={() => void onStartHere()}
disabled={isProcessing}
className="flex items-center justify-center gap-2 rounded-lg bg-accent px-5 py-3 min-h-[44px] text-sm font-semibold text-white hover:brightness-110 active:scale-[0.98] disabled:opacity-50 disabled:pointer-events-none transition-all"
onClick={() => onDismiss?.()}
className="px-4 py-2 rounded-lg text-sm text-muted-foreground hover:text-foreground bg-input border border-border hover:border-border-hover transition-all"
>
<ArrowRight size={14} />
{isProcessing ? 'Picking up…' : 'Start here'}
Close
</button>
</div>
) : (
// Pre-claim: 3 options (task lane exists) or 2 options (empty lane)
<div className="mt-6 space-y-2">
<p className="text-xs text-muted-foreground mb-3">
How would you like to approach this session?
</p>
{/* Continue — only when task lane has items */}
{hasTaskLane && onContinue && (
<button
ref={startBtnRef}
onClick={() => void onContinue()}
disabled={isProcessing}
className={cn(
'w-full flex items-center gap-3 rounded-lg px-4 py-3 min-h-[52px] text-sm font-semibold transition-all',
'bg-accent text-white hover:brightness-110 active:scale-[0.98] disabled:opacity-50 disabled:pointer-events-none',
)}
>
{activeOptionKey === 'continue' ? (
<Loader2 size={16} className="shrink-0 animate-spin" />
) : (
<ArrowRight size={16} className="shrink-0" />
)}
<span className="flex-1 text-left">
Continue where{' '}
<span className="font-bold">
{handoff.handed_off_by_name ?? 'the original engineer'}
</span>{' '}
left off
</span>
</button>
)}
{/* AI analysis */}
{onAIAnalysis && (
<button
ref={!hasTaskLane ? startBtnRef : undefined}
onClick={() => void onAIAnalysis()}
disabled={isProcessing}
className={cn(
'w-full flex items-center gap-3 rounded-lg border px-4 py-3 min-h-[52px] text-sm font-semibold transition-all disabled:opacity-50 disabled:pointer-events-none',
hasTaskLane
? 'border-border bg-card text-foreground hover:bg-elevated hover:border-border-hover active:scale-[0.98]'
: 'bg-accent text-white border-transparent hover:brightness-110 active:scale-[0.98]',
)}
>
{activeOptionKey === 'ai' ? (
<Loader2 size={16} className="shrink-0 animate-spin" />
) : (
<Sparkles size={16} className="shrink-0" />
)}
<span className="flex-1 text-left">Get AI analysis</span>
<span className="text-xs font-normal opacity-70">
{hasTaskLane ? 'Fresh take on what\'s been tried' : 'Generate diagnostic steps'}
</span>
</button>
)}
{/* Own approach */}
{onOwnThing && (
<button
onClick={() => void onOwnThing()}
disabled={isProcessing}
className="w-full flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3 min-h-[52px] text-sm text-foreground hover:bg-elevated hover:border-border-hover active:scale-[0.98] disabled:opacity-50 disabled:pointer-events-none transition-all"
>
{activeOptionKey === 'own' ? (
<Loader2 size={16} className="shrink-0 animate-spin text-muted-foreground" />
) : (
<User size={16} className="shrink-0 text-muted-foreground" />
)}
<span className="flex-1 text-left">I&apos;ll take it from here</span>
<span className="text-xs text-muted-foreground">I know what to try</span>
</button>
)}
</div>
)}
</div>
)

View File

@@ -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<HandoffResponse | null>(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<number | null>(null)
const [copiedChipCmd, setCopiedChipCmd] = useState(false)
const [chats, setChats] = useState<ChatListItem[]>([])
const [activeChatId, setActiveChatId] = useState<string | null>(() => {
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<string, unknown>
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() {
<div className="h-[calc(100vh-3.5rem)] overflow-y-auto p-4 sm:p-8">
<HandoffContextScreen
handoff={magicHandoff}
onStartHere={handleStartHere}
isProcessing={claiming}
onContinue={handleContinue}
onAIAnalysis={handleAIAnalysis}
onOwnThing={handleOwnThing}
isProcessing={activeOptionKey !== null}
hasTaskLane={activeActions.length > 0 || activeQuestions.length > 0}
activeOptionKey={activeOptionKey}
/>
</div>
</>
@@ -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' && (
<div className="px-3 sm:px-6 pt-2 shrink-0">
<div className="max-w-3xl mx-auto flex items-start gap-2">
<p className="font-sans text-[0.625rem] uppercase tracking-wider text-muted-foreground pt-1.5 shrink-0">
Suggested
</p>
<div className="flex flex-wrap gap-1.5 flex-1 min-w-0">
{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 (
<div className="px-3 sm:px-6 pt-2 pb-0.5 shrink-0">
<div className="max-w-3xl mx-auto">
<div className="flex items-center gap-2 mb-1.5">
<p className="font-sans text-[0.625rem] uppercase tracking-wider text-muted-foreground">
Suggested checks
</p>
<button
key={i}
type="button"
onClick={() => {
setInput(step)
inputRef.current?.focus()
}}
className="rounded-full border border-default bg-elevated px-3 py-1 text-xs text-foreground hover:bg-accent-dim hover:text-accent-text hover:border-accent/30 transition-colors text-left max-w-full truncate"
title={step}
onClick={() => { setChipsHidden(true); setSelectedChipCardIdx(null) }}
aria-label="Hide suggestions"
className="p-0.5 rounded text-muted-foreground hover:text-foreground hover:bg-elevated transition-colors"
>
{step}
<X size={11} />
</button>
))}
</div>
{/* Inline detail card — shown when a chip is selected */}
{selectedChip && (
<div className="mb-2 rounded-lg border border-default bg-card p-3 animate-fade-in">
<div className="flex items-start justify-between gap-2 mb-1.5">
<span className="text-[0.8125rem] font-medium text-heading leading-snug">{selectedChip.label}</span>
<button
onClick={() => setSelectedChipCardIdx(null)}
className="shrink-0 p-0.5 rounded text-muted-foreground hover:text-foreground transition-colors"
aria-label="Close detail"
>
<X size={12} />
</button>
</div>
{selectedChip.description && (
<p className="text-[0.6875rem] text-muted-foreground mb-2 leading-relaxed">{selectedChip.description}</p>
)}
{selectedChip.command && (
<div className="rounded-md bg-code border border-default/50 px-3 py-2 flex items-start gap-2 mb-2.5">
<code className="flex-1 text-[0.75rem] font-mono text-heading whitespace-pre-wrap break-all leading-relaxed">{selectedChip.command}</code>
<button
onClick={async () => {
try {
await navigator.clipboard.writeText(selectedChip.command!)
} catch {
try {
const el = document.createElement('textarea')
el.value = selectedChip.command!
el.style.cssText = 'position:fixed;opacity:0;pointer-events:none'
document.body.appendChild(el)
el.select()
document.execCommand('copy')
document.body.removeChild(el)
} catch { return }
}
setCopiedChipCmd(true)
setTimeout(() => setCopiedChipCmd(false), 1500)
}}
className="shrink-0 p-1 rounded text-muted-foreground hover:text-heading hover:bg-elevated transition-colors mt-0.5"
title={copiedChipCmd ? 'Copied!' : 'Copy command'}
>
{copiedChipCmd
? <Check size={13} className="text-success" />
: <Copy size={13} />
}
</button>
</div>
)}
<button
onClick={() => {
setSelectedChipCardIdx(null)
if (!showTaskLane) setShowTaskLane(true)
const el = document.getElementById(`task-lane-card-${selectedChip.cardIdx}`)
if (el) {
setTimeout(() => el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }), showTaskLane ? 0 : 200)
}
}}
className="flex items-center gap-1 text-[0.75rem] font-medium text-accent-text hover:text-accent transition-colors"
>
<ArrowRight size={11} />
Open in Tasks panel
</button>
</div>
)}
<div className="flex gap-2 overflow-x-auto pb-1" style={{ scrollbarWidth: 'none' }}>
{chipItems.map((item) => {
const isSelected = item.cardIdx === selectedChipCardIdx
return (
<button
key={item.cardIdx}
type="button"
onClick={() => {
setCopiedChipCmd(false)
setSelectedChipCardIdx(isSelected ? null : item.cardIdx)
}}
className={cn(
'flex items-start gap-2 rounded-lg border px-3 py-2.5 text-left transition-colors shrink-0 w-[172px]',
isSelected
? 'border-accent/50 bg-accent-dim'
: 'border-default bg-card hover:bg-accent-dim hover:border-accent/30',
)}
>
<ArrowRight size={12} className="text-accent-text shrink-0 mt-0.5" />
<span className="text-xs text-foreground line-clamp-2 leading-snug">{item.label}</span>
</button>
)
})}
</div>
</div>
<button
type="button"
onClick={() => setChipsHidden(true)}
aria-label="Hide suggestions"
className="p-1 rounded text-muted-foreground hover:text-foreground hover:bg-elevated transition-colors shrink-0"
>
<X size={12} />
</button>
</div>
</div>
)}
)
})()}
{/* Rich Input */}
<div className="px-3 sm:px-6 py-3 shrink-0">
@@ -2284,7 +2530,13 @@ export default function AssistantChatPage() {
{/* Conclude Session Modal */}
<ConcludeSessionModal
isOpen={showConclude}
onClose={() => 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() {
>
<HandoffContextScreen
handoff={overlayHandoff}
onStartHere={() => {}}
onDismiss={() => setOverlayHandoff(null)}
dismissible
/>

View File

@@ -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<string, unknown>
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