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