Merge PR #155: Escalation Mode wedge — live arrival + magic-moment pickup
Magic-moment handoff-context screen on senior pickup, live SSE escalation arrivals, time-to-first-action metric, role-gated claim with atomic conflict resolution, and chat ownership extension for claimed sessions.
This commit was merged in pull request #155.
This commit is contained in:
@@ -1,20 +1,83 @@
|
||||
# CURRENT_TASK.md
|
||||
|
||||
**Task:** No active task — pick from [`TODO.md`](TODO.md).
|
||||
**Task:** Build **Escalation Mode** — the wedge for ResolutionFlow's GTM (first paying-customer push). When a junior tech escalates a FlowPilot session, the senior tech sees structured handoff context in seconds instead of running a 5-minute verbal "tell me what you tried" call.
|
||||
|
||||
**Status:** ready for next pickup.
|
||||
**Status:** ✅ **Engineering complete.** Browser QA passed (2026-04-30). Branch `feat/escalation-metric-endpoint`; PR #155 ready to mark ready-for-review.
|
||||
|
||||
## Recommended next moves
|
||||
**Plan:** [`docs/plans/2026-04-27-escalation-mode-wedge-design.md`](../docs/plans/2026-04-27-escalation-mode-wedge-design.md). Reviewed by `/office-hours`, `/plan-eng-review`, `/plan-design-review`, `/codex review`. Eng + Design CLEARED.
|
||||
|
||||
1. **Promote `CI / e2e (pull_request)` to required on `main`.** Two consecutive PR runs (#150 and #153) have now finished green on the e2e job. That was the threshold the prior CI-recovery task set for promoting it. Branch protection update only — no code change.
|
||||
2. **Pick a backlog item.** Top of `TODO.md` "Up next" is the `data-testid` e2e-stability work (PR #152 spent five one-line selector updates chasing UI churn — adding stable test IDs to a small set of high-value elements would make those tests immune to copy/route renames). The new `currentChatRef` silent-return audit added in #153's session is in Backlog and is a natural pairing with the bug fix that was just shipped.
|
||||
**Test plan artifact:** [`docs/plans/2026-04-27-escalation-mode-wedge-test-plan.md`](../docs/plans/2026-04-27-escalation-mode-wedge-test-plan.md).
|
||||
|
||||
## Previous task — closed out
|
||||
## What's done (all sessions combined)
|
||||
|
||||
**Task:** Land PR #153 — fix the `AssistantChatPage` prefill `currentChatRef` bug that silently dropped AI follow-up responses in the task lane.
|
||||
All plan items complete. Key commits on `feat/escalation-metric-endpoint`:
|
||||
|
||||
**Status:** complete (2026-04-26).
|
||||
| Commit | What it ships |
|
||||
|---|---|
|
||||
| `d51e95c` | Plan + test-plan artifacts |
|
||||
| `52f6d03` | `GET /analytics/flowpilot/escalations` — time-to-first-action metric |
|
||||
| `7a5b853` | Role-gate claim to engineer-or-admin |
|
||||
| `07d0db9` | Email notifications on escalation |
|
||||
| `9f0bfd4` | `EscalationMetricCard` on `/escalations` |
|
||||
| `b8627f4` | SSE live-arrival animations in `EscalationQueue` |
|
||||
| `8e9d22e` | Magic-moment handoff-context screen |
|
||||
| `641853a` | Bell-icon opens pickup flow |
|
||||
| `029680a` | Unify `/escalate` through `HandoffManager` |
|
||||
| `0f00ee5` | Plan-locked polish: chips, unread dot, race toast, AI refresh |
|
||||
| `665530f` | Structural task-lane race fix |
|
||||
| `db717b0` | 3-option CTA, copy button fix, post-escalation redirect, claim 500 fix |
|
||||
| `dc69c9d` | Allow `escalated_to_id` to send chat (GET AI analysis fix) |
|
||||
|
||||
- PR #153 merged as commit `68fcdc6` on `main`. Backend, frontend, and e2e all green on the merged SHA after the env-var fix.
|
||||
- E2e CI needed a stub `ANTHROPIC_API_KEY` in the workflow so the AI-gated `POST /api/v1/ai-sessions` endpoint stops returning 503; the Playwright `page.route` stub still intercepts the actual `/chat` call in the browser, so no real Anthropic traffic occurs.
|
||||
- Regression test `frontend/e2e/assistant-chat-prefill.spec.ts` is part of the e2e suite going forward.
|
||||
**Browser QA results (2026-04-30):**
|
||||
|
||||
- ✅ Post-escalation redirect (dashboard + toast)
|
||||
- ✅ Magic-moment screen: header, AI assessment, 2-option CTA
|
||||
- ✅ "I'll take it from here": claim → dismiss → composer focused
|
||||
- ✅ "Get AI analysis": claim → briefing → AI responds → task lane populates
|
||||
- ✅ Task lane copy button: toast + checkmark
|
||||
- ✅ Chip expansion: inline detail + "Open in Tasks panel"
|
||||
- ✅ Post-claim overlay: dismissible mode, Close only
|
||||
|
||||
## Done on `feat/escalation-metric-endpoint` (branched from `main` @ `c0ed6d9`)
|
||||
|
||||
| Commit | What it ships |
|
||||
|---|---|
|
||||
| `d51e95c` | Plan + test-plan artifacts |
|
||||
| `52f6d03` | `GET /analytics/flowpilot/escalations` — in-product time-to-first-action |
|
||||
| `7a5b853` | Role-gate POST `/handoffs/{id}/claim` to engineer-or-admin |
|
||||
| `07d0db9` | `HandoffManager.dispatch_escalation_notifications` — emails engineer/admin teammates |
|
||||
| `9f0bfd4` | `EscalationMetricCard` mounted above the queue list |
|
||||
| `bc15952` | Codex: stabilize SSE backend tests |
|
||||
| `9bdd995` | Bound escalation assessment latency (ORIGINAL: 5s) |
|
||||
| `b8627f4` | Frontend SSE subscription in `EscalationQueue.tsx` — live-arrival animations |
|
||||
| `8e9d22e` | Magic-moment handoff-context screen on pickup |
|
||||
| `641853a` | Bell-icon notification opens the pickup flow |
|
||||
| `029680a` | Unify `/escalate` through `HandoffManager` |
|
||||
| `8914391` | First task-lane race fix (insufficient — see `665530f`) |
|
||||
| `0f00ee5` | Four plan-locked items: live AI refresh, suggested-step chips, unread dot, race-condition toast |
|
||||
| `665530f` | Structural task-lane fix — `taskLaneOwnerChatId` tagging |
|
||||
| `b7d7ff0` | docs(ai): refresh handoff for compute swap |
|
||||
| `0d1b305` | **Live-test fixes**: selectChat-gating bug (loadedChatIdsRef), 45s timeout bump, Enter-to-submit on escalate forms, dashboard expand-to-preview |
|
||||
|
||||
## Live-test results (2026-04-29 morning)
|
||||
|
||||
After the structural task-lane fix and the four polish items, end-to-end test confirmed:
|
||||
|
||||
- ✅ Junior escalates → senior gets bell-icon notification.
|
||||
- ✅ Magic-moment screen renders with handoff data on Pick Up.
|
||||
- ✅ Senior's chat surface loads with conversation history (after `0d1b305`'s selectChat fix — was completely broken before).
|
||||
- ✅ Sidebar shows the picked-up session with the "Escalated" pill (after `0d1b305`'s `loadChats()` call).
|
||||
- ✅ Suggested-step chips render below the composer.
|
||||
- ✅ Unread 6px dot on queue cards.
|
||||
- ✅ Task-lane regression is gone — no stale flash on new sessions.
|
||||
- ❌ **AI assessment placeholder never clears.** Drives the consolidation work above.
|
||||
|
||||
Untested live (low priority, can verify post-consolidation): race-condition toast (needs second user in same account).
|
||||
|
||||
## Two-metric framing — read this before quoting numbers to anyone
|
||||
|
||||
The in-product endpoint measures *post-claim time-to-first-action*. The "minutes recovered" sales claim is `manual_baseline − in_product_metric`. Manual baseline comes from the founder's stopwatch on the next 5 escalations. Don't roll the in-product number alone into "minutes recovered" — that's the apples-to-oranges miscount Codex caught.
|
||||
|
||||
## Kill-switch
|
||||
|
||||
Week 8: if 0 of 3 pilots produce a verifiable hours-saved-per-week number above 1.0, revisit the wedge.
|
||||
|
||||
@@ -13,6 +13,91 @@
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-30 — Allow `escalated_to_id` to send chat messages in claimed sessions
|
||||
|
||||
**Context:** During browser QA, clicking "Get AI analysis" on the magic-moment screen returned `POST /ai-sessions/{id}/chat → 400`. The senior tech who claimed the session is stored as `escalated_to_id` on `AISession`, not `user_id` (which remains the junior who created the session). `unified_chat_service.send_chat_message` queried `WHERE ai_sessions.user_id = :user_id`, so the senior's ID never matched and the endpoint rejected the request.
|
||||
|
||||
**Decision:** Extend the ownership check in `send_chat_message` to `OR ai_sessions.escalated_to_id = :user_id` using SQLAlchemy `or_()`. This is the minimal, correct fix: the session model already has a semantically valid "also owns" field for the claiming senior; extending the WHERE clause makes that ownership real.
|
||||
|
||||
**Rejected:**
|
||||
|
||||
- **Transfer `user_id` to the senior on claim.** Breaks the audit trail — `user_id` is the originating engineer throughout the session lifecycle. Any query scoped to "sessions this engineer worked on" would silently lose the junior's history.
|
||||
- **A separate `can_send_message` service method.** Adds indirection with no benefit for v1. One `or_()` line in the existing query is sufficient.
|
||||
- **Checking a role/permission flag instead.** Role gating (engineer/admin) already happens at the claim endpoint. The chat-send check is about session ownership, not role. Mixing the two concerns would be confusing.
|
||||
|
||||
**Consequences:**
|
||||
- Seniors can send AI briefings and continue chat work in sessions they have claimed. Core escalation pickup flow unblocked.
|
||||
- Any future caller of `send_chat_message` should be aware that "user_id or escalated_to_id" is the ownership rule. The service-level check is the single enforcement point.
|
||||
- `user_id` remains the originating engineer for all audit, history, and analytics queries. No data migration needed.
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-29 — Consolidate the three per-escalation AI calls into one structured generation
|
||||
|
||||
**Context:** A single user-initiated escalation currently triggers three separate Sonnet calls, all summarizing the same source material (session state, steps taken, "what we know") from slightly different angles:
|
||||
|
||||
1. `_build_escalation_package_enhanced` — runs in the background `enrich_escalation_async` task, builds a rich JSON payload that's saved to `ai_session.escalation_package`.
|
||||
2. `_generate_ai_assessment` — also background, returns the magic-moment screen fields (`likely_cause`, `suggested_steps[]`, `confidence`).
|
||||
3. `generate_status_update` — engineer-triggered when they click "Ticket Notes" / "Client Update" / "Email Draft" in the conclude modal, generates audience-specific PSA prose.
|
||||
|
||||
The user surfaced the smell: the engineer is *typically* generating a status update during the escalate flow, so the AI assessment work is being done twice with overlapping context and the engineer's PSA prose is being thrown away. Live test on 2026-04-29 also showed that bumping the assessment timeout 15s → 45s did NOT fix the empty-placeholder bug — meaning the architectural smell is also a demo blocker.
|
||||
|
||||
**Decision:** ONE structured AI call per escalation that produces a single payload covering both the magic-moment screen's diagnostic fields AND the PSA-ready prose. Persist to `SessionHandoff`. The conclude modal's "Ticket Notes" button reads from the saved prose instead of calling the model. "Client Update" and "Email Draft" buttons trigger a cheap Haiku transformation over the saved prose (tone shift only, not a re-summarization).
|
||||
|
||||
Proposed payload shape (final form decided during implementation):
|
||||
|
||||
```json
|
||||
{
|
||||
"summary_prose": "<PSA-flavored ticket-notes paragraph>",
|
||||
"what_we_know": ["<one-liner>"],
|
||||
"likely_cause": "<one sentence>",
|
||||
"suggested_steps": ["<short step>"],
|
||||
"confidence": "low | medium | high",
|
||||
"audience_variants": {"client_update": null, "email_draft": null}
|
||||
}
|
||||
```
|
||||
|
||||
`audience_variants` filled lazily on first user request, cached.
|
||||
|
||||
**Rejected:**
|
||||
|
||||
- **Just bumping the timeout further.** Already tried 5s → 15s → 45s. The architectural redundancy is the real cost — even if Sonnet completed reliably, three calls per escalation is wasteful and creates three places where state can diverge.
|
||||
- **Reusing the engineer's status update content as the AI assessment.** User's first instinct, but: status updates aren't always generated (engineer has to click), they're audience-specific (so you'd pick which one to copy), and they're prose without the structured fields the magic-moment screen needs. The right consolidation is the OTHER direction — generate ONE structured payload that the status-update buttons consume.
|
||||
- **Switching the assessment to Haiku for speed.** Faster but solves only the latency symptom, not the redundancy. Doesn't help the conclude modal's status-update buttons.
|
||||
|
||||
**Consequences:**
|
||||
|
||||
- Magic-moment screen populates in ~5s instead of 25s+ (work happens in the foreground escalate path, not in a background task that races with the senior's pickup).
|
||||
- Token spend per escalation drops by ~60% — one Sonnet call replaces two; the third (audience variants) becomes Haiku.
|
||||
- Engineer's "Ticket Notes" button is instant — no model round-trip.
|
||||
- Schema enforcement matters. The current `_generate_ai_assessment` returns freeform prose that the frontend stuffs into `assessment_text` because the structured fields aren't reliably parseable. The new call must use Anthropic's structured output / tool-use to enforce the schema.
|
||||
- Migration concern: `ai_session.escalation_package` JSON column has live data on existing sessions. Keep it READABLE for backward compatibility; just stop *writing* the enhanced payload from `enrich_escalation_async`. If downstream queue summaries depend on it, dual-write the basic snapshot.
|
||||
- Test fixtures (`test_handoff_manager.py`, `test_session_handoffs_api.py`) currently stub `_generate_ai_assessment` via `AsyncMock`. Updating the stubs is part of the rename.
|
||||
- The frontend SSE assessment-ready subscription (added in `0f00ee5`) stays as-is — it just listens for the new event payload.
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-28 — Tag the task-lane state with an owner chatId
|
||||
|
||||
**Context:** A recurring bug — every time the user returned to test escalation work, creating a new session would flash the previous session's task-lane data (questions, actions, "Tasks" pill counts) before the new session's AI response landed. The first attempt to fix it (`8914391`) added initializer-time guards (`incomingPrefill || isPickup`) that skipped the sessionStorage restore on mount. That covered exactly two entry paths and missed every other case: in-place URL navigation, mid-flight pickup, HMR re-runs, and the gap between `setActiveChatId(B)` and the AI response that finally populates B's questions/actions. The persistence effect made it worse by writing `{chatId: activeChatId, questions: activeQuestions}` — at any moment where activeChatId had flipped before the questions were updated, sessionStorage was stamped with `{chatId: B, questions: [A's data]}` and a subsequent restore would happily render A's data for B.
|
||||
|
||||
The root cause was that `activeQuestions` / `activeActions` / `showTaskLane` were three independent state slices implicitly assumed to be in sync with `activeChatId`. The synchronization was by convention, not by structure. Every code path that mutated them had to remember to call `resetSessionDerivedState` first; missing one created stale UI.
|
||||
|
||||
**Decision:** Add a `taskLaneOwnerChatId` state that records *which chatId the in-memory questions/actions belong to*, set at every site that populates them (sendPrefill, selectChat, handleSend, handleTaskSubmit, handleResumeNew, refreshFacts, handleApplyFix), cleared in `resetSessionDerivedState`. The persistence effect writes ownerChatId as the chatId tag. Render is gated on `taskLaneOwnerChatId === activeChatId` and ANDed into all three render conditions (toolbar Tasks button, narrow-viewport floating drawer, main side panel). The mount-time `skipTaskLaneRestore` guard stays as belt-and-braces for the prefill/pickup entry-flash window, which the owner-gate alone doesn't cover.
|
||||
|
||||
**Rejected:**
|
||||
- **More entry-path guards.** That's whack-a-mole — the next path nobody anticipated will reproduce the bug. The owner-gate makes the bug structurally impossible regardless of which path triggers it.
|
||||
- **Combining the four state slices into a single tagged object.** Cleaner long-term but a bigger refactor with more touch points. The owner-tracking approach gets the structural guarantee with a minimal diff and keeps the existing setState patterns.
|
||||
- **Inlining the comparison at every render site.** Works but proliferates the comparison; one named derived value (`taskLaneIsForActiveChat`) reads better and groups the gate with the persistence-effect / state declarations as a named concept.
|
||||
|
||||
**Consequences:**
|
||||
- Stale task-lane data is structurally unable to display. The lane is hidden during any window where `ownerChatId !== activeChatId`, no matter what mutation path got you there.
|
||||
- Adding new sites that populate `activeQuestions` / `activeActions` requires also setting `taskLaneOwnerChatId`. The pattern is documented in the commit message and visible in every existing populate site as a paired call.
|
||||
- The mount-time `skipTaskLaneRestore` guard is now redundant in steady-state but kept for the few-hundred-ms flash window between component mount and the first sendPrefill / selectChat effect. Deleting it would re-introduce a (smaller) flash without strong reason.
|
||||
- Future task-lane state slices (e.g. `facts`, `activeFix`) follow the same pattern: gate their visibility on the owner check via the existing render conditions. Tagging more slices with their own `*OwnerChatId` is a future refactor if the slices diverge.
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-24 — Adopt dual-agent handoff system (`.ai/` + `CLAUDE.md` + `AGENTS.md`)
|
||||
|
||||
**Context:** Claude Code hits session and weekly usage limits. Work stalls when the primary agent is locked out. Needed a structured way for OpenAI Codex to resume where Claude left off without losing architectural truth or drifting across sessions.
|
||||
|
||||
@@ -2,27 +2,55 @@
|
||||
|
||||
# HANDOFF.md
|
||||
|
||||
**Last updated:** 2026-04-26 04:55 EDT
|
||||
**Last updated:** 2026-04-30 (Codex review-fix pass)
|
||||
|
||||
**Active task:** None — pick from [`TODO.md`](TODO.md). See [`CURRENT_TASK.md`](CURRENT_TASK.md) for recommended next moves.
|
||||
**Active task:** **Escalation Mode** wedge — BROWSER QA COMPLETE + review fixes applied. Branch: `feat/escalation-metric-endpoint`. PR #155 ready to mark ready-for-review after committing this fix pass.
|
||||
|
||||
**Branch:** `main` is the home position. Recent merges: PR #150 (CI recovery, `87bb20b`), PR #153 (prefill `currentChatRef` fix, `68fcdc6`).
|
||||
## Where this session ended
|
||||
|
||||
## Where things stand
|
||||
Code-review fixes were applied after browser QA:
|
||||
|
||||
- CI is healthy on `main`: backend, frontend, and e2e all green on the latest commits.
|
||||
- Branch protection on `main`: PR-only merges, force-push blocked, **`CI / frontend (pull_request)` required**, **`CI / backend (pull_request)` required**, `CI / e2e (pull_request)` not yet required.
|
||||
- Two consecutive PR runs (#150, #153) finished green on e2e. The "promote e2e to required" gate from the prior task is now satisfiable.
|
||||
- Backend AI-gated endpoints (`POST /ai-sessions`, `/chat`, `/respond`, etc.) call `_require_ai_enabled()` and return 503 if no provider key is set. The e2e CI job now sets a stub `ANTHROPIC_API_KEY` so any future test that exercises those flows can rely on it; tests should still stub the actual AI calls in the browser via `page.route` so no real Anthropic traffic occurs.
|
||||
- `claim_session` now uses atomic conditional `UPDATE ... WHERE claimed_by IS NULL` instead of read-then-write, so simultaneous senior pickup cannot silently overwrite `claimed_by`.
|
||||
- Original escalators cannot claim their own handoff. The escalation queue also excludes the current user's own escalated sessions, preventing the post-escalation dashboard from showing the junior their own handoff.
|
||||
- `session.escalation_package["handoff_id"]` is now populated from a preassigned UUID instead of `None` before flush.
|
||||
- Frontend build blockers removed: deleted unused legacy `claiming` / `handleStartHere` path in `AssistantChatPage` and unused `onStartHere` destructuring in `HandoffContextScreen`.
|
||||
|
||||
## Immediate next steps
|
||||
**Validation:**
|
||||
|
||||
1. (Optional, ops-only) Promote `CI / e2e (pull_request)` to required on `main` in Gitea branch protection.
|
||||
2. Pick the next backlog item from `TODO.md`. Top of "Up next" is the `data-testid` e2e-stability audit; the new `currentChatRef` silent-return audit (added to backlog in this session) is a natural pairing with the bug fix that just shipped.
|
||||
- `git diff --check` ✅
|
||||
- `cd backend && pytest --override-ini='addopts=' tests/test_handoff_manager.py tests/test_session_handoffs_api.py tests/test_escalation_bus.py` ✅ `28 passed in 42.23s`
|
||||
- `cd frontend && /config/.bun/bin/bunx tsc -p tsconfig.app.json --noEmit --pretty false && /config/.bun/bin/bunx tsc -p tsconfig.node.json --noEmit --pretty false` ✅
|
||||
- Full frontend build could not complete because generated dirs are root-owned in this workspace: `frontend/node_modules/.tmp`, `frontend/node_modules/.vite-temp`, and likely `frontend/dist` produce EACCES. Type errors from review are fixed.
|
||||
|
||||
## Useful breadcrumbs
|
||||
**Not testable in dev (known limitations):**
|
||||
- "Continue where X left off": requires senior to have existing task lane for session (won't occur on first pickup)
|
||||
- Browser-level 409 race toast still requires two distinct senior accounts. Backend claim write is now atomic and covered by service/API tests for conflict, self-claim, and idempotent same-user retry.
|
||||
|
||||
- The fix that just landed: [`frontend/src/pages/AssistantChatPage.tsx`](../frontend/src/pages/AssistantChatPage.tsx) — `currentChatRef.current = session.session_id` after `setActiveChatId` in the dashboard prefill effect.
|
||||
- Regression test: [`frontend/e2e/assistant-chat-prefill.spec.ts`](../frontend/e2e/assistant-chat-prefill.spec.ts).
|
||||
- E2e env convention: [`.gitea/workflows/ci.yml`](../.gitea/workflows/ci.yml) — `ANTHROPIC_API_KEY` is stubbed in the e2e job env. Tests that exercise AI-gated endpoints should stub the actual AI calls in the browser, not rely on a real key.
|
||||
- Silent-return follow-up entry: [`.ai/TODO.md`](TODO.md), Backlog section.
|
||||
## Resume point — DO THIS NEXT
|
||||
|
||||
**Ship:** Commit this review-fix pass, then mark PR #155 ready-for-review and demo to stakeholder.
|
||||
|
||||
Optional before shipping:
|
||||
- Record Loom demo walking through the escalation flow end-to-end
|
||||
|
||||
## Key files changed this session
|
||||
|
||||
- `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/api/endpoints/ai_sessions.py` — escalation queue excludes the current user's own escalations
|
||||
- `backend/app/api/endpoints/session_handoffs.py` — self-claim returns 403
|
||||
- `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
|
||||
- `backend/tests/test_handoff_manager.py`, `backend/tests/test_session_handoffs_api.py` — regression coverage for atomic/idempotent claim, self-claim rejection, queue self-exclusion, and pre-flush handoff ID
|
||||
|
||||
## Watch-outs
|
||||
|
||||
- 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.
|
||||
- Legacy `claiming` / `handleStartHere` on `AssistantChatPage` was removed; `activeOptionKey !== null` is the active pre-claim processing signal.
|
||||
- The bus is acceptable for v1 pilot scale only (Railway single-replica). Redis pub/sub is the swap when horizontal scaling appears.
|
||||
|
||||
@@ -12,6 +12,141 @@
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-30 06:25 UTC — Codex — Apply Escalation Mode review fixes
|
||||
|
||||
- Reviewed the recent Escalation Mode wedge work and fixed the actionable findings before PR #155 is marked ready.
|
||||
- Reworked `HandoffManager.claim_session` from read-then-write to an atomic conditional update, preserving idempotent same-user retries and returning a typed conflict for a different claimant.
|
||||
- Blocked original engineers from claiming their own handoffs and filtered their own escalated sessions out of `/ai-sessions/escalation-queue`, preventing the post-escalation dashboard from showing a junior their own handoff.
|
||||
- Fixed the compatibility payload so `session.escalation_package["handoff_id"]` is populated from a preassigned UUID before flush.
|
||||
- Removed unused legacy frontend pickup state (`claiming`, `handleStartHere`, unused `onStartHere` destructuring) that made `tsc -b` fail under `noUnusedLocals`.
|
||||
- Added regression coverage for pre-flush handoff IDs, conflict handling, self-claim rejection, successful non-owner claim, and own-escalation queue exclusion.
|
||||
- Verified `git diff --check`; focused backend tests passed (`28 passed in 42.23s`); frontend `tsc --noEmit` checks passed for app and node configs. Full Vite/build script remains blocked by root-owned generated directories under `frontend/node_modules` / `frontend/dist` in this workspace, not by TypeScript errors.
|
||||
- Files touched: `backend/app/services/handoff_manager.py`, `backend/app/api/endpoints/ai_sessions.py`, `backend/app/api/endpoints/session_handoffs.py`, `backend/tests/test_handoff_manager.py`, `backend/tests/test_session_handoffs_api.py`, `frontend/src/components/flowpilot/HandoffContextScreen.tsx`, `frontend/src/pages/AssistantChatPage.tsx`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`.
|
||||
|
||||
## 2026-04-30 — Claude Code — Browser QA pass complete; chat ownership bug found and fixed; PR #155 ready
|
||||
|
||||
- Ran full browser QA pass on the escalation mode feature using gstack `/qa` skill.
|
||||
- **Critical bug found and fixed (commit `dc69c9d`):** `POST /ai-sessions/{id}/chat → 400` when senior clicked "Get AI analysis" on the magic-moment screen. Root cause: `unified_chat_service.send_chat_message` checked `AISession.user_id == user_id` only; senior is stored as `escalated_to_id`, not `user_id`. Fix: `or_(AISession.user_id == user_id, AISession.escalated_to_id == user_id)` in the WHERE clause.
|
||||
- **All 7 QA scenarios passed:**
|
||||
- Post-escalation redirect: junior routed to `/` with "Session escalated" toast.
|
||||
- Magic-moment screen: header, metadata, two-column AI assessment, 2-option CTA rendered correctly.
|
||||
- "I'll take it from here": claim → dismiss overlay → composer focused.
|
||||
- "Get AI analysis": claim → briefing sent → AI responded → task lane populated (after `dc69c9d` fix).
|
||||
- Task lane copy button: toast + checkmark visual feedback.
|
||||
- Chip expansion: inline detail card + "Open in Tasks panel" scroll.
|
||||
- Post-claim toolbar re-open: dismissible mode with Close-only CTA.
|
||||
- **Known non-blockers:** "Continue where X left off" path untestable on first pickup (`hasTaskLane=false` is correct v1 behavior). 409 race condition untestable with one senior account; backend logic code-reviewed and correct.
|
||||
- Backend tests: 17/17 pass.
|
||||
- Updated `HANDOFF.md` to reflect QA complete; updated `CURRENT_TASK.md` status to engineering+QA complete; appended architectural decision to `DECISIONS.md`.
|
||||
- Branch `feat/escalation-metric-endpoint` is ready for PR #155 to be marked ready-for-review.
|
||||
- **Files touched this session:** `backend/app/services/unified_chat_service.py`, `.ai/HANDOFF.md`, `.ai/CURRENT_TASK.md`, `.ai/DECISIONS.md`, `.ai/SESSION_LOG.md`.
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-29 04:30 EDT — Claude Code — Live QA bash, pickup bug fixes, AI summary consolidation surfaced
|
||||
|
||||
- User on a freshly swapped computer ran the live QA flow. Identified two bugs missed by static analysis from the previous session:
|
||||
- **Pickup landed on a blank chat surface.** Root cause: commit `8914391` had made `activeChatId` initialize from `urlSessionId`, which broke the selectChat-gating effect in `AssistantChatPage` (`urlSessionId === activeChatId` short-circuited fresh mounts). Symptom was `selectChat` never firing post-claim; messages, conversation history, and pickup-flow correctness all silently broken.
|
||||
- **Picked-up session missing from sidebar.** Root cause: `loadChats` runs once at mount; pre-claim the session's `escalated_to_id` is null (the junior didn't specify a target), so `listSessions` doesn't return it. Post-claim `claim_session` sets `escalated_to_id` to teamadmin, but the sidebar list never refreshes.
|
||||
- Fixes (commit `0d1b305`):
|
||||
- Replaced the `urlSessionId === activeChatId` gate with a `loadedChatIdsRef` set so selectChat fires once per URL session per page lifecycle, regardless of whether activeChatId already matches.
|
||||
- Added `loadChats()` call in `handleStartHere` after the claim succeeds so the sidebar reflects ownership.
|
||||
- Three additional pieces folded into `0d1b305` from the same QA bash:
|
||||
- **Enter-to-submit on the escalate forms.** Chat-input convention: plain Enter submits, Shift+Enter inserts a newline. Added optional `onSubmit` prop to `RichTextInput` (used by `EscalateModal`) and inline `onKeyDown` on the plain textarea in `ConcludeSessionModal`. The user explicitly asked for this — they want to type the reason and hit Enter without reaching for the mouse.
|
||||
- **Dashboard `PendingEscalations` rows expand to preview.** Click a row to reveal escalation reason + step count + confidence tier + PSA ticket number. Pick Up button click-stops to still go directly to magic moment. Single expansion at a time.
|
||||
- **`ESCALATION_AI_ASSESSMENT_TIMEOUT_SECONDS` bumped 15 → 45.** Backend logs showed Sonnet hitting the 15s timeout in field testing. Background-task architecture (e8ba74e) means this no longer blocks the user — only bounds before publishing `has_assessment: false`. **Did NOT fix the live demo.** Assessment placeholder still permanent in user's test.
|
||||
- Surfaced an architectural smell: the escalation flow makes **three** Sonnet calls — `_build_escalation_package_enhanced`, `_generate_ai_assessment`, and `generate_status_update` (engineer-triggered) — all summarizing the same source material from slightly different angles. User correctly observed: status update is typically generated during the escalate flow anyway; reusing that content would consolidate.
|
||||
- Decided the right consolidation: ONE structured AI call per escalation that returns both the magic-moment diagnostic fields (`likely_cause`, `suggested_steps[]`, `confidence`) AND PSA-ready prose. Magic moment populates immediately. Status update buttons become tone-shift transformations (Haiku) of the saved prose, not fresh summarizations. Drops to 1 call (~60% token reduction), eliminates the AI-summary placeholder bug because the work happens in the foreground escalate path. Full implementation plan written into CURRENT_TASK.md and DECISIONS.md.
|
||||
- Session ended pre-consolidation: user is updating Claude Code CLI and starting a fresh session for clean context window. All work pushed to origin (`0d1b305`). PR #155 still draft.
|
||||
- Test users for the next session (Acme MSP shared account, password `TestPass123!`): `engineer@` (junior) and `teamadmin@` (senior).
|
||||
- Files touched: `frontend/src/pages/AssistantChatPage.tsx`, `frontend/src/components/common/RichTextInput.tsx`, `frontend/src/components/flowpilot/EscalateModal.tsx`, `frontend/src/components/assistant/ConcludeSessionModal.tsx`, `frontend/src/components/dashboard/PendingEscalations.tsx`, `backend/app/core/config.py`, `.ai/CURRENT_TASK.md`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`, `.ai/DECISIONS.md`.
|
||||
|
||||
## 2026-04-28 02:00 EDT — Claude Code — Plan-locked wedge polish + structural task-lane fix
|
||||
|
||||
- Audited `docs/plans/2026-04-27-escalation-mode-wedge-design.md` against the branch and identified four locked-design / Codex-correction items not yet shipped: live AI assessment refresh, suggested-step chips, unread 6px dot on queue cards, and race-condition toast on claim conflict.
|
||||
- Shipped all four in commit `0f00ee5`:
|
||||
- **Live AI assessment refresh.** New `HandoffAssessmentReadyEvent` type and `onAssessmentReady` handler on `streamEscalations`. `AssistantChatPage` opens a scoped SSE subscription whenever it tracks a handoff missing its AI assessment; on a matching event it calls `handoffsApi.listHandoffs(sessionId)`, finds the handoff by id, and replaces both `magicHandoff` and `overlayHandoff` in place. Closes the loop on the async-assessment commit `e8ba74e` — without this, the senior had to manually reopen the Context overlay to see the AI assessment when the background task finished.
|
||||
- **Suggested-step chips.** New `chipsHidden` state in `AssistantChatPage`; chip strip renders above the composer when the magic-moment dissolves and `magicHandoff?.ai_assessment_data?.suggested_steps[]` is non-empty. Click prefills input and focuses; first send via `handleSend` flips `setChipsHidden(true)`; explicit X button also hides. Per-session lifetime by design (Codex correction locked).
|
||||
- **Unread 6px dot.** localStorage-backed seen set (`rf-escalation-seen`, capped at 200 entries) hydrated in `EscalationQueue`. Card render adds a 6px `bg-accent` dot when not in the seen set. `markSeen` called on Pick Up click AND on card body click (the "open" affordance). Hover deliberately doesn't clear (Codex correction). Pick Up button's onClick now calls `e.stopPropagation()` so it doesn't double-fire the card-open path.
|
||||
- **Race-condition toast on claim conflict.** New `HandoffAlreadyClaimedError` exception class in `handoff_manager.py`. `claim_session` now eager-loads `claimed_by_user` via `selectinload`, rejects different-user re-claims (idempotent for same-user double-clicks), and raises with `claimed_by_id` / `claimed_by_name` / `claimed_at`. The endpoint translates to HTTP 409 with structured `detail = {error: 'already_claimed', claimed_by_id, claimed_by_name, claimed_at}`. `AssistantChatPage.handleStartHere` extracts via `axios.isAxiosError`, formats `"Already claimed by {name} {time_ago}."` using the existing `timeAgo()` helper, drops `?pickup=true`, and dismisses the magic-moment so the loser flows back to the queue. Backed by 2 new unit tests (`test_claim_session_conflict_raises_already_claimed`, `test_claim_session_idempotent_for_same_user`).
|
||||
- User then reported that the task-lane stale-flash bug was still happening despite the prior fix `8914391` — "every time we work on something that's related to this, when we go back to test we create a new session and then the task lane shows unrelated session data." The previous fix only covered mount-time entry paths (prefill + pickup); any in-place transition still flashed.
|
||||
- Shipped structural fix in commit `665530f`. Introduced `taskLaneOwnerChatId` state that explicitly tags which chatId the in-memory `activeQuestions` / `activeActions` / `showTaskLane` values belong to. Set at every populate site (sendPrefill, selectChat, handleSend, handleTaskSubmit, handleResumeNew, refreshFacts, handleApplyFix). Cleared in `resetSessionDerivedState`. Persistence effect now writes `chatId: taskLaneOwnerChatId` (was `activeChatId` — that was the original write-side bug). Render gate `taskLaneIsForActiveChat = ownerChatId === activeChatId` ANDed into all three render conditions. The lane is structurally unable to display data tagged with a different chat. See DECISIONS entry. **Not yet verified in a real browser** — user is swapping computers and asked for the handoff first.
|
||||
- The two commits `0f00ee5` and `665530f` are **local-only** at session end. The user did not explicitly authorize a push, so per the handoff rule the branch was left unpushed. First action on resume is `git push`.
|
||||
- Tests: full handoff + escalation suite (`test_handoff_manager.py`, `test_session_handoffs_api.py`, `test_escalation_bus.py`, `test_flowpilot_analytics_escalations.py`) → 34 passed in 68.89s. Frontend `tsc -b` exit 0 after each commit.
|
||||
- Files touched: `frontend/src/api/aiSessions.ts`, `frontend/src/components/flowpilot/EscalationQueue.tsx`, `frontend/src/pages/AssistantChatPage.tsx`, `frontend/src/types/ai-session.ts`, `backend/app/api/endpoints/session_handoffs.py`, `backend/app/services/handoff_manager.py`, `backend/tests/test_handoff_manager.py`, `.ai/CURRENT_TASK.md`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`, `.ai/DECISIONS.md`.
|
||||
|
||||
## 2026-04-27 22:30 EDT — Claude Code — Escalation Mode: unify /escalate through HandoffManager
|
||||
|
||||
- User pushed back on the dual-path proposal: "why would we want two different escalation methods? Should the new one just be the way we escalate regardless if we're using a PSA or not using a PSA?" Right answer. Unified everything through `HandoffManager`.
|
||||
- Backend changes (commit `029680a`):
|
||||
- `HandoffCreateRequest` gains optional `target_user_id`; rejects self-targeting.
|
||||
- `HandoffManager.create_handoff` for intent='escalate' now does what the legacy `flowpilot_engine.escalate_session` used to: sets `session.escalation_reason` and `escalated_to_id`, builds the legacy AI-enhanced `escalation_package` via Sonnet (`_build_escalation_package_enhanced` lazy-imported with graceful fallback), and merges handoff metadata (`intent`, `handoff_id`, `snapshot`, `engineer_notes`) into it. Eager-loads `session.steps` + `session.user` via `selectinload` to dodge async lazy-load `MissingGreenlet` errors.
|
||||
- New `HandoffManager.finalize_escalation`: generates `SessionDocumentation`, pushes to PSA, and runs `notify()` (bell-icon AppNotification + Slack/Teams external channels) — all pre-commit so persistent state lands atomically with the handoff. Pulls engineer name via a separate User query rather than relying on `session.user` lazy access.
|
||||
- `dispatch_escalation_notifications` keeps only the fire-and-forget IO (bus publish + per-user emails) post-commit. Found and fixed an in-flight bug: had originally put `notify()` inside dispatch (post-commit), which left `Notification` rows uncommitted — moved into `finalize_escalation` (pre-commit).
|
||||
- `/handoff` endpoint passes `target_user_id` through and calls `finalize_escalation` pre-commit.
|
||||
- `/escalate` is now a thin shim: owner-only session lookup → `create_handoff(intent='escalate')` → `finalize_escalation` → commit → `dispatch_escalation_notifications` → return `SessionCloseResponse`. `flowpilot_engine.escalate_session` is no longer called by any endpoint.
|
||||
- `pickup_session` accepts both `requesting_escalation` (legacy in-flight) and `escalated` (new canonical) so existing queue items migrate seamlessly.
|
||||
- Escalation queue list (`/escalation-queue`) and sidebar count match either status.
|
||||
- Frontend: `useFlowPilotSession` optimistic update flips status to `escalated` instead of `requesting_escalation` so the page state matches the unified backend response.
|
||||
- Verified end-to-end live against the running dev stack: a single legacy `/escalate` call from `engineer@` produced status=`escalated`, a `SessionHandoff` row (`ea9b375a…`, intent='escalate'), a `SessionDocumentation`, a PSA push attempt (`no_psa` since no ticket), AND an `AppNotification` for `teamadmin@` with title "Session escalated by Jordan Tech" and link `/pilot/{session_id}?pickup=true`. Backend test suite: `1103 passed in 259.63s` with `-n auto`. Frontend `tsc -b` clean.
|
||||
- The legacy `SessionBriefing` render branch in `FlowPilotSessionPage.tsx` is now effectively dead for any new escalation (magic-moment takes over via the handoff record), but stays in place during the transition for legacy in-flight `requesting_escalation` sessions. Slated for cleanup after pilots run a couple of weeks on the unified path. `flowpilot_engine.escalate_session` is similarly orphaned and can be deleted at the same time.
|
||||
- Files touched: `backend/app/api/endpoints/ai_sessions.py`, `backend/app/api/endpoints/session_handoffs.py`, `backend/app/api/endpoints/sidebar.py`, `backend/app/schemas/session_handoff.py`, `backend/app/services/flowpilot_engine.py`, `backend/app/services/handoff_manager.py`, `frontend/src/hooks/useFlowPilotSession.ts`.
|
||||
|
||||
## 2026-04-27 21:50 EDT — Claude Code — Escalation Mode: bell-icon notification fix; push + draft PR
|
||||
|
||||
- User ran a live escalation test via the EscalateModal (legacy `/escalate` path) and reported that clicking the bell-icon notification "just clears the notification instead of taking me to the session". Diagnosed: navigation IS happening, but the notification link template was `/pilot/{session_id}` without `?pickup=true`, so the senior landed on `FlowPilotSessionPage` with no pickup mode. `loadSession` then hit `GET /ai-sessions/{id}` which 404'd because the senior wasn't owner / `escalated_to_id` / picked-up handler. The user perceived the resulting error state as the action having done nothing.
|
||||
- Two-part backend fix shipped in `641853a`. (1) `_build_notification_link` for `session.escalated` now ends with `?pickup=true` so notification clicks route through the senior-pickup flow (handoff-based or legacy SessionBriefing). (2) `GET /ai-sessions/{id}` access policy: any account member can now read a session's detail when status is `requesting_escalation` or `escalated`. Tenant boundary enforced by RLS — the owner-only guard was overly restrictive for explicitly-shared in-transit states. After-pickup access (handler / `escalated_to_id`) checks still apply for active/resolved sessions.
|
||||
- Verified end-to-end live: re-login as senior engineer (non-owner, non-target) and `GET /ai-sessions/{escalated-session-id}` returns 200 with full detail. Backend regression with broader subset (`test_escalation_bus`, `test_handoff_manager`, `test_session_handoffs_api`, `test_flowpilot_analytics_escalations`, `test_sessions`, `test_session_sharing`) → 94 passed in 43.26s.
|
||||
- Pushed `feat/escalation-metric-endpoint` to Gitea. Opened **draft PR #155** against `main` via Gitea API ([gitea.resolutionflow.com/chihlasm/resolutionflow/pulls/155](https://gitea.resolutionflow.com/chihlasm/resolutionflow/pulls/155)). Title prefixed `WIP:` so Gitea marks it `draft: true`. PR body links the design + test-plan artifacts and mirrors the test plan as a checklist with visual QA + e2e demo flow as the unchecked items.
|
||||
- Open question for next session: EscalateModal still calls the legacy `/escalate` endpoint, not the new `/handoff` path. The wedge demo flow (junior escalates → magic-moment renders) is cleaner if EscalateModal goes through `/handoff`. Legacy path does PSA documentation push that the handoff path doesn't, so a parallel path (legacy escalate also creates a handoff record) is probably the right call rather than full migration.
|
||||
- Files touched: `backend/app/api/endpoints/ai_sessions.py`, `backend/app/services/notification_service.py`, `.ai/CURRENT_TASK.md`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`.
|
||||
|
||||
## 2026-04-27 21:30 EDT — Claude Code — Escalation Mode: magic-moment handoff-context screen on pickup
|
||||
|
||||
- Continued the same session that shipped the live-arrival SSE subscription. Added the magic-moment screen on top.
|
||||
- New `frontend/src/components/flowpilot/HandoffContextScreen.tsx`: presentational 4-section view (header with problem summary + domain + step count + escalated-time + priority badge; "What's been tried" with engineer notes + step-count affordance; "AI assessment" with likely_cause / suggested_steps / confidence badge; "Start here" CTA). Confidence badge accepts both numeric (0..1) and string ("low"/"medium"/"high") shapes — backend emits the latter, the frontend type says `number`, runtime handles both. Renders an explicit "assessment unavailable — model didn't respond in time" branch when `ai_assessment_data` is null (the 5s timeout from `9bdd995` fired). `prefers-reduced-motion` swaps `animate-slide-up` for `animate-fade-in`. ARIA `role=dialog` + `aria-modal=true` + focus on primary CTA on mount + Esc dismiss when used as a re-openable overlay.
|
||||
- Integration in `frontend/src/pages/FlowPilotSessionPage.tsx`: on `/pilot/:id?pickup=true`, fetch the handoff list via `handoffsApi.listHandoffs` (account-scoped via RLS, no claim required) and find the latest unclaimed escalate handoff. If found, render the screen and skip `loadSession` (the senior would 404 pre-claim because they aren't yet `escalated_to_id`). "Start here" calls `handoffsApi.claimHandoff`, drops the `?pickup=true` query, and dismisses the screen — the existing `loadSession` effect then fires because the senior is now `escalated_to_id`. New "Context" toolbar button on active sessions (visible only when the senior arrived via the magic-moment flow this session — handoff lookup on demand) re-opens the screen as a dismissible overlay.
|
||||
- Verified end-to-end against the running dev stack: `listHandoffs` returns the unclaimed handoff with full payload (engineer_notes, snapshot keys); `claimHandoff` flips session status from `escalated` → `active` and sets `escalated_to_id`; subsequent `GET /ai-sessions/{id}` succeeds. `tsc -b` exit 0. No backend changes; backend tests still `32 passed in 18.91s`.
|
||||
- Deferred to TODOs in `CURRENT_TASK.md`: suggested-step chips below the chat input (Codex correction; threads through to `FlowPilotMessageBar`); `HandoffManager._generate_snapshot` expansion to include the recent diagnostic timeline pre-claim (today's snapshot is just `problem_summary, problem_domain, status, step_count, confidence_tier`); toolbar "Context" button visibility on revisited active sessions; owner-facing `/analytics/escalations` page; Playwright e2e for the GTM Loom demo path.
|
||||
- Branch state: 3 new commits (`b8627f4` SSE subscription, `f65b657` handoff doc bump, `8e9d22e` magic-moment screen). Branch is unpushed — next session pushes + opens draft PR.
|
||||
- Files touched this slice: `frontend/src/components/flowpilot/HandoffContextScreen.tsx` (new), `frontend/src/components/flowpilot/index.ts`, `frontend/src/pages/FlowPilotSessionPage.tsx`, `.ai/CURRENT_TASK.md`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`.
|
||||
|
||||
## 2026-04-27 21:00 EDT — Claude Code — Escalation Mode: frontend SSE subscription in EscalationQueue
|
||||
|
||||
- Picked up `feat/escalation-metric-endpoint` after the Codex test-stabilization pass. Confirmed green starting state: focused backend subset `32 passed in 18.78s` with `-n auto`.
|
||||
- Implemented the live-arrival frontend slice. Added `streamEscalations(handlers, signal)` to `frontend/src/api/aiSessions.ts` — fetch-based `ReadableStream` reader (native `EventSource` can't send auth headers) that parses SSE frames (event/data/comment lines), buffers partial frames across chunks, ignores `: keepalive` heartbeats, dispatches `ready` and `handoff_created` events. Added `HandoffCreatedEvent` and `EscalationStreamHandlers` types in `frontend/src/types/ai-session.ts` mirroring the backend bus payload.
|
||||
- Rewrote `frontend/src/components/flowpilot/EscalationQueue.tsx`. SSE subscription with `AbortController` + exponential-backoff reconnect (1s → 30s cap, attempt counter resets on `ready`). On `handoff_created` the component refetches the queue, diffs against the previous IDs via a `sessionsRef`, prepends new arrivals (newest-first) above established cards (oldest-first preserved). New IDs are tagged for 800ms so the locked 200ms slide-in animation plays before cleanup. Tab-title flash: captures `document.title` at mount, prefixes `(N)` while `document.hidden`, clears on `focus` / `visibilitychange`, restores on unmount. `prefers-reduced-motion: reduce` swaps `animate-slide-in-bottom` for `animate-fade-in`. ARIA: `role="region"` + `aria-live="polite"` on the list, `aria-label="N escalations awaiting pickup"` on the heading; Pick Up button bumped to `py-2.5` to clear the 44px touch floor.
|
||||
- Verified end-to-end against the running dev stack. `tsc -b` exit 0. Vite HMR'd the new component without errors. Raw SSE handshake against `/api/v1/ai-sessions/escalations/stream` returned 200 with `text/event-stream; charset=utf-8` plus the locked headers (`cache-control: no-cache`, `x-accel-buffering: no`). Subscriber received the `ready` frame on connect; after posting a handoff via the API, the subscriber received the `handoff_created` frame with the full payload — wire format matches the parser exactly. Backend regression: same focused subset still `32 passed in 18.91s`.
|
||||
- Not yet verified (would need a real browser session): the slide-in animation visually plays, the tab title actually updates, the reduced-motion media-query path, AbortController cancellation on unmount, backoff after a real network blip. Wire contract is confirmed; these are visual/timing-dependent and follow from correct parser + state machine.
|
||||
- Smoke-test artifact: a single test handoff (`0f6149db…` on session `50ea20d4…`) is sitting in the engineer's queue from the verification step. Harmless; useful as visual demo data.
|
||||
- Left for next session: the magic-moment handoff-context screen — 4 sections (problem summary / what's been tried / AI assessment / Start here CTA), loads on Pick Up, dissolves into the regular FlowPilot session view. Must render gracefully when `ai_assessment` is `None` (per the 5s assessment timeout from Codex's earlier fix).
|
||||
- Files touched: `frontend/src/api/aiSessions.ts`, `frontend/src/types/ai-session.ts`, `frontend/src/components/flowpilot/EscalationQueue.tsx`, `.ai/CURRENT_TASK.md`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`.
|
||||
|
||||
## 2026-04-27 EDT — Claude Code — Escalation Mode wedge: design through SSE backend (8 commits)
|
||||
|
||||
- One long session that produced the entire planning artifact stack and most of the backend for the Escalation Mode wedge. Output of `/office-hours` (8 founder-signal session, top-tier YC archetype indicators), `/plan-eng-review` (scope reduced from "2-3 weeks greenfield" to "~6-9 days integration + metric + polish" once the existing handoff_manager surface was inventoried), `/plan-design-review` (6/10 → 9/10 with magic-moment screen, hero metric placement, and real-time arrival visual locked), and `/codex review` (12 findings, 6 applied — two-metric framing, notification routing, claim auth gate moved in-scope, unread-state fix, "Start here" CTA reframe, per-channel delivery model; 5 rejected including the full-scope reduction Codex pushed for).
|
||||
- Branched `feat/escalation-metric-endpoint` off `main` @ `c0ed6d9`. Stack at session end: `d51e95c` plan + test-plan artifacts; `52f6d03` `GET /analytics/flowpilot/escalations` endpoint with 9 tests including multi-tenant isolation; `7a5b853` claim-endpoint role gate; `07d0db9` email dispatch on escalate with graceful-degradation regression; `9f0bfd4` `EscalationMetricCard` mounted above the queue list; `a283d0d` mid-flight `.ai/` refresh; `87bd0b7` WIP commit for SSE pub/sub bus + endpoint + 7 bus unit tests + 1 dispatcher integration test + 2 endpoint tests; `ba46fc5` paused-for-Codex-review handoff. Codex picked up from `ba46fc5` and added `bc15952` / `fff8338` / `9bdd995` (test stabilization + assessment latency bound).
|
||||
- Pause was forced by a runaway local test loop: multiple stale `pytest` processes were left inside `resolutionflow_backend` after several aborted runs and contended on the same Postgres test schema. Codex diagnosed and fixed (see entry above).
|
||||
- Frontend: thin slice — added `getEscalationMetrics` to `flowpilotAnalyticsApi`, the `EscalationMetricCard` component (loading / error / zero-data states + avg + median + conversion-rate + the inline two-metric disclaimer), and mounted it above `EscalationQueue`. `tsc -b` clean.
|
||||
- Plan-stage UI decisions locked into the design doc and the codebase: dedicated 4-section magic-moment screen on Pick Up that dissolves into FlowPilot; queue stat-card + dedicated owner analytics page for the hero metric (in two places, not one); 200ms slide-in + tab-title flash on real-time arrival, no sound, respects `prefers-reduced-motion`; unread dot clears on open/claim/dismiss, NOT on hover (Codex correction). Claim role gate moved in-scope per Codex (not deferred to TODO).
|
||||
- Two TODOs added: peer-tech escalation (deferred to v2 once a pilot asks); mobile/responsive design (also v2; pre-PMF wedge demo targets desktop). Claim role gate's TODO entry was struck through in the same session because it shipped in `7a5b853`.
|
||||
- Plan and test-plan artifacts copied into `docs/plans/` under the `YYYY-MM-DD-name-design.md` / `-test-plan.md` convention so they live alongside the existing project plans, not just in `~/.gstack/projects/`.
|
||||
- Left for next session: frontend SSE subscription in `EscalationQueue.tsx` (fetch-based ReadableStream — native EventSource can't send auth headers; match `streamDocumentation` in `frontend/src/api/aiSessions.ts`), then the magic-moment handoff-context screen, then push + draft PR. Default Claude Code model is being switched from Opus 4.7 1M-context to Opus 4.7 (200k) for the next session — the resume docs are sized to be self-sufficient under the smaller window.
|
||||
- Files touched (committed): `docs/plans/2026-04-27-escalation-mode-wedge-design.md`, `docs/plans/2026-04-27-escalation-mode-wedge-test-plan.md`, `backend/app/api/endpoints/flowpilot_analytics.py`, `backend/app/schemas/flowpilot_analytics.py`, `backend/app/api/endpoints/session_handoffs.py`, `backend/app/services/handoff_manager.py`, `backend/app/core/escalation_bus.py` (new), `backend/tests/test_flowpilot_analytics_escalations.py` (new), `backend/tests/test_escalation_bus.py` (new), `backend/tests/test_handoff_manager.py`, `backend/tests/test_session_handoffs_api.py`, `frontend/src/types/flowpilot-analytics.ts`, `frontend/src/api/flowpilotAnalytics.ts`, `frontend/src/components/flowpilot/EscalationMetricCard.tsx` (new), `frontend/src/components/flowpilot/index.ts`, `frontend/src/pages/EscalationQueuePage.tsx`, `.ai/CURRENT_TASK.md`, `.ai/HANDOFF.md`, `.ai/TODO.md`.
|
||||
|
||||
## 2026-04-27 19:50 EDT — Codex — Stabilize Escalation Mode SSE backend tests
|
||||
|
||||
- Diagnosed slow backend tests on `feat/escalation-metric-endpoint`. Multiple stale pytest processes were still alive inside `resolutionflow_backend` and held `resolutionflow_test` transactions open, blocking later per-test schema resets on `DROP SCHEMA public CASCADE`.
|
||||
- Reproduced a deterministic hang in `test_escalations_stream_returns_sse_content_type`: HTTPX `ASGITransport` buffers the full response body before returning, so an infinite SSE response never yielded the initial chunk and kept the auth DB dependency transaction open.
|
||||
- Fixed `stream_escalations` to release auth dependencies before the long-lived stream body with `Depends(..., scope="function")`.
|
||||
- Reworked the SSE handshake test to call `stream_escalations()` directly and consume one generator yield, then close it; kept viewer role-gate coverage through the API client.
|
||||
- Stubbed `_generate_ai_assessment()` in handoff manager/API tests so escalation handoff tests no longer wait on the real AI path.
|
||||
- Normalized account IDs inside `EscalationBus` so string UUIDs and `UUID` objects hit the same subscriber bucket; added a regression test.
|
||||
- Verified focused backend subset: serial `31 passed in 46.95s`; xdist `31 passed in 17.80s`. Confirmed no lingering pytest processes or test DB sessions afterward.
|
||||
- Follow-up in the same session: fixed the product latency risk by adding `ESCALATION_AI_ASSESSMENT_TIMEOUT_SECONDS` (default 5s) around escalation AI assessment generation. If the optional assessment times out, handoff creation continues with no assessment. Added regression coverage; focused xdist subset now `32 passed in 17.77s`.
|
||||
- Left for next session: continue frontend SSE subscription in `EscalationQueue.tsx`, then the magic-moment handoff-context screen.
|
||||
- Files touched: `backend/app/api/endpoints/session_handoffs.py`, `backend/app/core/config.py`, `backend/app/core/escalation_bus.py`, `backend/app/services/handoff_manager.py`, `backend/tests/test_escalation_bus.py`, `backend/tests/test_handoff_manager.py`, `backend/tests/test_session_handoffs_api.py`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`, `.ai/TODO.md`.
|
||||
|
||||
## 2026-04-26 03:50 EDT — Claude Code — Ship AssistantChatPage prefill `currentChatRef` fix; close out PR #150
|
||||
|
||||
- User reported a troubleshooting-session bug: after answering a subset of task-lane questions and clicking *Send N of M Responses*, no AI response appeared. Traced to `AssistantChatPage`: the dashboard prefill effect set `activeChatId` after creating a new chat session but never updated `currentChatRef.current`. The `currentChatRef.current !== sentForChatId` guard in `handleSend` and `handleTaskSubmit` then bailed silently on every later request and discarded the AI's reply. The user message was already pushed to the chat before the await, so the user saw their answers but nothing else.
|
||||
|
||||
@@ -15,3 +15,9 @@
|
||||
- [ ] **Per-test transactional rollback in `test_db` fixture.** Bigger engineering than xdist (which we already shipped). Instead of `DROP SCHEMA public CASCADE` per test, wrap each test in a savepoint and rollback at teardown. ~30-40% additional speedup on top of xdist for test-DB-heavy tests. Real refactor; only worth it if the suite gets significantly larger or runs more frequently.
|
||||
- [ ] **Consider `pytest-testmon` for PR-time test selection.** Tracks which tests touched which source files and only re-runs affected ones. Best for small PRs touching ~few files. Adds cache-invalidation complexity; only worth it if the suite stays painfully long even after xdist.
|
||||
- [ ] **AssistantChatPage `currentChatRef` guard is a silent return** — `handleSend`, `handleTaskSubmit`, `selectChat`, `refreshFacts`, `refreshActiveFix`, and `refreshPreview` all bail with `if (currentChatRef.current !== sentForChatId) return` when stale. This is by design for chat switching, but it also silently masked the prefill-ref bug fixed in PR #153 — the user just saw "no AI response" with no log, no toast, no Sentry event. Either (a) log a `console.warn`/Sentry breadcrumb on the mismatch path so future drift is visible, or (b) split "expected stale" (chat switch) from "unexpected stale" (ref never updated) so only the latter alerts. Pair with an audit of every `currentChatRef.current = ...` assignment vs every `setActiveChatId(...)` call to make sure they're paired everywhere.
|
||||
|
||||
- [ ] **Allow peer-tech to escalate a colleague's session.** Today `POST /ai-sessions/{session_id}/handoff` in [endpoints/session_handoffs.py:48](backend/app/api/endpoints/session_handoffs.py#L48) filters by `AISession.user_id == current_user.id`, so only the session owner can escalate. Real MSP shops have peer hand-offs: Junior A is on lunch, Junior B sees the session is stuck and should be able to escalate it. Auth tweak: switch from session-owner check to `require_engineer_or_admin` + same-account scope. Add a `handed_off_by` audit column (already exists on `SessionHandoff`) so the original-owner-vs-actual-escalator distinction is preserved. Surfaced from /plan-eng-review on the Escalation-Mode wedge plan; v1 wedge demo doesn't need this (solo-founder pilot), but capture for v2 once 3+ pilots are live and a peer-claim need surfaces.
|
||||
|
||||
- [ ] **Mobile/responsive design for EscalationQueue + handoff-context screen.** Pre-PMF wedge demo targets desktop only — MSP techs work on laptops/desktops in shop environments. Once 3+ paying customers exist and a tech requests mobile (likely on-call use case), spec the responsive behavior: stacked card layout below `sm:` breakpoint, full-bleed handoff-context overlay on mobile, swipe-to-claim gesture instead of Pick Up button. Surfaced from /plan-design-review on the Escalation-Mode wedge plan.
|
||||
|
||||
- [ ] **(MOVED IN-SCOPE for Escalation Mode v1, 2026-04-27)** ~~Add role gate to handoff claim endpoint.~~ Codex review correctly flagged this as wedge-relevant (the race-condition story depends on auth gating). Now part of the Escalation Mode v1 build, not a deferred TODO.
|
||||
|
||||
@@ -15,7 +15,7 @@ from datetime import datetime
|
||||
from typing import Annotated, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request, status
|
||||
from sqlalchemy import or_, select, func, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -452,6 +452,13 @@ async def resolve_session(
|
||||
|
||||
|
||||
# ── Escalate ──
|
||||
#
|
||||
# Thin shim over HandoffManager. The legacy `flowpilot_engine.escalate_session`
|
||||
# is no longer the source of truth — every escalation now creates a
|
||||
# SessionHandoff row, fans out via the SSE bus, dispatches AppNotification +
|
||||
# external channels via notify(), and emails per-user. EscalateModal and the
|
||||
# /handoff endpoint both funnel through here / through HandoffManager so the
|
||||
# senior-pickup magic-moment screen works regardless of entry point.
|
||||
|
||||
@router.post("/{session_id}/escalate", response_model=SessionCloseResponse)
|
||||
@limiter.limit("15/minute")
|
||||
@@ -459,25 +466,62 @@ async def escalate_session(
|
||||
request: Request,
|
||||
session_id: UUID,
|
||||
data: EscalateSessionRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
_: None = Depends(require_engineer_or_admin),
|
||||
):
|
||||
"""Escalate a FlowPilot session to another engineer."""
|
||||
"""Escalate a FlowPilot session — unified through HandoffManager."""
|
||||
from app.services.handoff_manager import HandoffManager, enrich_escalation_async
|
||||
|
||||
# Owner-only — matches the original constraint on flowpilot_engine.escalate_session.
|
||||
session_result = await db.execute(
|
||||
select(AISession).where(
|
||||
AISession.id == session_id,
|
||||
AISession.user_id == current_user.id,
|
||||
)
|
||||
)
|
||||
session = session_result.scalar_one_or_none()
|
||||
if not session:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Session not found"
|
||||
)
|
||||
|
||||
manager = HandoffManager(db)
|
||||
try:
|
||||
result = await flowpilot_engine.escalate_session(
|
||||
handoff = await manager.create_handoff(
|
||||
session_id=session_id,
|
||||
request=data,
|
||||
intent="escalate",
|
||||
engineer_notes=data.escalation_reason,
|
||||
user_id=current_user.id,
|
||||
db=db,
|
||||
priority="normal",
|
||||
target_user_id=data.escalated_to_id,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
except PermissionError as e:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))
|
||||
|
||||
documentation, psa_result = await manager.finalize_escalation(
|
||||
handoff, session, current_user.id
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
return result
|
||||
|
||||
await manager.dispatch_escalation_notifications(handoff)
|
||||
|
||||
# AI enrichment (Sonnet assessment + enhanced escalation_package) runs
|
||||
# in the background so the escalating engineer doesn't wait on
|
||||
# 15-25s of model latency. Result lands on the handoff row when ready;
|
||||
# the senior's magic-moment screen reads it at pickup time.
|
||||
background_tasks.add_task(
|
||||
enrich_escalation_async, handoff.id, current_user.id
|
||||
)
|
||||
|
||||
return SessionCloseResponse(
|
||||
session_id=session.id,
|
||||
status=session.status,
|
||||
documentation=documentation,
|
||||
**psa_result,
|
||||
)
|
||||
|
||||
|
||||
# ── Pause ──
|
||||
@@ -644,7 +688,8 @@ async def get_escalation_queue(
|
||||
select(AISession)
|
||||
.where(
|
||||
scope_filter,
|
||||
AISession.status == "requesting_escalation",
|
||||
AISession.status.in_(("requesting_escalation", "escalated")),
|
||||
AISession.user_id != current_user.id,
|
||||
)
|
||||
.order_by(AISession.created_at.desc())
|
||||
)
|
||||
@@ -838,13 +883,25 @@ async def list_sessions(
|
||||
date_to: Optional[datetime] = Query(None),
|
||||
q: Optional[str] = Query(None, min_length=2, max_length=200),
|
||||
):
|
||||
"""List the current user's AI sessions (owned or picked up)."""
|
||||
"""List the current user's AI sessions (owned or picked up).
|
||||
|
||||
"Picked up" includes both the legacy escalation_package.picked_up_by
|
||||
marker (set by flowpilot_engine.pickup_session) AND the new
|
||||
escalated_to_id field (set by HandoffManager.claim_session for the
|
||||
unified handoff/escalate path). Without the escalated_to_id branch
|
||||
the senior tech wouldn't see a session they just claimed in their
|
||||
chat sidebar — the picked-up session lands as the active chat with
|
||||
no entry in the list, which is what the user reported as "4 versions
|
||||
of the session" (their unrelated owned sessions show up while the
|
||||
claimed one is invisible).
|
||||
"""
|
||||
user_id_str = str(current_user.id)
|
||||
query = (
|
||||
select(AISession)
|
||||
.where(
|
||||
or_(
|
||||
AISession.user_id == current_user.id,
|
||||
AISession.escalated_to_id == current_user.id,
|
||||
AISession.escalation_package["picked_up_by"].as_string() == user_id_str,
|
||||
)
|
||||
)
|
||||
@@ -901,10 +958,21 @@ async def get_session(
|
||||
if not session:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found")
|
||||
|
||||
# Allow access if user is owner, escalation target, or picked-up handler
|
||||
# Allow access if user is owner, escalation target, or picked-up handler.
|
||||
# Sessions in transit (requesting_escalation / escalated) are also
|
||||
# readable by any account member — the whole point of escalation is that
|
||||
# other techs can see the context before claiming. Tenant boundary is
|
||||
# enforced by RLS on the underlying query, so account-scope is the right
|
||||
# ceiling for in-transit reads.
|
||||
pkg = session.escalation_package or {}
|
||||
is_handler = pkg.get("picked_up_by") == str(current_user.id)
|
||||
if session.user_id != current_user.id and session.escalated_to_id != current_user.id and not is_handler:
|
||||
is_in_transit = session.status in ("requesting_escalation", "escalated")
|
||||
if (
|
||||
session.user_id != current_user.id
|
||||
and session.escalated_to_id != current_user.id
|
||||
and not is_handler
|
||||
and not is_in_transit
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found")
|
||||
|
||||
return _build_session_detail(session)
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
Endpoints:
|
||||
GET /analytics/flowpilot?period=30d — Main dashboard data
|
||||
GET /analytics/flowpilot/knowledge-gaps — Knowledge gap report
|
||||
GET /analytics/flowpilot/escalations?period=30d — Escalation handoff metrics
|
||||
"""
|
||||
import logging
|
||||
import statistics
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Annotated, Optional
|
||||
|
||||
@@ -13,10 +15,17 @@ from sqlalchemy import select, func, case, cast, Date, extract
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.rate_limit import limiter
|
||||
from app.api.deps import get_current_active_user, get_db, require_team_admin
|
||||
from app.api.deps import (
|
||||
get_current_active_user,
|
||||
get_db,
|
||||
require_engineer_or_admin,
|
||||
require_team_admin,
|
||||
)
|
||||
from app.models.user import User
|
||||
from app.models.tree import Tree
|
||||
from app.models.ai_session import AISession
|
||||
from app.models.ai_session_step import AISessionStep
|
||||
from app.models.session_handoff import SessionHandoff
|
||||
from app.models.flow_proposal import FlowProposal
|
||||
from app.models.psa_activity_log import PsaActivityLog
|
||||
from app.models.psa_post_log import PsaPostLog
|
||||
@@ -36,6 +45,7 @@ from app.schemas.flowpilot_analytics import (
|
||||
EnhancedPsaMetrics,
|
||||
PsaFunnel,
|
||||
PsaDailyTrend,
|
||||
EscalationMetrics,
|
||||
)
|
||||
from app.services.knowledge_gap_service import get_knowledge_gaps, KnowledgeGapReport
|
||||
|
||||
@@ -727,3 +737,104 @@ async def get_enhanced_psa_metrics(
|
||||
push_funnel=push_funnel,
|
||||
daily_trend=daily_trend,
|
||||
)
|
||||
|
||||
|
||||
# ─── Escalation Mode metrics (wedge stat for /escalations queue + analytics page)
|
||||
#
|
||||
# Pulls all (handoff.claimed_at, first_step_after_claim.created_at) pairs in the
|
||||
# window and aggregates avg/median/p95 of the delta in Python. Pilot scale
|
||||
# (~1k rows max per account per month) makes this cheaper and clearer than
|
||||
# Postgres percentile_cont gymnastics.
|
||||
#
|
||||
# IMPORTANT: this is the in-product metric only. The "minutes recovered"
|
||||
# sales claim requires manual baseline measurement (see The Assignment in
|
||||
# docs/plans/2026-04-27-escalation-mode-wedge-design.md).
|
||||
|
||||
|
||||
@router.get("/escalations", response_model=EscalationMetrics)
|
||||
@limiter.limit("30/minute")
|
||||
async def get_escalation_metrics(
|
||||
request: Request,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
_: None = Depends(require_engineer_or_admin),
|
||||
period: str = Query("30d", pattern="^(7d|30d|90d)$"),
|
||||
) -> EscalationMetrics:
|
||||
"""Time-to-first-action after escalation claim, account-scoped.
|
||||
|
||||
Returns:
|
||||
n_handoffs_claimed: handoffs in window that were claimed by a senior.
|
||||
n_handoffs_with_action: subset where the senior took at least one
|
||||
action (an ai_session_step row created after claimed_at).
|
||||
avg/median/p95_seconds_to_first_action: aggregates of
|
||||
(first_step.created_at - claimed_at) in seconds.
|
||||
|
||||
Excludes handoffs where claimed_at IS NULL (never claimed) and handoffs
|
||||
where no ai_session_step was created after the claim. Both are
|
||||
counted — n_handoffs_claimed includes "no action yet" handoffs so the
|
||||
conversion rate is visible.
|
||||
"""
|
||||
if not current_user.account_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="No account"
|
||||
)
|
||||
|
||||
account_id = current_user.account_id
|
||||
period_start = _get_period_start(period)
|
||||
|
||||
# First-action timestamp per handoff via correlated scalar subquery.
|
||||
first_action_subq = (
|
||||
select(func.min(AISessionStep.created_at))
|
||||
.where(
|
||||
AISessionStep.session_id == SessionHandoff.session_id,
|
||||
AISessionStep.created_at > SessionHandoff.claimed_at,
|
||||
)
|
||||
.correlate(SessionHandoff)
|
||||
.scalar_subquery()
|
||||
)
|
||||
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(
|
||||
SessionHandoff.claimed_at,
|
||||
first_action_subq.label("first_action_at"),
|
||||
).where(
|
||||
SessionHandoff.account_id == account_id,
|
||||
SessionHandoff.claimed_at.isnot(None),
|
||||
SessionHandoff.claimed_at >= period_start,
|
||||
)
|
||||
)
|
||||
).all()
|
||||
|
||||
n_handoffs_claimed = len(rows)
|
||||
deltas: list[float] = []
|
||||
for claimed_at, first_action_at in rows:
|
||||
if first_action_at is None:
|
||||
continue
|
||||
delta_s = (first_action_at - claimed_at).total_seconds()
|
||||
# Floor at zero — clock drift between rows could in theory yield a
|
||||
# tiny negative if a step's created_at races claimed_at. Surface as
|
||||
# 0s rather than absurd negative deltas.
|
||||
if delta_s < 0:
|
||||
delta_s = 0.0
|
||||
deltas.append(delta_s)
|
||||
|
||||
n_handoffs_with_action = len(deltas)
|
||||
if n_handoffs_with_action == 0:
|
||||
return EscalationMetrics(
|
||||
period=period,
|
||||
n_handoffs_claimed=n_handoffs_claimed,
|
||||
n_handoffs_with_action=0,
|
||||
)
|
||||
|
||||
sorted_deltas = sorted(deltas)
|
||||
p95_idx = max(0, int(round(0.95 * (n_handoffs_with_action - 1))))
|
||||
|
||||
return EscalationMetrics(
|
||||
period=period,
|
||||
n_handoffs_claimed=n_handoffs_claimed,
|
||||
n_handoffs_with_action=n_handoffs_with_action,
|
||||
avg_seconds_to_first_action=round(statistics.fmean(deltas), 2),
|
||||
median_seconds_to_first_action=round(statistics.median(deltas), 2),
|
||||
p95_seconds_to_first_action=round(sorted_deltas[p95_idx], 2),
|
||||
)
|
||||
|
||||
@@ -1,23 +1,28 @@
|
||||
"""Handoff endpoints — unified park/escalate.
|
||||
|
||||
POST /ai-sessions/{id}/handoff — Create handoff
|
||||
POST /ai-sessions/{id}/handoff — Create handoff
|
||||
GET /ai-sessions/{id}/handoffs — Handoff history
|
||||
POST /ai-sessions/{id}/handoffs/{hid}/claim — Claim session
|
||||
GET /ai-sessions/queue — Team queue
|
||||
GET /ai-sessions/queue — Team queue
|
||||
GET /ai-sessions/escalations/stream — SSE: live escalation arrivals
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Annotated
|
||||
from typing import Annotated, AsyncGenerator
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_active_user, get_db
|
||||
from app.api.deps import get_current_active_user, get_db, require_engineer_or_admin
|
||||
from app.core.escalation_bus import bus as escalation_bus
|
||||
from app.models.user import User
|
||||
from app.models.ai_session import AISession
|
||||
from app.models.session_handoff import SessionHandoff
|
||||
from app.services.handoff_manager import HandoffManager
|
||||
from app.services.handoff_manager import HandoffAlreadyClaimedError, HandoffManager
|
||||
from app.schemas.session_handoff import (
|
||||
HandoffCreateRequest,
|
||||
HandoffResponse,
|
||||
@@ -36,6 +41,7 @@ router = APIRouter(prefix="/ai-sessions/{session_id}", tags=["session-handoffs"]
|
||||
async def create_handoff(
|
||||
session_id: UUID,
|
||||
body: HandoffCreateRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
) -> HandoffResponse:
|
||||
@@ -58,12 +64,35 @@ async def create_handoff(
|
||||
engineer_notes=body.engineer_notes,
|
||||
user_id=current_user.id,
|
||||
priority=body.priority,
|
||||
target_user_id=body.target_user_id,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
# For escalate: generate documentation + push to PSA before commit so
|
||||
# the handoff and the PSA-state changes land atomically.
|
||||
if handoff.intent == "escalate":
|
||||
await manager.finalize_escalation(handoff, session, current_user.id)
|
||||
|
||||
await db.commit()
|
||||
return HandoffResponse.model_validate(handoff)
|
||||
|
||||
# Best-effort notification dispatch AFTER commit so we never email about
|
||||
# a rolled-back handoff. Failures are swallowed inside the manager —
|
||||
# handoff creation is authoritative; notifications are advisory.
|
||||
if handoff.intent == "escalate":
|
||||
from app.services.handoff_manager import enrich_escalation_async
|
||||
|
||||
await manager.dispatch_escalation_notifications(handoff)
|
||||
# AI enrichment (Sonnet assessment + enhanced escalation_package)
|
||||
# runs in the background after the response is sent so the
|
||||
# escalating engineer doesn't wait on 15-25s of model latency.
|
||||
background_tasks.add_task(
|
||||
enrich_escalation_async, handoff.id, current_user.id
|
||||
)
|
||||
|
||||
return HandoffResponse.model_validate(handoff).model_copy(
|
||||
update={"handed_off_by_name": current_user.name}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/handoffs", response_model=list[HandoffResponse])
|
||||
@@ -86,21 +115,49 @@ async def list_handoffs(
|
||||
async def claim_handoff(
|
||||
session_id: UUID,
|
||||
handoff_id: UUID,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
current_user: Annotated[User, Depends(require_engineer_or_admin)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
) -> HandoffResponse:
|
||||
"""Claim a handed-off session."""
|
||||
"""Claim a handed-off session.
|
||||
|
||||
Role-gated to engineer/admin/owner — viewers cannot claim. The race-condition
|
||||
story (two seniors clicking Pick Up simultaneously) depends on auth gating
|
||||
for audit integrity. Codex review flagged this as wedge-relevant; locked
|
||||
in-scope for Escalation Mode v1.
|
||||
"""
|
||||
manager = HandoffManager(db)
|
||||
try:
|
||||
handoff = await manager.claim_session(
|
||||
handoff_id=handoff_id,
|
||||
claiming_user_id=current_user.id,
|
||||
)
|
||||
except HandoffAlreadyClaimedError as e:
|
||||
# Loser of the race — the API surfaces structured detail so the
|
||||
# client can render "Already claimed by {name} {time_ago}" without
|
||||
# a follow-up fetch.
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail={
|
||||
"error": "already_claimed",
|
||||
"claimed_by_id": str(e.claimed_by_id),
|
||||
"claimed_by_name": e.claimed_by_name,
|
||||
"claimed_at": e.claimed_at.isoformat(),
|
||||
},
|
||||
)
|
||||
except PermissionError as e:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))
|
||||
except ValueError as e:
|
||||
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")
|
||||
@@ -114,3 +171,83 @@ async def get_queue(
|
||||
team_id=current_user.team_id,
|
||||
account_id=current_user.account_id,
|
||||
)
|
||||
|
||||
|
||||
# ─── Live escalation arrivals (SSE) ──────────────────────────────────────────
|
||||
#
|
||||
# Streams `handoff_created` events to subscribers in the same account_id as
|
||||
# the new handoff. Connected EscalationQueue instances prepend the new card
|
||||
# with the locked 200ms slide-in. Account-scoped: cross-tenant leakage is
|
||||
# prevented at the bus.publish boundary (only handoff.account_id subscribers
|
||||
# are notified) and re-enforced here by binding the subscription to
|
||||
# current_user.account_id.
|
||||
#
|
||||
# Heartbeat: a `: keepalive\n\n` SSE comment every 25s keeps the connection
|
||||
# alive through Railway / nginx default 60s idle timeouts. Reconnect policy
|
||||
# is on the client (browser EventSource auto-reconnects; our fetch-based
|
||||
# reader retries with backoff).
|
||||
|
||||
|
||||
_HEARTBEAT_INTERVAL_S = 25
|
||||
_QUEUE_GET_TIMEOUT_S = 25 # < heartbeat so heartbeat fires reliably
|
||||
|
||||
|
||||
@queue_router.get("/escalations/stream")
|
||||
async def stream_escalations(
|
||||
request: Request,
|
||||
current_user: Annotated[
|
||||
User,
|
||||
Depends(require_engineer_or_admin, scope="function"),
|
||||
],
|
||||
):
|
||||
"""SSE stream of new escalation arrivals for the current user's account.
|
||||
|
||||
Role-gated to engineer/admin/owner so viewers can't subscribe (matches
|
||||
the queue + claim role surface). One open connection per browser tab is
|
||||
expected; the bus handles fan-out.
|
||||
"""
|
||||
if not current_user.account_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="No account"
|
||||
)
|
||||
|
||||
account_id = current_user.account_id
|
||||
|
||||
async def event_generator() -> AsyncGenerator[str, None]:
|
||||
queue = await escalation_bus.subscribe(account_id)
|
||||
try:
|
||||
# Initial hello so the client knows the stream is live.
|
||||
yield (
|
||||
"event: ready\n"
|
||||
f"data: {json.dumps({'account_id': str(account_id)})}\n\n"
|
||||
)
|
||||
|
||||
while True:
|
||||
if await request.is_disconnected():
|
||||
break
|
||||
try:
|
||||
event = await asyncio.wait_for(
|
||||
queue.get(), timeout=_QUEUE_GET_TIMEOUT_S
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
# Heartbeat keeps the connection alive through proxies.
|
||||
yield ": keepalive\n\n"
|
||||
continue
|
||||
|
||||
event_type = event.get("type", "message")
|
||||
yield (
|
||||
f"event: {event_type}\n"
|
||||
f"data: {json.dumps(event)}\n\n"
|
||||
)
|
||||
finally:
|
||||
await escalation_bus.unsubscribe(account_id, queue)
|
||||
|
||||
return StreamingResponse(
|
||||
event_generator(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -161,7 +161,7 @@ async def get_sidebar_stats(
|
||||
select(func.count()).where(
|
||||
and_(
|
||||
esc_scope,
|
||||
AISession.status == "requesting_escalation",
|
||||
AISession.status.in_(("requesting_escalation", "escalated")),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -111,6 +111,16 @@ class Settings(BaseSettings):
|
||||
GOOGLE_AI_API_KEY: Optional[str] = None
|
||||
AI_MODEL_GEMINI: str = "gemini-2.5-flash"
|
||||
AI_MODEL_ANTHROPIC: str = "claude-sonnet-4-6"
|
||||
# Bound for the diagnostic assessment Sonnet call. Generation runs in a
|
||||
# FastAPI BackgroundTask (commit e8ba74e), so this no longer blocks the
|
||||
# senior's click — only how long we wait before publishing
|
||||
# `handoff_assessment_ready` with has_assessment=false. 15s was hitting
|
||||
# tail latency on Sonnet (timeout 03:57:35 in field testing 2026-04-29),
|
||||
# leaving the magic-moment placeholder permanent. 45s is the right
|
||||
# ceiling: well above Sonnet p99 for a 500-token output, far enough
|
||||
# below "the senior gives up watching" that we still surface SOMETHING
|
||||
# on persistent slowness.
|
||||
ESCALATION_AI_ASSESSMENT_TIMEOUT_SECONDS: int = 45
|
||||
|
||||
# Model tier routing — maps action types to model tiers
|
||||
AI_MODEL_TIERS: dict[str, str] = {
|
||||
|
||||
105
backend/app/core/escalation_bus.py
Normal file
105
backend/app/core/escalation_bus.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""In-memory pub/sub bus for live escalation events.
|
||||
|
||||
Single-process, non-durable. When a handoff fires, every connected SSE
|
||||
subscriber for the same `account_id` receives the event. Subscribers come
|
||||
and go as senior techs open and close the EscalationQueue page.
|
||||
|
||||
Pre-PMF scale (3 pilots × 5-20 techs/MSP = ~15-60 concurrent subscribers
|
||||
total, single Railway replica) makes in-memory the right call. When the
|
||||
deployment scales horizontally, swap this for Redis pub/sub or similar —
|
||||
the public surface (`publish` / `subscribe`) is intentionally narrow so
|
||||
the swap is local.
|
||||
|
||||
Events are JSON-serializable dicts. `publish()` is non-blocking (drops the
|
||||
event if a subscriber's queue is full rather than back-pressuring the
|
||||
caller). `subscribe()` MUST be paired with `unsubscribe()` in a finally
|
||||
block, or you leak queues.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Bound how many unconsumed events can sit in a subscriber's queue before
|
||||
# we start dropping. 64 is generous for the queue-page use case; if a
|
||||
# subscriber is that far behind, they're probably gone or stuck.
|
||||
_QUEUE_MAXSIZE = 64
|
||||
|
||||
|
||||
class EscalationBus:
|
||||
"""Account-scoped pub/sub for escalation arrival events."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._subscribers: dict[UUID, set[asyncio.Queue[dict[str, Any]]]] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
@staticmethod
|
||||
def _normalize_account_id(account_id: UUID | str) -> UUID:
|
||||
return account_id if isinstance(account_id, UUID) else UUID(str(account_id))
|
||||
|
||||
async def subscribe(self, account_id: UUID | str) -> asyncio.Queue[dict[str, Any]]:
|
||||
"""Register a new subscriber queue for an account.
|
||||
|
||||
Caller must invoke `unsubscribe(account_id, queue)` when the
|
||||
consumer disconnects.
|
||||
"""
|
||||
normalized_account_id = self._normalize_account_id(account_id)
|
||||
queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(
|
||||
maxsize=_QUEUE_MAXSIZE
|
||||
)
|
||||
async with self._lock:
|
||||
self._subscribers.setdefault(normalized_account_id, set()).add(queue)
|
||||
return queue
|
||||
|
||||
async def unsubscribe(
|
||||
self, account_id: UUID | str, queue: asyncio.Queue[dict[str, Any]]
|
||||
) -> None:
|
||||
normalized_account_id = self._normalize_account_id(account_id)
|
||||
async with self._lock:
|
||||
subs = self._subscribers.get(normalized_account_id)
|
||||
if subs is None:
|
||||
return
|
||||
subs.discard(queue)
|
||||
if not subs:
|
||||
self._subscribers.pop(normalized_account_id, None)
|
||||
|
||||
async def publish(self, account_id: UUID | str, event: dict[str, Any]) -> int:
|
||||
"""Fan event out to every subscriber for `account_id`.
|
||||
|
||||
Returns the number of subscribers that successfully received the
|
||||
event. Drops the event for any subscriber whose queue is full
|
||||
(logs at warning level).
|
||||
"""
|
||||
normalized_account_id = self._normalize_account_id(account_id)
|
||||
async with self._lock:
|
||||
subs = list(self._subscribers.get(normalized_account_id, ()))
|
||||
if not subs:
|
||||
return 0
|
||||
delivered = 0
|
||||
for queue in subs:
|
||||
try:
|
||||
queue.put_nowait(event)
|
||||
delivered += 1
|
||||
except asyncio.QueueFull:
|
||||
logger.warning(
|
||||
"EscalationBus: dropped event for full subscriber queue "
|
||||
"(account_id=%s, event=%s)",
|
||||
normalized_account_id,
|
||||
event.get("type", "?"),
|
||||
)
|
||||
return delivered
|
||||
|
||||
def subscriber_count(self, account_id: UUID | str) -> int:
|
||||
"""Diagnostic — number of active subscribers for an account."""
|
||||
normalized_account_id = self._normalize_account_id(account_id)
|
||||
return len(self._subscribers.get(normalized_account_id, ()))
|
||||
|
||||
|
||||
# Module-level singleton. FastAPI imports this; `subscribe()` and `publish()`
|
||||
# are coroutine-safe via the internal Lock.
|
||||
bus = EscalationBus()
|
||||
@@ -124,3 +124,26 @@ class FlowPilotDashboard(BaseModel):
|
||||
confidence_breakdown: ConfidenceBreakdown
|
||||
knowledge_coverage: KnowledgeCoverage
|
||||
psa_metrics: PsaMetrics | None = None
|
||||
|
||||
|
||||
class EscalationMetrics(BaseModel):
|
||||
"""In-product time-to-first-action metric for the Escalation Mode wedge.
|
||||
|
||||
NOTE: this is the *in-product* metric (post-claim time-to-first-action). The
|
||||
"minutes recovered" sales claim requires a manual baseline measurement of the
|
||||
pre-Escalation-Mode verbal-handoff time. See
|
||||
docs/plans/2026-04-27-escalation-mode-wedge-design.md for the two-metric
|
||||
framing — do not roll this number alone into "minutes recovered."
|
||||
"""
|
||||
|
||||
period: str
|
||||
n_handoffs_claimed: int
|
||||
n_handoffs_with_action: int
|
||||
avg_seconds_to_first_action: float | None = None
|
||||
median_seconds_to_first_action: float | None = None
|
||||
p95_seconds_to_first_action: float | None = None
|
||||
metric_definition: str = (
|
||||
"elapsed_seconds(first ai_session_step in session where "
|
||||
"created_at > SessionHandoff.claimed_at) — measures post-claim activity "
|
||||
"lag, NOT verbal-handoff savings. Pair with manual baseline."
|
||||
)
|
||||
|
||||
@@ -10,12 +10,18 @@ class HandoffCreateRequest(BaseModel):
|
||||
intent: str = Field(..., pattern="^(park|escalate)$")
|
||||
engineer_notes: str | None = None
|
||||
priority: str = Field("normal", pattern="^(normal|elevated)$")
|
||||
# Optional escalation target — if set, only this user is the named
|
||||
# recipient. Notification dispatch fans out to all engineer/admin/owner
|
||||
# users in the account either way; this just records the original
|
||||
# engineer's preferred recipient on the session for audit/UX.
|
||||
target_user_id: UUID | None = None
|
||||
|
||||
|
||||
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]
|
||||
|
||||
@@ -632,8 +632,10 @@ async def pickup_session(
|
||||
allow_team_access=True, team_id=team_id,
|
||||
)
|
||||
|
||||
if session.status != "requesting_escalation":
|
||||
raise ValueError(f"Session is {session.status}, not requesting_escalation")
|
||||
if session.status not in ("requesting_escalation", "escalated"):
|
||||
raise ValueError(
|
||||
f"Session is {session.status}, not in an escalated state"
|
||||
)
|
||||
|
||||
# Can't pick up your own session
|
||||
if session.user_id == user_id:
|
||||
@@ -911,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):
|
||||
|
||||
@@ -3,22 +3,65 @@
|
||||
Creates handoff snapshots, AI assessments (for escalations), claim workflow,
|
||||
and queue queries. Dual-writes to ai_sessions.escalation_package for
|
||||
backward compatibility with the existing escalation queue.
|
||||
|
||||
For intent='escalate', `create_handoff` also runs the legacy enrichment
|
||||
that the deprecated `/escalate` endpoint used to do directly: setting
|
||||
`escalated_to_id`, building the AI-enhanced escalation_package (Sonnet),
|
||||
and recording escalation_reason. `finalize_escalation` then generates the
|
||||
SessionDocumentation and pushes to PSA. `dispatch_escalation_notifications`
|
||||
fans out the bell-icon AppNotification + external channels (Slack/Teams)
|
||||
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
|
||||
from uuid import UUID
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, update
|
||||
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
|
||||
from app.models.ai_session import AISession
|
||||
from app.models.session_branch import SessionBranch
|
||||
from app.models.session_handoff import SessionHandoff
|
||||
from app.models.user import User
|
||||
from app.schemas.ai_session import SessionDocumentation
|
||||
from app.services.notification_service import notify
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HandoffAlreadyClaimedError(Exception):
|
||||
"""Raised when a senior tries to claim a handoff another senior already won.
|
||||
|
||||
Carries the winning claimer's id, display name, and claim timestamp so the
|
||||
API layer can surface a "Already claimed by {name} {time_ago}" toast on
|
||||
the losing client. The race story is the locked design — without this
|
||||
exception the endpoint would silently overwrite `claimed_by` and both
|
||||
seniors would think they own the session.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
claimed_by_id: UUID,
|
||||
claimed_by_name: str,
|
||||
claimed_at: datetime,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
f"Handoff already claimed by {claimed_by_name} at {claimed_at.isoformat()}"
|
||||
)
|
||||
self.claimed_by_id = claimed_by_id
|
||||
self.claimed_by_name = claimed_by_name
|
||||
self.claimed_at = claimed_at
|
||||
|
||||
|
||||
class HandoffManager:
|
||||
"""Unified park/escalate handoff management."""
|
||||
|
||||
@@ -32,37 +75,71 @@ class HandoffManager:
|
||||
engineer_notes: str | None,
|
||||
user_id: UUID,
|
||||
priority: str = "normal",
|
||||
target_user_id: UUID | None = None,
|
||||
) -> SessionHandoff:
|
||||
"""Create a handoff (park or escalate).
|
||||
|
||||
Generates snapshot, updates session status, dual-writes to
|
||||
escalation_package for backward compat.
|
||||
|
||||
For intent='escalate' also: sets `session.escalation_reason` and
|
||||
optionally `session.escalated_to_id`, builds the AI-enhanced
|
||||
escalation package (the rich one the legacy `/escalate` path used
|
||||
to produce), and merges the handoff metadata into it. Self-targeting
|
||||
is rejected with ValueError, matching legacy behavior.
|
||||
"""
|
||||
user_id = UUID(str(user_id))
|
||||
if target_user_id:
|
||||
target_user_id = UUID(str(target_user_id))
|
||||
|
||||
# Eager-load steps + user — _build_escalation_package_enhanced and
|
||||
# finalize_escalation iterate over session.steps to compose the
|
||||
# legacy enriched package and the SessionDocumentation, and the
|
||||
# notify() dispatcher reads session.user.name. Without selectinload
|
||||
# the async session raises MissingGreenlet on attribute access.
|
||||
result = await self.db.execute(
|
||||
select(AISession).where(AISession.id == session_id)
|
||||
select(AISession)
|
||||
.options(
|
||||
selectinload(AISession.steps),
|
||||
selectinload(AISession.user),
|
||||
)
|
||||
.where(AISession.id == session_id)
|
||||
)
|
||||
session = result.scalar_one_or_none()
|
||||
if not session:
|
||||
raise ValueError(f"Session {session_id} not found")
|
||||
|
||||
# Generate snapshot
|
||||
if intent == "escalate":
|
||||
if target_user_id and target_user_id == user_id:
|
||||
raise ValueError(
|
||||
"Cannot escalate a session to yourself. Use pause instead."
|
||||
)
|
||||
if session.status not in ("active", "paused"):
|
||||
raise ValueError(
|
||||
f"Cannot escalate session in status: {session.status}"
|
||||
)
|
||||
|
||||
# Generate snapshot — fast, no AI calls.
|
||||
snapshot = await self._generate_snapshot(session)
|
||||
|
||||
# Generate AI assessment for escalations
|
||||
ai_assessment = None
|
||||
ai_assessment_data = None
|
||||
if intent == "escalate":
|
||||
ai_assessment, ai_assessment_data = await self._generate_ai_assessment(session)
|
||||
|
||||
# AI enrichment (assessment + enhanced escalation_package) is now
|
||||
# deferred to a background task scheduled by the endpoint after
|
||||
# commit — both calls hit Sonnet and together can take 15-25s,
|
||||
# which is too long to block the click path. The handoff row lands
|
||||
# immediately with `ai_assessment=None`; the magic-moment screen
|
||||
# shows "Assessment still computing" until enrich_async finishes
|
||||
# and the senior refreshes (or, eventually, polls).
|
||||
handoff_id = uuid4()
|
||||
handoff = SessionHandoff(
|
||||
id=handoff_id,
|
||||
session_id=session_id,
|
||||
account_id=session.account_id,
|
||||
handed_off_by=user_id,
|
||||
intent=intent,
|
||||
source_branch_id=session.active_branch_id,
|
||||
snapshot=snapshot,
|
||||
ai_assessment=ai_assessment,
|
||||
ai_assessment_data=ai_assessment_data,
|
||||
ai_assessment=None,
|
||||
ai_assessment_data=None,
|
||||
engineer_notes=engineer_notes,
|
||||
priority=priority,
|
||||
)
|
||||
@@ -73,20 +150,248 @@ class HandoffManager:
|
||||
session.status = "paused"
|
||||
elif intent == "escalate":
|
||||
session.status = "escalated"
|
||||
session.escalation_reason = engineer_notes
|
||||
if target_user_id:
|
||||
session.escalated_to_id = target_user_id
|
||||
|
||||
session.handoff_count = (session.handoff_count or 0) + 1
|
||||
|
||||
# Dual-write for backward compat
|
||||
# Dual-write the minimal escalation_package shape now. The async
|
||||
# enrichment task overwrites this with the AI-enhanced shape
|
||||
# (`steps_tried`, `remaining_hypotheses`, etc.) when it completes —
|
||||
# consumers that read these fields (PSA writeback, legacy
|
||||
# SessionBriefing) tolerate either shape.
|
||||
session.escalation_package = {
|
||||
"snapshot": snapshot,
|
||||
"intent": intent,
|
||||
"engineer_notes": engineer_notes,
|
||||
"handoff_id": str(handoff.id),
|
||||
"handoff_id": str(handoff_id),
|
||||
}
|
||||
|
||||
await self.db.flush()
|
||||
return handoff
|
||||
|
||||
async def finalize_escalation(
|
||||
self,
|
||||
handoff: SessionHandoff,
|
||||
session: AISession,
|
||||
user_id: UUID,
|
||||
) -> tuple[SessionDocumentation | None, dict[str, Any]]:
|
||||
"""Post-create enrichment for intent='escalate' handoffs.
|
||||
|
||||
Generates the SessionDocumentation + pushes documentation to PSA if
|
||||
a ticket is linked. Returns (documentation, psa_result) so the
|
||||
legacy `/escalate` shim can map back to SessionCloseResponse. Safe
|
||||
to call only when handoff.intent == 'escalate' — for park, returns
|
||||
a no-op no-PSA dict.
|
||||
"""
|
||||
if handoff.intent != "escalate":
|
||||
return None, {
|
||||
"psa_push_status": "no_psa",
|
||||
"psa_push_error": None,
|
||||
"member_mapping_warning": None,
|
||||
}
|
||||
|
||||
# Lazy import to avoid circular dependency: flowpilot_engine imports
|
||||
# plenty of services at module load time and we don't want
|
||||
# handoff_manager pulled into that graph at import.
|
||||
from app.services.flowpilot_engine import (
|
||||
_generate_documentation,
|
||||
_push_to_psa,
|
||||
)
|
||||
|
||||
documentation = _generate_documentation(session)
|
||||
psa_result = await _push_to_psa(session, user_id, self.db)
|
||||
|
||||
# Bell-icon AppNotification rows + external account-level channels
|
||||
# (Slack/Teams webhooks, shared escalations inboxes). This is the
|
||||
# `notify()` call the legacy /escalate path used to make directly,
|
||||
# and it has to happen BEFORE the endpoint commits so the
|
||||
# AppNotification rows land atomically with the handoff. Per-user
|
||||
# emails come after commit in dispatch_escalation_notifications —
|
||||
# those are pure IO with no persistent state.
|
||||
try:
|
||||
engineer_user = (
|
||||
await self.db.execute(
|
||||
select(User).where(User.id == user_id)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
engineer_name = (
|
||||
engineer_user.name
|
||||
if engineer_user and engineer_user.name
|
||||
else "Unknown"
|
||||
)
|
||||
target_user_ids = (
|
||||
[session.escalated_to_id] if session.escalated_to_id else None
|
||||
)
|
||||
await notify(
|
||||
"session.escalated",
|
||||
handoff.account_id,
|
||||
{
|
||||
"session_id": str(handoff.session_id),
|
||||
"engineer_name": engineer_name,
|
||||
"escalation_reason": handoff.engineer_notes or "",
|
||||
"problem_summary": session.problem_summary or "N/A",
|
||||
# Surface the PSA ticket id in the bell-icon title so two
|
||||
# similarly-worded escalations are still distinguishable
|
||||
# at a glance.
|
||||
"psa_ticket_id": session.psa_ticket_id,
|
||||
},
|
||||
self.db,
|
||||
target_user_ids=target_user_ids,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"notify() dispatch failed for handoff %s", handoff.id
|
||||
)
|
||||
|
||||
return documentation, psa_result
|
||||
|
||||
async def _build_enhanced_escalation_package(
|
||||
self,
|
||||
session: AISession,
|
||||
user_id: UUID,
|
||||
) -> dict[str, Any]:
|
||||
"""Lazy wrapper around the legacy enhanced-package builder.
|
||||
|
||||
The builder lives in flowpilot_engine; we only need it for the
|
||||
escalate path. Failures are caught here so handoff creation never
|
||||
depends on the optional Sonnet enrichment — return the minimal
|
||||
shape on failure.
|
||||
"""
|
||||
try:
|
||||
from app.services.flowpilot_engine import (
|
||||
_build_escalation_package_enhanced,
|
||||
)
|
||||
return await _build_escalation_package_enhanced(session, user_id)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Enhanced escalation package build failed for session %s; "
|
||||
"falling back to minimal package",
|
||||
session.id,
|
||||
)
|
||||
return {}
|
||||
|
||||
|
||||
async def dispatch_escalation_notifications(
|
||||
self, handoff: SessionHandoff
|
||||
) -> int:
|
||||
"""Email engineer-or-admin users in the account about a new escalation.
|
||||
|
||||
Call this AFTER `db.commit()` has succeeded — sending email for a
|
||||
rolled-back handoff is the kind of trust-erosion bug that makes pilot
|
||||
customers stop trusting the tool. Returns the number of recipients
|
||||
successfully emailed (best-effort, not authoritative).
|
||||
|
||||
Failures are logged but never raise: the wedge demo's reliability
|
||||
story is "handoff creation always succeeds; notification is best-effort,"
|
||||
not "handoff creation depends on the email service being up." This is
|
||||
the graceful-degradation regression the eng + codex reviews both
|
||||
flagged as critical.
|
||||
|
||||
Per-channel delivery records (Codex correction on the dead
|
||||
`notification_sent` boolean) are a v1.x story — for now the
|
||||
application logs are the audit trail.
|
||||
"""
|
||||
if handoff.intent != "escalate":
|
||||
return 0
|
||||
|
||||
# Publish to the in-memory bus first so connected senior-tech inboxes
|
||||
# see the new card slide in within ~1s of escalate. This path is
|
||||
# fire-and-forget (no IO, just memory) so it can sit ahead of the
|
||||
# email fan-out.
|
||||
try:
|
||||
await escalation_bus.publish(
|
||||
handoff.account_id,
|
||||
{
|
||||
"type": "handoff_created",
|
||||
"handoff_id": str(handoff.id),
|
||||
"session_id": str(handoff.session_id),
|
||||
"priority": handoff.priority,
|
||||
"engineer_notes": handoff.engineer_notes or "",
|
||||
"created_at": handoff.created_at.isoformat()
|
||||
if handoff.created_at
|
||||
else None,
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"EscalationBus publish failed for handoff %s", handoff.id
|
||||
)
|
||||
|
||||
try:
|
||||
recipients = (
|
||||
await self.db.execute(
|
||||
select(User).where(
|
||||
User.account_id == handoff.account_id,
|
||||
User.id != handoff.handed_off_by,
|
||||
User.account_role.in_(("owner", "admin", "engineer")),
|
||||
User.is_active.is_(True),
|
||||
User.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
if not recipients:
|
||||
logger.info(
|
||||
"No notification recipients for handoff %s in account %s",
|
||||
handoff.id,
|
||||
handoff.account_id,
|
||||
)
|
||||
return 0
|
||||
|
||||
# Pull session for the email subject. Fall back to a generic title
|
||||
# if the session is gone (e.g. cascade delete mid-dispatch).
|
||||
session_result = await self.db.execute(
|
||||
select(AISession).where(AISession.id == handoff.session_id)
|
||||
)
|
||||
session = session_result.scalar_one_or_none()
|
||||
problem = (
|
||||
session.problem_summary if session and session.problem_summary
|
||||
else "an active session"
|
||||
)
|
||||
|
||||
title = f"New escalation: {problem}"
|
||||
notes = (handoff.engineer_notes or "").strip()
|
||||
body = (
|
||||
"A teammate has escalated a session and is asking for help.\n\n"
|
||||
f"Reason: {notes if notes else 'No reason provided.'}\n"
|
||||
f"Priority: {handoff.priority}"
|
||||
)
|
||||
link_url = (
|
||||
f"{settings.FRONTEND_URL.rstrip('/')}/escalations"
|
||||
if settings.FRONTEND_URL
|
||||
else None
|
||||
)
|
||||
|
||||
results = await asyncio.gather(
|
||||
*[
|
||||
EmailService.send_notification_email(
|
||||
to_email=r.email,
|
||||
title=title,
|
||||
body=body,
|
||||
link_url=link_url,
|
||||
)
|
||||
for r in recipients
|
||||
],
|
||||
return_exceptions=True,
|
||||
)
|
||||
sent = sum(1 for r in results if r is True)
|
||||
logger.info(
|
||||
"Escalation notifications dispatched for handoff %s: %d/%d recipients",
|
||||
handoff.id,
|
||||
sent,
|
||||
len(recipients),
|
||||
)
|
||||
return sent
|
||||
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Escalation notification dispatch failed for handoff %s",
|
||||
handoff.id,
|
||||
)
|
||||
return 0
|
||||
|
||||
async def _generate_snapshot(self, session: AISession) -> dict[str, Any]:
|
||||
"""Generate a snapshot of the session state at handoff time."""
|
||||
snapshot: dict[str, Any] = {
|
||||
@@ -125,16 +430,56 @@ class HandoffManager:
|
||||
handoff_id: UUID,
|
||||
claiming_user_id: UUID,
|
||||
) -> SessionHandoff:
|
||||
"""Claim a handed-off session."""
|
||||
"""Claim a handed-off session.
|
||||
|
||||
If the handoff was already claimed by a *different* user (the race
|
||||
story: two seniors clicking Pick Up simultaneously), raise
|
||||
`HandoffAlreadyClaimedError` with the winning claimer's details so
|
||||
the API can return 409 with the data the loser's toast needs. A
|
||||
re-claim by the same user is idempotent.
|
||||
"""
|
||||
claiming_user_id = UUID(str(claiming_user_id))
|
||||
claimed_at = datetime.now(timezone.utc)
|
||||
|
||||
update_result = await self.db.execute(
|
||||
update(SessionHandoff)
|
||||
.where(
|
||||
SessionHandoff.id == handoff_id,
|
||||
SessionHandoff.claimed_by.is_(None),
|
||||
SessionHandoff.handed_off_by != claiming_user_id,
|
||||
)
|
||||
.values(claimed_by=claiming_user_id, claimed_at=claimed_at)
|
||||
.returning(SessionHandoff.id)
|
||||
)
|
||||
claimed_now = update_result.scalar_one_or_none() is not None
|
||||
|
||||
result = await self.db.execute(
|
||||
select(SessionHandoff).where(SessionHandoff.id == handoff_id)
|
||||
select(SessionHandoff)
|
||||
.options(
|
||||
selectinload(SessionHandoff.claimed_by_user),
|
||||
selectinload(SessionHandoff.handed_off_by_user),
|
||||
)
|
||||
.where(SessionHandoff.id == handoff_id)
|
||||
)
|
||||
handoff = result.scalar_one_or_none()
|
||||
if not handoff:
|
||||
raise ValueError(f"Handoff {handoff_id} not found")
|
||||
|
||||
handoff.claimed_by = claiming_user_id
|
||||
handoff.claimed_at = datetime.now(timezone.utc)
|
||||
handed_off_by = UUID(str(handoff.handed_off_by))
|
||||
claimed_by = (
|
||||
UUID(str(handoff.claimed_by)) if handoff.claimed_by is not None else None
|
||||
)
|
||||
|
||||
if handed_off_by == claiming_user_id:
|
||||
raise PermissionError("Cannot claim your own handoff")
|
||||
|
||||
if not claimed_now and claimed_by != claiming_user_id:
|
||||
claimer = handoff.claimed_by_user
|
||||
raise HandoffAlreadyClaimedError(
|
||||
claimed_by_id=claimed_by,
|
||||
claimed_by_name=claimer.name if claimer else "another engineer",
|
||||
claimed_at=handoff.claimed_at or datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
# Reactivate session
|
||||
session_result = await self.db.execute(
|
||||
@@ -149,43 +494,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."""
|
||||
) -> dict[str, Any] | None:
|
||||
"""Single structured AI call for the escalation magic-moment screen.
|
||||
|
||||
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:
|
||||
from app.services.assistant_chat_service import _call_ai
|
||||
|
||||
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:]
|
||||
return await asyncio.wait_for(
|
||||
self._generate_handoff_summary_inner(session),
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
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,
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(
|
||||
"Handoff summary timed out after %ss for session %s",
|
||||
timeout,
|
||||
session.id,
|
||||
)
|
||||
|
||||
assessment_data = {
|
||||
"likely_cause": "See assessment text",
|
||||
"suggested_steps": [],
|
||||
"confidence": "medium",
|
||||
}
|
||||
|
||||
return assessment_text, assessment_data
|
||||
return None
|
||||
except Exception:
|
||||
logger.exception("Failed to generate AI assessment")
|
||||
return None, None
|
||||
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
|
||||
@@ -288,3 +701,105 @@ class HandoffManager:
|
||||
})
|
||||
|
||||
return queue_items
|
||||
|
||||
|
||||
async def enrich_escalation_async(handoff_id: UUID, user_id: UUID) -> None:
|
||||
"""Run the AI enrichment for an escalation handoff in the background.
|
||||
|
||||
Scheduled by `/escalate` and `/handoff` (intent=escalate) endpoints via
|
||||
FastAPI BackgroundTasks. Opens its own DB session because the request
|
||||
session is closed by the time this runs. Generates:
|
||||
|
||||
1. The legacy AI-enhanced escalation_package (Sonnet, ~5-10s) — saved
|
||||
to `session.escalation_package`, preserving the `intent` /
|
||||
`engineer_notes` / `handoff_id` keys the dual-write set so legacy
|
||||
consumers keep working.
|
||||
2. The diagnostic AI assessment (Sonnet, ~4-15s) — saved to
|
||||
`handoff.ai_assessment` and `handoff.ai_assessment_data`.
|
||||
|
||||
On completion publishes a `handoff_assessment_ready` event on the
|
||||
escalation bus so any connected magic-moment screen can refresh
|
||||
without a manual reload. Failures are logged but never propagated —
|
||||
the click-path-side handoff creation already committed, so worst case
|
||||
the senior sees the "Assessment still computing" placeholder until
|
||||
they refresh manually.
|
||||
"""
|
||||
from app.core.database import async_session_maker
|
||||
from app.core.escalation_bus import bus as escalation_bus
|
||||
|
||||
async with async_session_maker() as db:
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(SessionHandoff).where(SessionHandoff.id == handoff_id)
|
||||
)
|
||||
handoff = result.scalar_one_or_none()
|
||||
if not handoff or handoff.intent != "escalate":
|
||||
return
|
||||
|
||||
session_result = await db.execute(
|
||||
select(AISession)
|
||||
.options(selectinload(AISession.steps), selectinload(AISession.user))
|
||||
.where(AISession.id == handoff.session_id)
|
||||
)
|
||||
session = session_result.scalar_one_or_none()
|
||||
if not session:
|
||||
logger.warning(
|
||||
"enrich_escalation_async: session %s gone for handoff %s",
|
||||
handoff.session_id,
|
||||
handoff_id,
|
||||
)
|
||||
return
|
||||
|
||||
manager = HandoffManager(db)
|
||||
|
||||
# Single consolidated AI call — replaces the old
|
||||
# _generate_ai_assessment + _build_enhanced_escalation_package pair.
|
||||
try:
|
||||
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: summary generation failed for handoff %s",
|
||||
handoff_id,
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
try:
|
||||
await escalation_bus.publish(
|
||||
handoff.account_id,
|
||||
{
|
||||
"type": "handoff_assessment_ready",
|
||||
"handoff_id": str(handoff.id),
|
||||
"session_id": str(handoff.session_id),
|
||||
"has_assessment": handoff.ai_assessment_data is not None,
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"enrich_escalation_async: bus publish failed for handoff %s",
|
||||
handoff_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"enrich_escalation_async failed for handoff %s", handoff_id
|
||||
)
|
||||
try:
|
||||
await db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -371,13 +371,35 @@ async def _send_teams_message(
|
||||
def _build_notification_title(event: str, payload: dict[str, Any]) -> str:
|
||||
"""Human-readable title per event type."""
|
||||
titles = {
|
||||
"session.escalated": "Session escalated by {engineer_name}",
|
||||
# Distinguishability matters in the bell panel: with a generic title
|
||||
# ("Session escalated by Jane") two different escalations from the
|
||||
# same junior look like a duplicate notification. Including a short
|
||||
# problem snippet (and ticket number if present) lets the senior
|
||||
# tell them apart at a glance.
|
||||
"session.escalated": "Escalation from {engineer_name}{ticket_suffix}: {problem_snippet}",
|
||||
"session.high_priority": "High-priority session started: {ticket_number}",
|
||||
"proposal.pending": "New flow proposal: {title}",
|
||||
"proposal.approved": "Flow proposal approved: {title}",
|
||||
"knowledge_gap.detected": "Knowledge gap detected: {gap_type}",
|
||||
"test": "Test Notification from ResolutionFlow",
|
||||
}
|
||||
|
||||
# Build the escalation-specific derived fields. Done here rather than at
|
||||
# the call site so every dispatch path (legacy /escalate shim, /handoff,
|
||||
# any future entry point) gets consistent formatting without each one
|
||||
# having to repeat the snippet logic.
|
||||
if event == "session.escalated":
|
||||
problem = (payload.get("problem_summary") or "").strip()
|
||||
if not problem or problem.upper() == "N/A":
|
||||
problem_snippet = "(no summary provided)"
|
||||
elif len(problem) > 70:
|
||||
problem_snippet = problem[:67].rstrip() + "…"
|
||||
else:
|
||||
problem_snippet = problem
|
||||
ticket = payload.get("psa_ticket_id") or payload.get("ticket_number")
|
||||
ticket_suffix = f" · #{ticket}" if ticket else ""
|
||||
payload = {**payload, "problem_snippet": problem_snippet, "ticket_suffix": ticket_suffix}
|
||||
|
||||
template = titles.get(event, f"Notification: {event}")
|
||||
try:
|
||||
return template.format(**payload)
|
||||
@@ -405,7 +427,12 @@ def _build_notification_body(event: str, payload: dict[str, Any]) -> str:
|
||||
def _build_notification_link(event: str, payload: dict[str, Any]) -> Optional[str]:
|
||||
"""In-app link per event type. Returns path (no host)."""
|
||||
links: dict[str, str] = {
|
||||
"session.escalated": "/pilot/{session_id}",
|
||||
# ?pickup=true triggers the senior-tech handoff/pickup flow on the
|
||||
# session page (magic-moment screen for handoff-based escalations,
|
||||
# legacy SessionBriefing for `requesting_escalation` sessions).
|
||||
# Without it the senior lands on a session-detail GET they can't
|
||||
# access pre-pickup, which the user perceives as a dead notification.
|
||||
"session.escalated": "/pilot/{session_id}?pickup=true",
|
||||
"session.high_priority": "/pilot/{session_id}",
|
||||
"proposal.pending": "/review-queue",
|
||||
"proposal.approved": "/review-queue",
|
||||
|
||||
@@ -583,10 +583,14 @@ async def send_chat_message(
|
||||
|
||||
Returns (ai_content, suggested_flows, session, fork_metadata, actions_data, questions_data).
|
||||
"""
|
||||
from sqlalchemy import or_
|
||||
result = await db.execute(
|
||||
select(AISession).where(
|
||||
AISession.id == session_id,
|
||||
AISession.user_id == user_id,
|
||||
or_(
|
||||
AISession.user_id == user_id,
|
||||
AISession.escalated_to_id == user_id,
|
||||
),
|
||||
AISession.session_type == "chat",
|
||||
)
|
||||
)
|
||||
|
||||
121
backend/tests/test_escalation_bus.py
Normal file
121
backend/tests/test_escalation_bus.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""Unit tests for the in-memory escalation pub/sub bus."""
|
||||
import asyncio
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.escalation_bus import EscalationBus
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_publish_with_no_subscribers_returns_zero():
|
||||
bus = EscalationBus()
|
||||
delivered = await bus.publish(uuid4(), {"type": "handoff_created"})
|
||||
assert delivered == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscribe_then_publish_delivers_event():
|
||||
bus = EscalationBus()
|
||||
account = uuid4()
|
||||
queue = await bus.subscribe(account)
|
||||
try:
|
||||
delivered = await bus.publish(account, {"type": "handoff_created", "id": "x"})
|
||||
assert delivered == 1
|
||||
event = await asyncio.wait_for(queue.get(), timeout=1.0)
|
||||
assert event == {"type": "handoff_created", "id": "x"}
|
||||
finally:
|
||||
await bus.unsubscribe(account, queue)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_two_subscribers_same_account_both_receive():
|
||||
bus = EscalationBus()
|
||||
account = uuid4()
|
||||
q1 = await bus.subscribe(account)
|
||||
q2 = await bus.subscribe(account)
|
||||
try:
|
||||
delivered = await bus.publish(account, {"type": "x"})
|
||||
assert delivered == 2
|
||||
e1 = await asyncio.wait_for(q1.get(), timeout=1.0)
|
||||
e2 = await asyncio.wait_for(q2.get(), timeout=1.0)
|
||||
assert e1 == e2 == {"type": "x"}
|
||||
finally:
|
||||
await bus.unsubscribe(account, q1)
|
||||
await bus.unsubscribe(account, q2)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscriber_in_other_account_does_not_receive():
|
||||
"""Cross-tenant isolation is the whole point — sanity check it directly."""
|
||||
bus = EscalationBus()
|
||||
account_a = uuid4()
|
||||
account_b = uuid4()
|
||||
q_a = await bus.subscribe(account_a)
|
||||
q_b = await bus.subscribe(account_b)
|
||||
try:
|
||||
delivered = await bus.publish(account_a, {"type": "x"})
|
||||
assert delivered == 1
|
||||
|
||||
e_a = await asyncio.wait_for(q_a.get(), timeout=1.0)
|
||||
assert e_a == {"type": "x"}
|
||||
|
||||
# B's queue must remain empty.
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(q_b.get(), timeout=0.1)
|
||||
finally:
|
||||
await bus.unsubscribe(account_a, q_a)
|
||||
await bus.unsubscribe(account_b, q_b)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_publish_normalizes_string_uuid_account_id():
|
||||
"""ORM-created objects can briefly carry string UUIDs in-memory."""
|
||||
bus = EscalationBus()
|
||||
account = uuid4()
|
||||
queue = await bus.subscribe(account)
|
||||
try:
|
||||
delivered = await bus.publish(str(account), {"type": "x"})
|
||||
assert delivered == 1
|
||||
event = await asyncio.wait_for(queue.get(), timeout=1.0)
|
||||
assert event == {"type": "x"}
|
||||
finally:
|
||||
await bus.unsubscribe(str(account), queue)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unsubscribe_drops_subscriber_count_to_zero():
|
||||
bus = EscalationBus()
|
||||
account = uuid4()
|
||||
q = await bus.subscribe(account)
|
||||
assert bus.subscriber_count(account) == 1
|
||||
await bus.unsubscribe(account, q)
|
||||
assert bus.subscriber_count(account) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_publish_drops_events_when_subscriber_queue_is_full():
|
||||
"""A stuck subscriber must not back-pressure publishers."""
|
||||
bus = EscalationBus()
|
||||
account = uuid4()
|
||||
queue = await bus.subscribe(account)
|
||||
try:
|
||||
# Stuff the queue past capacity (maxsize is 64) without consuming.
|
||||
for _ in range(65):
|
||||
await bus.publish(account, {"type": "x"})
|
||||
# Sanity: queue holds at most maxsize.
|
||||
assert queue.qsize() <= 64
|
||||
# Publishes after capacity didn't raise — they were dropped silently.
|
||||
finally:
|
||||
await bus.unsubscribe(account, queue)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unsubscribe_unknown_queue_is_noop():
|
||||
"""Defensive: unsubscribe on an account/queue that isn't registered
|
||||
should not raise — finally blocks rely on this."""
|
||||
bus = EscalationBus()
|
||||
account = uuid4()
|
||||
fake_queue: asyncio.Queue = asyncio.Queue()
|
||||
# Should not raise.
|
||||
await bus.unsubscribe(account, fake_queue)
|
||||
363
backend/tests/test_flowpilot_analytics_escalations.py
Normal file
363
backend/tests/test_flowpilot_analytics_escalations.py
Normal file
@@ -0,0 +1,363 @@
|
||||
"""Tests for GET /analytics/flowpilot/escalations — Escalation Mode wedge metric.
|
||||
|
||||
Covers the in-product time-to-first-action measurement that powers the queue
|
||||
stat-card and the analytics page. The savings claim itself comes from the
|
||||
manual baseline (the Assignment); these tests only cover what the in-product
|
||||
endpoint returns.
|
||||
"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from uuid import UUID as PyUUID
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.ai_session import AISession
|
||||
from app.models.ai_session_step import AISessionStep
|
||||
from app.models.session_handoff import SessionHandoff
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
URL = "/api/v1/analytics/flowpilot/escalations"
|
||||
|
||||
|
||||
# ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _make_session(db, *, user_id, account_id) -> AISession:
|
||||
s = AISession(
|
||||
user_id=user_id,
|
||||
account_id=account_id,
|
||||
session_type="guided",
|
||||
intake_type="free_text",
|
||||
intake_content={"text": "test"},
|
||||
status="escalated",
|
||||
confidence_tier="discovery",
|
||||
conversation_messages=[],
|
||||
)
|
||||
db.add(s)
|
||||
await db.flush()
|
||||
return s
|
||||
|
||||
|
||||
async def _make_handoff(
|
||||
db,
|
||||
*,
|
||||
session_id,
|
||||
account_id,
|
||||
user_id,
|
||||
claimed_at: datetime | None,
|
||||
claimed_by=None,
|
||||
) -> SessionHandoff:
|
||||
h = SessionHandoff(
|
||||
session_id=session_id,
|
||||
account_id=account_id,
|
||||
handed_off_by=user_id,
|
||||
intent="escalate",
|
||||
snapshot={"branch_map": "stub"},
|
||||
priority="normal",
|
||||
claimed_at=claimed_at,
|
||||
claimed_by=claimed_by,
|
||||
)
|
||||
db.add(h)
|
||||
await db.flush()
|
||||
return h
|
||||
|
||||
|
||||
async def _make_step(db, *, session_id, account_id, created_at: datetime) -> AISessionStep:
|
||||
"""Insert an ai_session_step row with an explicit created_at.
|
||||
|
||||
SQLAlchemy's default would set created_at to now(); the metric query keys
|
||||
off this column so the tests need to control it directly.
|
||||
"""
|
||||
step = AISessionStep(
|
||||
session_id=session_id,
|
||||
account_id=account_id,
|
||||
step_order=1,
|
||||
step_type="note",
|
||||
content={"text": "first action"},
|
||||
confidence_at_step=0.5,
|
||||
input_tokens=0,
|
||||
output_tokens=0,
|
||||
is_fork_point=False,
|
||||
was_free_text=False,
|
||||
was_skipped=False,
|
||||
created_at=created_at,
|
||||
)
|
||||
db.add(step)
|
||||
await db.flush()
|
||||
return step
|
||||
|
||||
|
||||
# ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_zero_metrics_when_no_handoffs(
|
||||
client: AsyncClient, auth_headers, test_user
|
||||
):
|
||||
"""Empty account → n_handoffs_claimed=0, all stats None, 200 OK."""
|
||||
response = await client.get(URL, headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["period"] == "30d"
|
||||
assert body["n_handoffs_claimed"] == 0
|
||||
assert body["n_handoffs_with_action"] == 0
|
||||
assert body["avg_seconds_to_first_action"] is None
|
||||
assert body["median_seconds_to_first_action"] is None
|
||||
assert body["p95_seconds_to_first_action"] is None
|
||||
# Disclaimer is part of the contract — pilots reading the API should see it.
|
||||
assert "manual baseline" in body["metric_definition"].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_happy_path_single_handoff_with_action(
|
||||
client: AsyncClient, auth_headers, test_user, test_db
|
||||
):
|
||||
"""One claimed handoff + a step 90s later → avg=median=p95=90.0."""
|
||||
user_id = PyUUID(test_user["user_data"]["id"])
|
||||
account_id = PyUUID(test_user["user_data"]["account_id"])
|
||||
|
||||
claimed_at = datetime.now(timezone.utc) - timedelta(hours=2)
|
||||
first_action_at = claimed_at + timedelta(seconds=90)
|
||||
|
||||
session = await _make_session(test_db, user_id=user_id, account_id=account_id)
|
||||
await _make_handoff(
|
||||
test_db,
|
||||
session_id=session.id,
|
||||
account_id=account_id,
|
||||
user_id=user_id,
|
||||
claimed_at=claimed_at,
|
||||
claimed_by=user_id,
|
||||
)
|
||||
await _make_step(
|
||||
test_db,
|
||||
session_id=session.id,
|
||||
account_id=account_id,
|
||||
created_at=first_action_at,
|
||||
)
|
||||
await test_db.commit()
|
||||
|
||||
response = await client.get(URL, headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["n_handoffs_claimed"] == 1
|
||||
assert body["n_handoffs_with_action"] == 1
|
||||
assert body["avg_seconds_to_first_action"] == 90.0
|
||||
assert body["median_seconds_to_first_action"] == 90.0
|
||||
assert body["p95_seconds_to_first_action"] == 90.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handoff_claimed_but_no_action(
|
||||
client: AsyncClient, auth_headers, test_user, test_db
|
||||
):
|
||||
"""Claimed handoff with no post-claim step → counted in n_handoffs_claimed
|
||||
but not in n_handoffs_with_action; aggregates remain None."""
|
||||
user_id = PyUUID(test_user["user_data"]["id"])
|
||||
account_id = PyUUID(test_user["user_data"]["account_id"])
|
||||
claimed_at = datetime.now(timezone.utc) - timedelta(minutes=5)
|
||||
|
||||
session = await _make_session(test_db, user_id=user_id, account_id=account_id)
|
||||
await _make_handoff(
|
||||
test_db,
|
||||
session_id=session.id,
|
||||
account_id=account_id,
|
||||
user_id=user_id,
|
||||
claimed_at=claimed_at,
|
||||
claimed_by=user_id,
|
||||
)
|
||||
# Pre-claim step (created_at < claimed_at) — must NOT count.
|
||||
await _make_step(
|
||||
test_db,
|
||||
session_id=session.id,
|
||||
account_id=account_id,
|
||||
created_at=claimed_at - timedelta(seconds=30),
|
||||
)
|
||||
await test_db.commit()
|
||||
|
||||
response = await client.get(URL, headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["n_handoffs_claimed"] == 1
|
||||
assert body["n_handoffs_with_action"] == 0
|
||||
assert body["avg_seconds_to_first_action"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unclaimed_handoffs_excluded(
|
||||
client: AsyncClient, auth_headers, test_user, test_db
|
||||
):
|
||||
"""Handoffs with claimed_at IS NULL are excluded entirely."""
|
||||
user_id = PyUUID(test_user["user_data"]["id"])
|
||||
account_id = PyUUID(test_user["user_data"]["account_id"])
|
||||
|
||||
session = await _make_session(test_db, user_id=user_id, account_id=account_id)
|
||||
await _make_handoff(
|
||||
test_db,
|
||||
session_id=session.id,
|
||||
account_id=account_id,
|
||||
user_id=user_id,
|
||||
claimed_at=None,
|
||||
)
|
||||
await test_db.commit()
|
||||
|
||||
response = await client.get(URL, headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["n_handoffs_claimed"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_period_window_excludes_old_handoffs(
|
||||
client: AsyncClient, auth_headers, test_user, test_db
|
||||
):
|
||||
"""A handoff claimed >7d ago must not appear in ?period=7d."""
|
||||
user_id = PyUUID(test_user["user_data"]["id"])
|
||||
account_id = PyUUID(test_user["user_data"]["account_id"])
|
||||
|
||||
old_claimed_at = datetime.now(timezone.utc) - timedelta(days=10)
|
||||
session = await _make_session(test_db, user_id=user_id, account_id=account_id)
|
||||
await _make_handoff(
|
||||
test_db,
|
||||
session_id=session.id,
|
||||
account_id=account_id,
|
||||
user_id=user_id,
|
||||
claimed_at=old_claimed_at,
|
||||
claimed_by=user_id,
|
||||
)
|
||||
await _make_step(
|
||||
test_db,
|
||||
session_id=session.id,
|
||||
account_id=account_id,
|
||||
created_at=old_claimed_at + timedelta(seconds=60),
|
||||
)
|
||||
await test_db.commit()
|
||||
|
||||
# 7d window: excluded
|
||||
r7 = await client.get(URL, headers=auth_headers, params={"period": "7d"})
|
||||
assert r7.status_code == 200
|
||||
assert r7.json()["n_handoffs_claimed"] == 0
|
||||
|
||||
# 90d window: included
|
||||
r90 = await client.get(URL, headers=auth_headers, params={"period": "90d"})
|
||||
assert r90.status_code == 200
|
||||
assert r90.json()["n_handoffs_claimed"] == 1
|
||||
assert r90.json()["n_handoffs_with_action"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aggregate_stats_for_multiple_handoffs(
|
||||
client: AsyncClient, auth_headers, test_user, test_db
|
||||
):
|
||||
"""Three handoffs with deltas 30/60/180s → avg=90, median=60, p95≈180."""
|
||||
user_id = PyUUID(test_user["user_data"]["id"])
|
||||
account_id = PyUUID(test_user["user_data"]["account_id"])
|
||||
|
||||
base = datetime.now(timezone.utc) - timedelta(hours=3)
|
||||
deltas = [30, 60, 180]
|
||||
for i, delta in enumerate(deltas):
|
||||
s = await _make_session(test_db, user_id=user_id, account_id=account_id)
|
||||
claimed_at = base + timedelta(minutes=i * 10)
|
||||
await _make_handoff(
|
||||
test_db,
|
||||
session_id=s.id,
|
||||
account_id=account_id,
|
||||
user_id=user_id,
|
||||
claimed_at=claimed_at,
|
||||
claimed_by=user_id,
|
||||
)
|
||||
await _make_step(
|
||||
test_db,
|
||||
session_id=s.id,
|
||||
account_id=account_id,
|
||||
created_at=claimed_at + timedelta(seconds=delta),
|
||||
)
|
||||
await test_db.commit()
|
||||
|
||||
response = await client.get(URL, headers=auth_headers)
|
||||
body = response.json()
|
||||
assert response.status_code == 200
|
||||
assert body["n_handoffs_claimed"] == 3
|
||||
assert body["n_handoffs_with_action"] == 3
|
||||
assert body["avg_seconds_to_first_action"] == 90.0
|
||||
assert body["median_seconds_to_first_action"] == 60.0
|
||||
assert body["p95_seconds_to_first_action"] == 180.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_account_isolation_requesting_user_only_sees_own_account(
|
||||
client: AsyncClient, auth_headers, test_user, test_db
|
||||
):
|
||||
"""A handoff in another account must not appear in this user's response.
|
||||
|
||||
Critical: the Phase 4 RLS pattern can fail silently if account_id is wrong.
|
||||
This test would catch an account-scoped query that accidentally returned
|
||||
cross-tenant rows.
|
||||
"""
|
||||
from app.models.account import Account
|
||||
|
||||
other_account = Account(name="Other MSP", display_code="OTHER001")
|
||||
test_db.add(other_account)
|
||||
await test_db.flush()
|
||||
|
||||
other_user = User(
|
||||
email="other@example.com",
|
||||
password_hash="x",
|
||||
name="Other Tech",
|
||||
role="engineer",
|
||||
account_id=other_account.id,
|
||||
account_role="owner",
|
||||
)
|
||||
test_db.add(other_user)
|
||||
await test_db.flush()
|
||||
|
||||
s = await _make_session(
|
||||
test_db, user_id=other_user.id, account_id=other_account.id
|
||||
)
|
||||
claimed_at = datetime.now(timezone.utc) - timedelta(hours=1)
|
||||
await _make_handoff(
|
||||
test_db,
|
||||
session_id=s.id,
|
||||
account_id=other_account.id,
|
||||
user_id=other_user.id,
|
||||
claimed_at=claimed_at,
|
||||
claimed_by=other_user.id,
|
||||
)
|
||||
await _make_step(
|
||||
test_db,
|
||||
session_id=s.id,
|
||||
account_id=other_account.id,
|
||||
created_at=claimed_at + timedelta(seconds=45),
|
||||
)
|
||||
await test_db.commit()
|
||||
|
||||
response = await client.get(URL, headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
# The other account's handoff must NOT leak into this account's response.
|
||||
assert body["n_handoffs_claimed"] == 0
|
||||
assert body["n_handoffs_with_action"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_viewer_role_is_blocked(
|
||||
client: AsyncClient, test_user, auth_headers, test_db
|
||||
):
|
||||
"""Downgrade the test user to 'viewer' and confirm the endpoint 403s."""
|
||||
user_id = PyUUID(test_user["user_data"]["id"])
|
||||
user = (
|
||||
await test_db.execute(select(User).where(User.id == user_id))
|
||||
).scalar_one()
|
||||
user.account_role = "viewer"
|
||||
await test_db.commit()
|
||||
|
||||
response = await client.get(URL, headers=auth_headers)
|
||||
assert response.status_code == 403
|
||||
assert "engineer" in response.json()["detail"].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_period_rejected(client: AsyncClient, auth_headers):
|
||||
"""period=1d is not in {7d,30d,90d} — must 422."""
|
||||
response = await client.get(URL, headers=auth_headers, params={"period": "1d"})
|
||||
assert response.status_code == 422
|
||||
@@ -1,8 +1,32 @@
|
||||
"""Integration tests for HandoffManager service."""
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.models.ai_session import AISession
|
||||
from app.models.user import User
|
||||
from app.services.handoff_manager import HandoffManager
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def stub_ai_assessment():
|
||||
"""Keep handoff tests focused on handoff behavior, not external AI calls."""
|
||||
with patch.object(
|
||||
HandoffManager,
|
||||
"_generate_handoff_summary",
|
||||
new=AsyncMock(
|
||||
return_value={
|
||||
"summary_prose": "Stub escalation assessment",
|
||||
"what_we_know": [],
|
||||
"likely_cause": "Stub",
|
||||
"suggested_steps": [],
|
||||
"confidence": "medium",
|
||||
}
|
||||
),
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -75,6 +99,56 @@ async def test_create_escalate_handoff(client: AsyncClient, test_user, auth_head
|
||||
assert session.status == "escalated"
|
||||
assert session.escalation_package is not None
|
||||
assert "branch_map" in session.escalation_package or "snapshot" in session.escalation_package
|
||||
assert session.escalation_package["handoff_id"] == str(handoff.id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_escalate_handoff_does_not_wait_on_slow_ai_assessment(
|
||||
client: AsyncClient, test_user, auth_headers, test_db, monkeypatch
|
||||
):
|
||||
"""Escalate should commit a handoff even when optional AI assessment is slow."""
|
||||
session = AISession(
|
||||
user_id=test_user["user_data"]["id"],
|
||||
account_id=test_user["user_data"]["account_id"],
|
||||
session_type="guided",
|
||||
intake_type="free_text",
|
||||
intake_content={"text": "test"},
|
||||
status="active",
|
||||
confidence_tier="discovery",
|
||||
conversation_messages=[],
|
||||
)
|
||||
test_db.add(session)
|
||||
await test_db.flush()
|
||||
|
||||
async def slow_summary(self, session):
|
||||
await asyncio.sleep(0.2)
|
||||
return {"summary_prose": "too slow", "confidence": "medium"}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.handoff_manager.settings."
|
||||
"ESCALATION_AI_ASSESSMENT_TIMEOUT_SECONDS",
|
||||
0.01,
|
||||
)
|
||||
with patch.object(
|
||||
HandoffManager,
|
||||
"_generate_handoff_summary_inner",
|
||||
new=slow_summary,
|
||||
):
|
||||
manager = HandoffManager(test_db)
|
||||
handoff = await manager.create_handoff(
|
||||
session_id=session.id,
|
||||
intent="escalate",
|
||||
engineer_notes="Need senior help",
|
||||
user_id=test_user["user_data"]["id"],
|
||||
)
|
||||
|
||||
assert handoff.intent == "escalate"
|
||||
assert handoff.ai_assessment is None
|
||||
assert handoff.ai_assessment_data is None
|
||||
|
||||
await test_db.refresh(session)
|
||||
assert session.status == "escalated"
|
||||
assert session.handoff_count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -108,8 +182,399 @@ async def test_claim_session(client: AsyncClient, test_user, test_admin, auth_he
|
||||
claiming_user_id=test_admin["user_data"]["id"],
|
||||
)
|
||||
|
||||
assert claimed.claimed_by == test_admin["user_data"]["id"]
|
||||
assert str(claimed.claimed_by) == test_admin["user_data"]["id"]
|
||||
assert claimed.claimed_at is not None
|
||||
|
||||
await test_db.refresh(session)
|
||||
assert session.status == "active"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_claim_session_conflict_raises_already_claimed(
|
||||
client: AsyncClient, test_user, test_admin, auth_headers, test_db
|
||||
):
|
||||
"""Two seniors claiming simultaneously: the second raises the typed
|
||||
HandoffAlreadyClaimedError carrying the winner's identity. Without this
|
||||
guard both calls would silently overwrite claimed_by — the locked
|
||||
race-condition story depends on a real conflict response."""
|
||||
from app.services.handoff_manager import (
|
||||
HandoffAlreadyClaimedError,
|
||||
HandoffManager,
|
||||
)
|
||||
|
||||
session = AISession(
|
||||
user_id=test_user["user_data"]["id"],
|
||||
account_id=test_user["user_data"]["account_id"],
|
||||
session_type="guided",
|
||||
intake_type="free_text",
|
||||
intake_content={"text": "test"},
|
||||
status="active",
|
||||
confidence_tier="discovery",
|
||||
conversation_messages=[],
|
||||
)
|
||||
test_db.add(session)
|
||||
loser = User(
|
||||
email="race-loser@example.com",
|
||||
password_hash="x",
|
||||
name="Race Loser",
|
||||
role="engineer",
|
||||
account_id=test_user["user_data"]["account_id"],
|
||||
account_role="engineer",
|
||||
)
|
||||
test_db.add(loser)
|
||||
await test_db.flush()
|
||||
|
||||
manager = HandoffManager(test_db)
|
||||
handoff = await manager.create_handoff(
|
||||
session_id=session.id,
|
||||
intent="escalate",
|
||||
engineer_notes="Need help",
|
||||
user_id=test_user["user_data"]["id"],
|
||||
)
|
||||
|
||||
# First claim — admin wins.
|
||||
await manager.claim_session(
|
||||
handoff_id=handoff.id,
|
||||
claiming_user_id=test_admin["user_data"]["id"],
|
||||
)
|
||||
|
||||
# Second claim by a different user — standing in for the other senior who
|
||||
# lost the race.
|
||||
with pytest.raises(HandoffAlreadyClaimedError) as exc_info:
|
||||
await manager.claim_session(
|
||||
handoff_id=handoff.id,
|
||||
claiming_user_id=loser.id,
|
||||
)
|
||||
|
||||
err = exc_info.value
|
||||
assert str(err.claimed_by_id) == test_admin["user_data"]["id"]
|
||||
assert err.claimed_by_name # populated from User.name
|
||||
assert err.claimed_at is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_claim_session_idempotent_for_same_user(
|
||||
client: AsyncClient, test_user, test_admin, auth_headers, test_db
|
||||
):
|
||||
"""A re-claim by the user who already won is a no-op, not a conflict.
|
||||
Defends against double-clicks / network retries on the loser-side toast."""
|
||||
session = AISession(
|
||||
user_id=test_user["user_data"]["id"],
|
||||
account_id=test_user["user_data"]["account_id"],
|
||||
session_type="guided",
|
||||
intake_type="free_text",
|
||||
intake_content={"text": "test"},
|
||||
status="active",
|
||||
confidence_tier="discovery",
|
||||
conversation_messages=[],
|
||||
)
|
||||
test_db.add(session)
|
||||
await test_db.flush()
|
||||
|
||||
manager = HandoffManager(test_db)
|
||||
handoff = await manager.create_handoff(
|
||||
session_id=session.id,
|
||||
intent="escalate",
|
||||
engineer_notes="Need help",
|
||||
user_id=test_user["user_data"]["id"],
|
||||
)
|
||||
|
||||
first = await manager.claim_session(
|
||||
handoff_id=handoff.id,
|
||||
claiming_user_id=test_admin["user_data"]["id"],
|
||||
)
|
||||
second = await manager.claim_session(
|
||||
handoff_id=handoff.id,
|
||||
claiming_user_id=test_admin["user_data"]["id"],
|
||||
)
|
||||
|
||||
assert str(first.claimed_by) == str(second.claimed_by) == test_admin["user_data"]["id"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_claim_session_rejects_self_claim(
|
||||
client: AsyncClient, test_user, auth_headers, test_db
|
||||
):
|
||||
"""The engineer who escalated a session cannot pick up their own handoff."""
|
||||
session = AISession(
|
||||
user_id=test_user["user_data"]["id"],
|
||||
account_id=test_user["user_data"]["account_id"],
|
||||
session_type="guided",
|
||||
intake_type="free_text",
|
||||
intake_content={"text": "test"},
|
||||
status="active",
|
||||
confidence_tier="discovery",
|
||||
conversation_messages=[],
|
||||
)
|
||||
test_db.add(session)
|
||||
await test_db.flush()
|
||||
|
||||
manager = HandoffManager(test_db)
|
||||
handoff = await manager.create_handoff(
|
||||
session_id=session.id,
|
||||
intent="escalate",
|
||||
engineer_notes="Need help",
|
||||
user_id=test_user["user_data"]["id"],
|
||||
)
|
||||
|
||||
with pytest.raises(PermissionError):
|
||||
await manager.claim_session(
|
||||
handoff_id=handoff.id,
|
||||
claiming_user_id=test_user["user_data"]["id"],
|
||||
)
|
||||
|
||||
|
||||
# ─── Notification dispatch ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_emails_engineer_recipients_in_account(
|
||||
client: AsyncClient, test_user, auth_headers, test_db
|
||||
):
|
||||
"""dispatch_escalation_notifications emails every engineer/admin in the
|
||||
account except the escalator."""
|
||||
# Add a second user (engineer role) in the same account.
|
||||
teammate = User(
|
||||
email="teammate@example.com",
|
||||
password_hash="x",
|
||||
name="Teammate",
|
||||
role="engineer",
|
||||
account_id=test_user["user_data"]["account_id"],
|
||||
account_role="engineer",
|
||||
)
|
||||
test_db.add(teammate)
|
||||
await test_db.flush()
|
||||
|
||||
# Add a viewer-role user — must NOT receive a notification.
|
||||
viewer = User(
|
||||
email="viewer@example.com",
|
||||
password_hash="x",
|
||||
name="Viewer",
|
||||
role="engineer",
|
||||
account_id=test_user["user_data"]["account_id"],
|
||||
account_role="viewer",
|
||||
)
|
||||
test_db.add(viewer)
|
||||
await test_db.flush()
|
||||
|
||||
session = AISession(
|
||||
user_id=test_user["user_data"]["id"],
|
||||
account_id=test_user["user_data"]["account_id"],
|
||||
session_type="guided",
|
||||
intake_type="free_text",
|
||||
intake_content={"text": "vpn down"},
|
||||
problem_summary="VPN won't connect after Win update",
|
||||
status="active",
|
||||
confidence_tier="discovery",
|
||||
conversation_messages=[],
|
||||
)
|
||||
test_db.add(session)
|
||||
await test_db.commit()
|
||||
|
||||
manager = HandoffManager(test_db)
|
||||
handoff = await manager.create_handoff(
|
||||
session_id=session.id,
|
||||
intent="escalate",
|
||||
engineer_notes="Stuck on auth handshake",
|
||||
user_id=test_user["user_data"]["id"],
|
||||
)
|
||||
await test_db.commit()
|
||||
|
||||
with patch(
|
||||
"app.services.handoff_manager.EmailService.send_notification_email",
|
||||
new=AsyncMock(return_value=True),
|
||||
) as send:
|
||||
sent = await manager.dispatch_escalation_notifications(handoff)
|
||||
|
||||
assert sent == 1 # only the engineer-role teammate
|
||||
recipients = {call.kwargs["to_email"] for call in send.call_args_list}
|
||||
assert recipients == {"teammate@example.com"}
|
||||
assert viewer.email not in recipients
|
||||
assert test_user["email"] not in recipients # not self-notified
|
||||
|
||||
title = send.call_args_list[0].kwargs["title"]
|
||||
assert "VPN won't connect after Win update" in title
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_skipped_for_park_intent(
|
||||
client: AsyncClient, test_user, auth_headers, test_db
|
||||
):
|
||||
"""park-intent handoffs are private (waiting for client logs etc) — no
|
||||
team-wide email."""
|
||||
session = AISession(
|
||||
user_id=test_user["user_data"]["id"],
|
||||
account_id=test_user["user_data"]["account_id"],
|
||||
session_type="guided",
|
||||
intake_type="free_text",
|
||||
intake_content={"text": "x"},
|
||||
status="active",
|
||||
confidence_tier="discovery",
|
||||
conversation_messages=[],
|
||||
)
|
||||
test_db.add(session)
|
||||
await test_db.commit()
|
||||
|
||||
manager = HandoffManager(test_db)
|
||||
handoff = await manager.create_handoff(
|
||||
session_id=session.id,
|
||||
intent="park",
|
||||
engineer_notes="waiting on customer",
|
||||
user_id=test_user["user_data"]["id"],
|
||||
)
|
||||
await test_db.commit()
|
||||
|
||||
with patch(
|
||||
"app.services.handoff_manager.EmailService.send_notification_email",
|
||||
new=AsyncMock(return_value=True),
|
||||
) as send:
|
||||
sent = await manager.dispatch_escalation_notifications(handoff)
|
||||
|
||||
assert sent == 0
|
||||
assert send.call_count == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_graceful_degradation_when_email_raises(
|
||||
client: AsyncClient, test_user, auth_headers, test_db
|
||||
):
|
||||
"""If the email service raises (auth misconfig, network, etc.), dispatch
|
||||
must NOT raise. Handoff creation has already committed; emailing is
|
||||
best-effort. Codex-flagged regression."""
|
||||
teammate = User(
|
||||
email="t@example.com",
|
||||
password_hash="x",
|
||||
name="T",
|
||||
role="engineer",
|
||||
account_id=test_user["user_data"]["account_id"],
|
||||
account_role="engineer",
|
||||
)
|
||||
test_db.add(teammate)
|
||||
await test_db.flush()
|
||||
|
||||
session = AISession(
|
||||
user_id=test_user["user_data"]["id"],
|
||||
account_id=test_user["user_data"]["account_id"],
|
||||
session_type="guided",
|
||||
intake_type="free_text",
|
||||
intake_content={"text": "x"},
|
||||
status="active",
|
||||
confidence_tier="discovery",
|
||||
conversation_messages=[],
|
||||
)
|
||||
test_db.add(session)
|
||||
await test_db.commit()
|
||||
|
||||
manager = HandoffManager(test_db)
|
||||
handoff = await manager.create_handoff(
|
||||
session_id=session.id,
|
||||
intent="escalate",
|
||||
engineer_notes="help",
|
||||
user_id=test_user["user_data"]["id"],
|
||||
)
|
||||
await test_db.commit()
|
||||
|
||||
with patch(
|
||||
"app.services.handoff_manager.EmailService.send_notification_email",
|
||||
new=AsyncMock(side_effect=RuntimeError("SMTP down")),
|
||||
):
|
||||
# Must not raise.
|
||||
sent = await manager.dispatch_escalation_notifications(handoff)
|
||||
assert sent == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_publishes_to_escalation_bus(
|
||||
client: AsyncClient, test_user, auth_headers, test_db
|
||||
):
|
||||
"""dispatch_escalation_notifications puts an event on the in-memory bus
|
||||
so connected SSE subscribers see live arrivals."""
|
||||
from app.core.escalation_bus import bus as escalation_bus
|
||||
|
||||
session = AISession(
|
||||
user_id=test_user["user_data"]["id"],
|
||||
account_id=test_user["user_data"]["account_id"],
|
||||
session_type="guided",
|
||||
intake_type="free_text",
|
||||
intake_content={"text": "x"},
|
||||
problem_summary="VPN down",
|
||||
status="active",
|
||||
confidence_tier="discovery",
|
||||
conversation_messages=[],
|
||||
)
|
||||
test_db.add(session)
|
||||
await test_db.commit()
|
||||
|
||||
manager = HandoffManager(test_db)
|
||||
handoff = await manager.create_handoff(
|
||||
session_id=session.id,
|
||||
intent="escalate",
|
||||
engineer_notes="please help",
|
||||
user_id=test_user["user_data"]["id"],
|
||||
)
|
||||
await test_db.commit()
|
||||
|
||||
from uuid import UUID as PyUUID
|
||||
account_id = PyUUID(test_user["user_data"]["account_id"])
|
||||
|
||||
queue = await escalation_bus.subscribe(account_id)
|
||||
try:
|
||||
with patch(
|
||||
"app.services.handoff_manager.EmailService.send_notification_email",
|
||||
new=AsyncMock(return_value=True),
|
||||
):
|
||||
await manager.dispatch_escalation_notifications(handoff)
|
||||
|
||||
import asyncio
|
||||
event = await asyncio.wait_for(queue.get(), timeout=1.0)
|
||||
assert event["type"] == "handoff_created"
|
||||
assert event["handoff_id"] == str(handoff.id)
|
||||
assert event["session_id"] == str(session.id)
|
||||
assert event["priority"] == "normal"
|
||||
finally:
|
||||
await escalation_bus.unsubscribe(account_id, queue)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_handoff_endpoint_dispatches_on_escalate(
|
||||
client: AsyncClient, test_user, auth_headers, test_db
|
||||
):
|
||||
"""End-to-end: POST /handoff with intent=escalate triggers
|
||||
dispatch_escalation_notifications after commit. Verifies the wiring in
|
||||
the endpoint, not just the manager method."""
|
||||
teammate = User(
|
||||
email="t2@example.com",
|
||||
password_hash="x",
|
||||
name="T2",
|
||||
role="engineer",
|
||||
account_id=test_user["user_data"]["account_id"],
|
||||
account_role="engineer",
|
||||
)
|
||||
test_db.add(teammate)
|
||||
await test_db.commit()
|
||||
|
||||
session = AISession(
|
||||
user_id=test_user["user_data"]["id"],
|
||||
account_id=test_user["user_data"]["account_id"],
|
||||
session_type="guided",
|
||||
intake_type="free_text",
|
||||
intake_content={"text": "x"},
|
||||
status="active",
|
||||
confidence_tier="discovery",
|
||||
conversation_messages=[],
|
||||
)
|
||||
test_db.add(session)
|
||||
await test_db.commit()
|
||||
|
||||
with patch(
|
||||
"app.services.handoff_manager.EmailService.send_notification_email",
|
||||
new=AsyncMock(return_value=True),
|
||||
) as send:
|
||||
resp = await client.post(
|
||||
f"/api/v1/ai-sessions/{session.id}/handoff",
|
||||
headers=auth_headers,
|
||||
json={"intent": "escalate", "engineer_notes": "Need help"},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
assert send.call_count == 1
|
||||
assert send.call_args.kwargs["to_email"] == "t2@example.com"
|
||||
|
||||
@@ -1,8 +1,41 @@
|
||||
"""API endpoint tests for session handoffs."""
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from uuid import UUID as PyUUID
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.endpoints.session_handoffs import stream_escalations
|
||||
from app.core.escalation_bus import bus as escalation_bus
|
||||
from app.models.ai_session import AISession
|
||||
from app.models.session_handoff import SessionHandoff
|
||||
from app.models.user import User
|
||||
from app.services.handoff_manager import HandoffManager
|
||||
|
||||
|
||||
class _ConnectedRequest:
|
||||
async def is_disconnected(self) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def stub_ai_assessment():
|
||||
"""Endpoint tests should not wait on the external AI assessment path."""
|
||||
with patch.object(
|
||||
HandoffManager,
|
||||
"_generate_handoff_summary",
|
||||
new=AsyncMock(
|
||||
return_value={
|
||||
"summary_prose": "Stub escalation assessment",
|
||||
"what_we_know": [],
|
||||
"likely_cause": "Stub",
|
||||
"suggested_steps": [],
|
||||
"confidence": "medium",
|
||||
}
|
||||
),
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -58,3 +91,234 @@ async def test_get_queue(client: AsyncClient, test_user, auth_headers, test_db):
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data) >= 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_claim_blocked_for_viewer_role(
|
||||
client: AsyncClient, test_user, auth_headers, test_db
|
||||
):
|
||||
"""POST /handoffs/{id}/claim must 403 for viewer-role users.
|
||||
|
||||
Codex review flagged the missing role gate as wedge-relevant: the
|
||||
race-condition story (two seniors clicking Pick Up simultaneously)
|
||||
requires auth gating for audit integrity. Viewers must not be able
|
||||
to claim escalations.
|
||||
"""
|
||||
# Create a session + handoff as the engineer-role test_user (default = owner).
|
||||
session = AISession(
|
||||
user_id=test_user["user_data"]["id"],
|
||||
account_id=test_user["user_data"]["account_id"],
|
||||
session_type="guided",
|
||||
intake_type="free_text",
|
||||
intake_content={"text": "test"},
|
||||
status="active",
|
||||
confidence_tier="discovery",
|
||||
conversation_messages=[],
|
||||
)
|
||||
test_db.add(session)
|
||||
await test_db.commit()
|
||||
|
||||
create_resp = await client.post(
|
||||
f"/api/v1/ai-sessions/{session.id}/handoff",
|
||||
headers=auth_headers,
|
||||
json={"intent": "escalate", "engineer_notes": "Need help"},
|
||||
)
|
||||
assert create_resp.status_code == 201
|
||||
handoff_id = create_resp.json()["id"]
|
||||
|
||||
# Downgrade the user to viewer.
|
||||
user_id = PyUUID(test_user["user_data"]["id"])
|
||||
user = (
|
||||
await test_db.execute(select(User).where(User.id == user_id))
|
||||
).scalar_one()
|
||||
user.account_role = "viewer"
|
||||
await test_db.commit()
|
||||
|
||||
claim_resp = await client.post(
|
||||
f"/api/v1/ai-sessions/{session.id}/handoffs/{handoff_id}/claim",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert claim_resp.status_code == 403
|
||||
assert "engineer" in claim_resp.json()["detail"].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_escalations_stream_blocked_for_viewer(
|
||||
client: AsyncClient, test_user, auth_headers, test_db
|
||||
):
|
||||
"""SSE stream is role-gated to engineer-or-admin (matches queue/claim)."""
|
||||
user_id = PyUUID(test_user["user_data"]["id"])
|
||||
user = (
|
||||
await test_db.execute(select(User).where(User.id == user_id))
|
||||
).scalar_one()
|
||||
user.account_role = "viewer"
|
||||
await test_db.commit()
|
||||
|
||||
resp = await client.get(
|
||||
"/api/v1/ai-sessions/escalations/stream", headers=auth_headers
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_escalations_stream_returns_sse_content_type(
|
||||
client: AsyncClient, test_user, auth_headers, test_db
|
||||
):
|
||||
"""Engineer/owner can open the SSE stream and gets text/event-stream
|
||||
plus an initial `ready` event. Read just enough bytes to confirm the
|
||||
handshake — the full pub/sub flow is covered by the bus + dispatcher tests
|
||||
separately.
|
||||
|
||||
Do not use `client.stream()` here: HTTPX's ASGITransport buffers the whole
|
||||
response body before returning, which hangs forever for an infinite SSE
|
||||
stream.
|
||||
"""
|
||||
user_id = PyUUID(test_user["user_data"]["id"])
|
||||
user = (
|
||||
await test_db.execute(select(User).where(User.id == user_id))
|
||||
).scalar_one()
|
||||
|
||||
resp = await stream_escalations(_ConnectedRequest(), current_user=user)
|
||||
assert resp.media_type == "text/event-stream"
|
||||
|
||||
body_iterator = resp.body_iterator
|
||||
try:
|
||||
first = await anext(body_iterator)
|
||||
finally:
|
||||
await body_iterator.aclose()
|
||||
|
||||
assert "event: ready" in first
|
||||
assert '"account_id"' in first
|
||||
assert escalation_bus.subscriber_count(user.account_id) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_claim_allowed_for_engineer_role(
|
||||
client: AsyncClient, test_user, auth_headers, test_db
|
||||
):
|
||||
"""POST /handoffs/{id}/claim succeeds for engineer-or-admin roles."""
|
||||
original_engineer = User(
|
||||
email="original-engineer@example.com",
|
||||
password_hash="x",
|
||||
name="Original Engineer",
|
||||
role="engineer",
|
||||
account_id=test_user["user_data"]["account_id"],
|
||||
account_role="engineer",
|
||||
)
|
||||
test_db.add(original_engineer)
|
||||
await test_db.flush()
|
||||
|
||||
session = AISession(
|
||||
user_id=original_engineer.id,
|
||||
account_id=test_user["user_data"]["account_id"],
|
||||
session_type="guided",
|
||||
intake_type="free_text",
|
||||
intake_content={"text": "test"},
|
||||
status="active",
|
||||
confidence_tier="discovery",
|
||||
conversation_messages=[],
|
||||
)
|
||||
test_db.add(session)
|
||||
await test_db.flush()
|
||||
|
||||
handoff = SessionHandoff(
|
||||
session_id=session.id,
|
||||
account_id=test_user["user_data"]["account_id"],
|
||||
handed_off_by=original_engineer.id,
|
||||
intent="escalate",
|
||||
snapshot={"problem_summary": "test"},
|
||||
engineer_notes="Need help",
|
||||
)
|
||||
test_db.add(handoff)
|
||||
await test_db.commit()
|
||||
|
||||
# Default test_user role is "owner", which passes engineer-or-admin.
|
||||
claim_resp = await client.post(
|
||||
f"/api/v1/ai-sessions/{session.id}/handoffs/{handoff.id}/claim",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert claim_resp.status_code == 200
|
||||
assert claim_resp.json()["claimed_by"] == test_user["user_data"]["id"]
|
||||
assert claim_resp.json()["claimed_at"] is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_claim_rejects_self_claim(
|
||||
client: AsyncClient, test_user, auth_headers, test_db
|
||||
):
|
||||
"""POST /handoffs/{id}/claim returns 403 for the original escalator."""
|
||||
session = AISession(
|
||||
user_id=test_user["user_data"]["id"],
|
||||
account_id=test_user["user_data"]["account_id"],
|
||||
session_type="guided",
|
||||
intake_type="free_text",
|
||||
intake_content={"text": "test"},
|
||||
status="escalated",
|
||||
confidence_tier="discovery",
|
||||
conversation_messages=[],
|
||||
)
|
||||
test_db.add(session)
|
||||
await test_db.flush()
|
||||
|
||||
handoff = SessionHandoff(
|
||||
session_id=session.id,
|
||||
account_id=test_user["user_data"]["account_id"],
|
||||
handed_off_by=test_user["user_data"]["id"],
|
||||
intent="escalate",
|
||||
snapshot={"problem_summary": "test"},
|
||||
engineer_notes="Need help",
|
||||
)
|
||||
test_db.add(handoff)
|
||||
await test_db.commit()
|
||||
|
||||
claim_resp = await client.post(
|
||||
f"/api/v1/ai-sessions/{session.id}/handoffs/{handoff.id}/claim",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert claim_resp.status_code == 403
|
||||
assert "own handoff" in claim_resp.json()["detail"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_escalation_queue_excludes_own_escalations(
|
||||
client: AsyncClient, test_user, auth_headers, test_db
|
||||
):
|
||||
"""The post-escalation dashboard queue should not show your own handoff."""
|
||||
own_session = AISession(
|
||||
user_id=test_user["user_data"]["id"],
|
||||
account_id=test_user["user_data"]["account_id"],
|
||||
session_type="chat",
|
||||
intake_type="free_text",
|
||||
intake_content={"text": "own"},
|
||||
status="escalated",
|
||||
confidence_tier="discovery",
|
||||
conversation_messages=[],
|
||||
)
|
||||
other_engineer = User(
|
||||
email="other-engineer@example.com",
|
||||
password_hash="x",
|
||||
name="Other Engineer",
|
||||
role="engineer",
|
||||
account_id=test_user["user_data"]["account_id"],
|
||||
account_role="engineer",
|
||||
)
|
||||
test_db.add_all([own_session, other_engineer])
|
||||
await test_db.flush()
|
||||
other_session = AISession(
|
||||
user_id=other_engineer.id,
|
||||
account_id=test_user["user_data"]["account_id"],
|
||||
session_type="chat",
|
||||
intake_type="free_text",
|
||||
intake_content={"text": "other"},
|
||||
status="escalated",
|
||||
confidence_tier="discovery",
|
||||
conversation_messages=[],
|
||||
)
|
||||
test_db.add(other_session)
|
||||
await test_db.commit()
|
||||
|
||||
resp = await client.get("/api/v1/ai-sessions/escalation-queue", headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
ids = {item["id"] for item in resp.json()}
|
||||
assert str(own_session.id) not in ids
|
||||
assert str(other_session.id) in ids
|
||||
|
||||
494
docs/plans/2026-04-27-escalation-mode-wedge-design.md
Normal file
494
docs/plans/2026-04-27-escalation-mode-wedge-design.md
Normal file
@@ -0,0 +1,494 @@
|
||||
# Design: ResolutionFlow GTM — Escalation-Mode-First Wedge
|
||||
|
||||
Generated by /office-hours on 2026-04-26
|
||||
Branch: main
|
||||
Repo: chihlasm/resolutionflow
|
||||
Status: APPROVED
|
||||
Mode: Startup
|
||||
|
||||
## Problem Statement
|
||||
|
||||
ResolutionFlow is a multi-tenant SaaS troubleshooting platform for MSPs, currently
|
||||
in Go-to-Market Validation (pre-PMF). The backend is feature-complete (55+ endpoints,
|
||||
100+ tests, FlowPilot telemetry baseline accruing). The product has users but no
|
||||
paying customers.
|
||||
|
||||
The blocker is not engineering completeness. The blocker is the absence of a sharp
|
||||
GTM story tied to a number a buyer can verify. The session reframed the wedge twice
|
||||
before landing on the real one.
|
||||
|
||||
**What ResolutionFlow actually is:** the structuring layer between conversational AI
|
||||
and the way MSP techs work tickets. AI is great at producing answers; it is bad at
|
||||
producing workflow-shaped output. ResolutionFlow gives the tech the AI they already
|
||||
trust (Claude/GPT) but organizes the output into actionable structured steps,
|
||||
records the session, captures customer-specific context, and turns the result into
|
||||
PSA-formatted ticket notes — and optionally a runbook — without the tech writing
|
||||
anything.
|
||||
|
||||
**Positioning line:** "the senior engineer looking over your shoulder."
|
||||
|
||||
## Demand Evidence
|
||||
|
||||
The founder is the first user. Senior Systems Engineer at an MSP, losing ~20
|
||||
hours/week to cross-domain interruptions (systems engineer pulled into networking
|
||||
problems and vice versa). At least 4 interruptions per day, with the time cost
|
||||
concentrated in the gap between AI-conversation output and MSP-ticket workflow.
|
||||
|
||||
This is solving-your-own-problem demand evidence — strongest possible signal at
|
||||
this stage. The 20 hrs/week figure is the founder's own time, not a hypothetical.
|
||||
Every MSP shop with a senior tech and a junior tech has a version of this problem.
|
||||
|
||||
Telemetry signal (Phase 0.5 baseline accruing): captured flows pile up but are not
|
||||
being re-used. This says capture works, retrieval doesn't — which means the
|
||||
"hours-saved-via-re-use" number isn't yet generatable from existing data. The
|
||||
GTM-grade ROI story needs a different metric until re-use lands: minutes recovered
|
||||
per escalation, generated by Approach A below.
|
||||
|
||||
## Status Quo
|
||||
|
||||
MSP techs today resolve tickets via three workarounds:
|
||||
|
||||
1. **AI in a tab.** Junior tech opens Claude or ChatGPT, pastes the problem, gets a
|
||||
wall of prose, parses it into action items in their head, executes, repeats. AI
|
||||
does the diagnostic work. The tech does all the structure-extraction and
|
||||
ticket-note-writing afterward.
|
||||
|
||||
2. **Tribal knowledge.** Junior tech pings senior in Slack. Senior tech is
|
||||
interrupted (4+ times/day per the founder's own data). Context handoff is verbal
|
||||
and lossy.
|
||||
|
||||
3. **Stale runbooks.** Half-maintained Notion / IT Glue / SharePoint pages that
|
||||
nobody trusts because they're 18 months out of date and don't match the current
|
||||
customer environment.
|
||||
|
||||
The cost of these workarounds for the founder personally: ~20 hours per week of
|
||||
senior-tech time lost. For a 5-tech MSP, the equivalent is 1 full FTE worth of
|
||||
senior-engineer hours leaking into context-switching and tab-hopping.
|
||||
|
||||
## Target User & Narrowest Wedge
|
||||
|
||||
**Target user:** Senior Systems Engineer at a small-to-mid MSP (5-20 techs). The
|
||||
founder is exemplar #1. Buying authority is shared between senior tech (champion)
|
||||
and MSP owner (signs the check).
|
||||
|
||||
**Narrowest paid wedge:** Escalation Mode. Single sharp feature. When a junior tech
|
||||
escalates a ticket they were working in FlowPilot, the senior tech opens the ticket
|
||||
and sees the entire structured session state — every step the junior tried, every
|
||||
dead end, every command output — instead of starting with "tell me what you tried"
|
||||
for five minutes.
|
||||
|
||||
Why this is the wedge:
|
||||
|
||||
- **Two metrics, not one** (revised after /codex review 2026-04-27):
|
||||
- **Manual baseline** (the Assignment, weeks 0-2): senior tech stopwatches the
|
||||
next 5 escalations. T1 (first diagnostic action) − T0 (open ticket) under
|
||||
today's verbal-handoff workflow. This is the "what you currently lose" number.
|
||||
- **In-product metric** (telemetry, week 3+): time-to-first-action after claim,
|
||||
derived from `ai_session_step` rows where `created_at > SessionHandoff.claimed_at`
|
||||
AND `user_id = SessionHandoff.claimed_by`. This is the "what it is now with
|
||||
structured handoff" number.
|
||||
- **The savings claim** = manual baseline − in-product metric. Quote both
|
||||
explicitly in pilot conversations. Do NOT roll the in-product number alone
|
||||
into "minutes recovered" — that's an apples-to-oranges miscount Codex caught
|
||||
in the cross-model review.
|
||||
- **Single-feature demo:** a 2-minute Loom shows the magic moment — junior hits
|
||||
escalate, senior window opens with full structured context. No theory required.
|
||||
- **Cross-buyer story:** sells to senior tech (less interruption) AND owner (junior
|
||||
techs resolve faster, take more accounts).
|
||||
- **Hours-saved math is simple:** 4-5 minutes per escalation × 15-30 escalations
|
||||
per week per senior tech = 1-2 hours/week recovered per senior. At $80-150/hr
|
||||
fully-loaded senior tech cost, the tool pays for itself with one customer.
|
||||
|
||||
## Constraints
|
||||
|
||||
- **One-founder shop.** Cannot run three concurrent product narratives. Sequence
|
||||
matters more than scope.
|
||||
- **Pre-PMF runway implied.** 4-8 week build cycles before talking to a buyer are
|
||||
expensive. Approach A's 1-2 week timeline is the binding constraint.
|
||||
- **Existing architecture is mostly aligned.** FlowPilot, unified_chat_service,
|
||||
FlowProposal, ConnectWise PSA integration — most of the pieces exist. Risk is
|
||||
positioning and UX, not capability.
|
||||
- **PSA copilot competition is real.** ConnectWise / Autotask / Halo are racing to
|
||||
ship AI features. The wedge has to be sharp because we lose on distribution.
|
||||
|
||||
## Premises
|
||||
|
||||
The five load-bearing claims this design rests on, all confirmed in session:
|
||||
|
||||
1. **Diagnostic AI is commoditized.** ResolutionFlow does not compete on
|
||||
"AI solves the ticket faster." That race is over. ChatGPT/Claude already won.
|
||||
2. **The structuring layer is the wedge.** AI conversational output is too dense
|
||||
and unstructured for active troubleshooting. ResolutionFlow's value is
|
||||
organizing that output into actionable, separable, recorded steps.
|
||||
3. **Escalation context is the killer feature.** "Junior hits escalate, senior gets
|
||||
full structured context in 30 seconds instead of 5 minutes" is the sharpest
|
||||
demoable moment in the entire product surface.
|
||||
4. **First paying customer is bottom-up, prosumer-flavored.** Senior tech at a
|
||||
small MSP, $20-50/seat/month, monthly billing. Owner-targeted enterprise
|
||||
pricing waits until 5+ paying shops establish baseline ROI numbers.
|
||||
5. **Distribution is MSP communities, not paid SaaS ads.** r/msp, MSPGeek, RocketMSP,
|
||||
PSA marketplace listings. The channel matches the buyer.
|
||||
|
||||
## Approaches Considered
|
||||
|
||||
### Approach A: Escalation Mode first (REDUCED SCOPE per /plan-eng-review)
|
||||
|
||||
Lead the GTM with the killer feature. Polish the escalate-with-context handoff:
|
||||
junior tech mid-session hits escalate, senior tech window opens with full
|
||||
structured session state. 2-min demo Loom. Pilot with **3 MSPs** in the founder's
|
||||
network (capped at 3 to preserve build capacity for B). Metric: minutes recovered
|
||||
per escalation.
|
||||
|
||||
**SCOPE REDUCTION (2026-04-27 eng review):** ~80% of Approach A is already built.
|
||||
The original 2-3 week estimate assumed greenfield. Codebase audit confirms:
|
||||
|
||||
| What the doc said "build" | What actually exists |
|
||||
|---|---|
|
||||
| Session-state serialization | `ai_session.escalation_package` (JSONB), `SessionHandoff.snapshot` |
|
||||
| Senior-tech inbox | [EscalationQueuePage.tsx](frontend/src/pages/EscalationQueuePage.tsx) + [EscalationQueue.tsx](frontend/src/components/flowpilot/EscalationQueue.tsx) |
|
||||
| Claim workflow | [handoff_manager.py:123 claim_session()](backend/app/services/handoff_manager.py#L123) |
|
||||
| API surface | [session_handoffs.py](backend/app/api/endpoints/session_handoffs.py) — POST /handoff, /claim, GET queue |
|
||||
| AI assessment for senior | `_generate_ai_assessment()` in handoff_manager |
|
||||
| PSA round-trip | `escalation_package_markdown`, `escalation_package_external_id` |
|
||||
|
||||
**Real engineering scope (~6-9 days):**
|
||||
|
||||
1. **Notification dual-path** (4-5 days). `notification_sent` flag is a dead column —
|
||||
never written. Wire two channels in `handoff_manager.create_handoff`:
|
||||
- **Email** (existing `EmailService.send_notification_email`) — handles offline seniors.
|
||||
- **WebSocket / SSE push** to the EscalationQueue for live demo magic moment.
|
||||
- Set `notification_sent=true` after dispatch confirmation.
|
||||
- Graceful degradation: handoff still created if notification raises (regression test required).
|
||||
|
||||
2. **Hero metric endpoint** (~2 hours). New `GET /api/v1/analytics/escalation-metrics`,
|
||||
account-scoped, role-gated to `require_engineer_or_admin`. Computes
|
||||
*minutes recovered per escalation* by querying:
|
||||
```
|
||||
ai_session_step.created_at (first row by senior_tech_user_id where created_at > SessionHandoff.claimed_at)
|
||||
minus
|
||||
SessionHandoff.claimed_at
|
||||
```
|
||||
Returns a rolling-30-day average per account. No schema change.
|
||||
|
||||
3. **UX polish on EscalationQueue + receiving-engineer view** (2-3 days). Confirm the
|
||||
magic-moment screen lands when senior clicks claim. Add an unread indicator on
|
||||
the queue. Wire optimistic insert when SSE event arrives.
|
||||
|
||||
4. **Loom + landing page copy** (1-2 days). Non-engineering. Outside this plan's scope
|
||||
but required for the GTM in week 3.
|
||||
|
||||
**Test plan:** 100% coverage of new paths — 13 tests including 4 e2e and 1 regression
|
||||
(graceful-degradation when notification dispatch raises). Test plan artifact at
|
||||
`~/.gstack/projects/chihlasm-resolutionflow/abc-main-eng-review-test-plan-20260427-000000.md`.
|
||||
|
||||
**Risk:** Low. Single feature, single metric, architecture-aligned. The dual-path
|
||||
notification is the only mildly novel surface; both halves use existing infra.
|
||||
|
||||
**Reuses:** `services/handoff_manager.py`, `services/escalation_package_generator.py`,
|
||||
`models/session_handoff.py`, `models/ai_session.py`, `services/notification_service.py`,
|
||||
`models/notification_log.py`, EmailService, EscalationQueuePage + EscalationQueue.
|
||||
|
||||
### UI Specifications (locked by /plan-design-review 2026-04-27)
|
||||
|
||||
**Magic-moment screen** (new, after Pick Up click): dedicated handoff-context view that
|
||||
loads BEFORE the regular FlowPilot session view, then dissolves on first senior action.
|
||||
Four sections, single frame:
|
||||
|
||||
1. **Problem summary** (top, 2-3 lines): junior's framing. Bricolage Grotesque h2.
|
||||
2. **What's been tried** (left or middle column): structured list of `dead_ends_flagged[]`
|
||||
and `steps_attempted[]` from `escalation_package` JSONB. Card-flat surface, IBM Plex.
|
||||
3. **AI assessment** (right column): `ai_assessment_data` rendered as 3 fields —
|
||||
`likely_cause`, `suggested_steps[]`, `confidence`. accent-dim badge for confidence.
|
||||
4. **Start here** (primary CTA, electric-blue, ≥44px touch target): opens FlowPilot
|
||||
session at the most-likely-next-step. Senior typing or clicking anywhere triggers
|
||||
200ms fade-out and FlowPilot view fades in. Re-openable via "Show handoff context"
|
||||
ghost button in FlowPilot toolbar.
|
||||
|
||||
**Hero metric ("minutes recovered per escalation"):** lives in TWO places:
|
||||
- **Queue stat-card** (above EscalationQueue list on /escalations): compact, "X.X hrs
|
||||
saved this month" + "click for details" affordance. Refreshes on queue load.
|
||||
- **Dedicated `/analytics/escalations` page** (owner-facing): trend chart (4-week
|
||||
rolling), per-tech breakdown, per-problem-domain segmentation. Engineer-or-admin
|
||||
role-gated.
|
||||
|
||||
**Real-time arrival visual** (when WebSocket pushes a new escalation):
|
||||
- New card slides in from above the list, 200ms ease-out CSS transition.
|
||||
- Browser tab title prefixes with " (1) " / " (N) " when tab is backgrounded; clears
|
||||
on focus.
|
||||
- No sound. MUST respect `prefers-reduced-motion: reduce` (slide-in collapses to
|
||||
instant fade-in).
|
||||
|
||||
**Unread state:** subtle 6px dot in top-right corner of card for escalations the
|
||||
current senior has never opened. Dot fades on first hover or click.
|
||||
|
||||
**Race-condition (two seniors click Pick Up simultaneously):** loser sees a toast
|
||||
"Already claimed by [name] 2s ago" via existing `@/lib/toast`; the card flashes the
|
||||
winner's name in the meta row for 1s, then dissolves from the loser's view via
|
||||
optimistic update + WebSocket reconciliation.
|
||||
|
||||
**Unread state (Codex correction 2026-04-27):** dot indicator clears on **open,
|
||||
claim, or explicit dismiss** — NOT on hover. Hover-to-clear is a bad proxy for
|
||||
acknowledgment because incidental mouse movement creates false clears.
|
||||
|
||||
**Notification routing (Codex finding 2026-04-27):** v1 fans out the email + push
|
||||
to **all engineer-or-admin role users in the same account_id as the SessionHandoff**.
|
||||
No on-call/round-robin logic in v1. If pilots ask for routing, capture as v2 TODO.
|
||||
The first senior to claim wins; everyone else's notification self-resolves on
|
||||
WebSocket reconciliation.
|
||||
|
||||
**Notification delivery model (Codex correction 2026-04-27):** drop the
|
||||
`notification_sent: bool` flag from v1. Replace with per-channel delivery rows
|
||||
in a new `notification_log` table (already exists — reuse, don't add a new model)
|
||||
keyed by `(handoff_id, channel, recipient_user_id, status)` where status ∈
|
||||
{queued, sent, failed, suppressed}. This makes partial-success and per-channel
|
||||
retry visible. If the existing `notification_log` schema doesn't match, defer
|
||||
the per-channel persistence to a v2 TODO and v1 logs delivery attempts to the
|
||||
existing telemetry stream instead. Do NOT keep the dead boolean.
|
||||
|
||||
**"Start here" CTA (Codex correction 2026-04-27):** opens the FlowPilot session
|
||||
at the **latest known state** (the AI's most recent agent_message + the current
|
||||
pending_task_lane). Surface `ai_assessment_data.suggested_steps[]` as a list of
|
||||
chips below the chat input — clicking a chip prefills the input. Do NOT invent a
|
||||
"jump to most-likely-next-step" capability that doesn't exist in the session model.
|
||||
|
||||
**`/claim` role gate (Codex correction 2026-04-27, IN-SCOPE for v1):** add
|
||||
`require_engineer_or_admin` dep on POST `/handoffs/{id}/claim`. Originally
|
||||
deferred to TODO during eng review; Codex correctly flagged it as wedge-relevant
|
||||
because the race-condition story depends on auth gating. ~30 min change. Removed
|
||||
from TODO.md.
|
||||
|
||||
**A11y requirements (mandatory before pilot ship):**
|
||||
- Keyboard: Tab order through queue cards; Enter on focused card opens it; Pick Up
|
||||
button is a reachable target; Esc closes the handoff-context overlay.
|
||||
- ARIA: `role="region"` + `aria-live="polite"` on the queue list (announces arrivals);
|
||||
`aria-label="N escalations awaiting pickup"` on the heading; the slide-in animation
|
||||
must not announce twice (debounce live-region updates).
|
||||
- Pick Up button: bump from `py-2` to `py-2.5` to clear the 44px touch-target floor.
|
||||
- Color contrast: confidence-badge text on accent-dim background must be ≥4.5:1
|
||||
(verify against DESIGN-SYSTEM.md tokens).
|
||||
|
||||
**DS token discipline:** every new piece must use `card-flat`, `accent-dim`/`accent-text`,
|
||||
`text-muted-foreground`, `bg-card`/`bg-elevated`, IBM Plex / Bricolage / JetBrains,
|
||||
explicit `transition` property lists (never `transition: all`). No glass, no blur,
|
||||
no gradient surfaces. Electric-blue accent reserved for interactive elements only.
|
||||
|
||||
**Mobile responsive:** deferred to post-pilot TODO. Pre-PMF wedge target is desktop;
|
||||
MSP techs work on laptops/desktops in shop environments.
|
||||
|
||||
**Deferred to TODO.md (out of scope for v1 wedge):**
|
||||
- Peer-tech escalates colleague's session (currently session-owner-only)
|
||||
- Role gate on POST /claim (currently any authenticated user in account)
|
||||
|
||||
### Approach B: Full Structured Resolution loop (split B1 + B2)
|
||||
|
||||
End-to-end demo: tech opens FlowPilot, structure appears in side panel as AI
|
||||
responds, ticket notes auto-populate at end, optional runbook capture for reusable
|
||||
patterns. Tells the full "senior engineer over your shoulder" story.
|
||||
|
||||
**B1 — Side panel + PSA-formatted ticket notes** (ships first):
|
||||
- Structured side panel that surfaces parsed AI markers as live actionable steps
|
||||
while the conversation runs.
|
||||
- PSA-formatted ticket-notes exporter (ConnectWise first; Autotask/Halo later).
|
||||
- Effort: M (~3 weeks).
|
||||
|
||||
**B2 — Runbook offer-and-save** (gated on pilot demand):
|
||||
- "Save this resolution as a flow?" prompt at session end, with auto-drafted
|
||||
runbook from the structured session state.
|
||||
- Effort: S (~1 week). Don't build until at least 2 pilot customers explicitly
|
||||
ask for it.
|
||||
|
||||
- **Risk:** Medium. The structured-output panel quality is the whole demo. If it
|
||||
looks dumb, the demo dies.
|
||||
- **Reuses:** FlowPilot, unified_chat_service, FlowProposal, ConnectWise PSA
|
||||
integration.
|
||||
|
||||
### Approach C: Senior-Tech Time-Saved Counter
|
||||
|
||||
Continuous measurement layer underneath A and B. Every session contributes an
|
||||
estimated minutes-saved number. Owner-facing dashboard quotes "this month your
|
||||
shop saved N hours of senior-tech time." Sells to MSP owner with verifiable ROI.
|
||||
|
||||
- **Effort:** S (~1 week + ongoing measurement methodology refinement).
|
||||
- **Risk:** Medium-low. Methodology has to be defensible. If numbers look
|
||||
made-up, trust dies fast.
|
||||
- **Reuses:** FlowPilot telemetry, session metadata, account-scoped analytics.
|
||||
|
||||
## Recommended Approach
|
||||
|
||||
**A first (1-2 weeks), then B (3-4 weeks after A ships), with C running underneath
|
||||
both as a continuous backdrop.**
|
||||
|
||||
Sequence rationale:
|
||||
|
||||
- **A is the sharpest possible 2-minute demo.** Single feature, single metric,
|
||||
buyer-verifiable in their own data. Get it in front of 5 MSPs in week 3.
|
||||
- **B is the depth play.** Once Approach A has produced first-pilot signal,
|
||||
Approach B's full structured-resolution loop becomes the "what we ship next" that
|
||||
retains pilots and converts them to paid.
|
||||
- **C compounds across both.** Every session under A or B contributes to the
|
||||
time-saved counter. By week 6 there are real numbers to put in front of an MSP
|
||||
owner — turning a senior-tech-led pilot into an owner-signed contract.
|
||||
|
||||
This sequence is non-negotiable. Building B before A is the classic pre-PMF trap of
|
||||
perfecting product before validating GTM. Building C alone is measurement without a
|
||||
demo to anchor it.
|
||||
|
||||
## Pricing
|
||||
|
||||
**Pilot pricing (first 3-5 customers): $39/seat/month, monthly billing,
|
||||
month-to-month.** Anchored against IT Glue (~$29/tech), Hudu (~$25/tech),
|
||||
Liongard (~$3/endpoint). The premium over IT Glue/Hudu reflects the active-session
|
||||
value (vs. their static-runbook value) — 30% above the runbook-only category.
|
||||
|
||||
Customer #6+ pricing is an Open Question (revisit after 3 pilots produce real
|
||||
hours-saved data; price up if the per-seat ROI is over $200/seat/mo).
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Free-tier shape.** Should the time-saved counter be free forever as a
|
||||
distribution lever, with paid for the structuring + escalation? Land-and-expand
|
||||
pattern. Decide after 3 pilot conversions.
|
||||
2. **PSA-marketplace timing.** ConnectWise Marketplace listing requires partnership
|
||||
onboarding (~6-week cycle). Submit application week 5; expect listing live by
|
||||
week 11. Don't gate launch on it.
|
||||
3. **Customer #6+ pricing.** Revisit after 3 pilot customers produce verifiable
|
||||
hours-saved numbers.
|
||||
|
||||
## Deferred (YAGNI until 10 paying customers)
|
||||
|
||||
- HIPAA / SOC2 audit positioning. Pre-PMF is too early; revisit when a regulated-
|
||||
vertical MSP asks for it explicitly.
|
||||
- Multi-PSA depth (Autotask, Halo). ConnectWise alone covers ~40% of the SMB MSP
|
||||
market and is sufficient for first 5-10 customers.
|
||||
- Cross-tenant pattern detection. The data-flywheel-across-shops play is at least
|
||||
6 months out; building it before single-shop ROI is proven is premature.
|
||||
|
||||
## Success Criteria (revised for realism)
|
||||
|
||||
- **Week 3:** Approach A shipped. 3 MSPs in active free pilot (cap at 3 to
|
||||
preserve B1 build capacity).
|
||||
- **Weeks 3-6:** Pilot management dominates. B1 build is paused; founder runs
|
||||
pilot calls, captures bug reports, iterates UX. Stripe seat-based billing is
|
||||
set up in week 5.
|
||||
- **Week 6:** First verbal commit from a pilot customer. Verified
|
||||
minutes-recovered-per-escalation number from at least 2 pilots.
|
||||
- **Week 8:** First paid customer (procurement cycles run 4-6 weeks even at small
|
||||
MSPs; 2 weeks from verbal commit to signed contract is realistic). Time-saved
|
||||
counter (Approach C) producing dashboard-quality data.
|
||||
- **Week 11:** B1 (side panel + PSA notes) shipped. 3-5 paying customers. First
|
||||
MSP-owner-led conversation. ConnectWise Marketplace listing live.
|
||||
- **Quarter end:** $5K MRR or 10 paying customers, whichever comes first. Loom
|
||||
demos posted publicly to r/msp and MSPGeek.
|
||||
|
||||
## Distribution Plan (week-by-week cadence)
|
||||
|
||||
- **Week 3:** Escalation Mode demo Loom posted. r/msp launch post.
|
||||
- **Week 4:** MSPGeek Discord AMA scheduled. RocketMSP newsletter pitch sent.
|
||||
- **Week 5:** ConnectWise Marketplace listing application submitted. Stripe
|
||||
billing live for paid conversion.
|
||||
- **Week 6:** First "guest on Inside MSP podcast" outreach. Second r/msp post
|
||||
(case study from a pilot, anonymized).
|
||||
- **Week 7-8:** Pilot conversion calls. First paying customer.
|
||||
- **Week 9-11:** B1 ships. Owner-targeted demo Loom. Second podcast outreach.
|
||||
|
||||
**Founder-led pilot:** The first 3-5 customers come from the founder's existing
|
||||
MSP network. Treat them as design partners; expect to ship feature requests
|
||||
weekly during pilot. Cap at 3 active pilots until B1 ships.
|
||||
|
||||
**Tech audience channels:** r/msp, r/sysadmin, MSPGeek Discord, RocketMSP
|
||||
newsletter, Inside MSP podcast.
|
||||
**Owner audience channels:** ConnectWise Marketplace, MSP-focused Substacks,
|
||||
RIA Vendor Roundup.
|
||||
|
||||
CI/CD: existing Railway auto-deploy via GitHub mirror. No new pipeline needed.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **Session-state serialization (Approach A blocker).** Schema design + migration
|
||||
is the longest-lead engineering task. 3-5 days budget. Do this first.
|
||||
- **Stripe seat-based billing (week 5 task).** No billing infrastructure exists
|
||||
today. ~3-5 days of work for monthly subscriptions + invoice flow. Block on
|
||||
this before week-8 first-paid milestone.
|
||||
- **ConnectWise PSA integration depth.** Sufficient for ticket-notes auto-export
|
||||
(Approach B1). Autotask and Halo wait until first 5 paying ConnectWise
|
||||
customers.
|
||||
- **Authentication.** Existing JWT + role hierarchy is sufficient for senior-tech
|
||||
inbox view; no new auth work needed.
|
||||
|
||||
## Risks and Kill-Switch
|
||||
|
||||
- **Risk: Session-state serialization design churn.** If the schema needs to
|
||||
change after pilot feedback, every saved session has to migrate. Mitigation:
|
||||
keep schema versioned and forward-compatible from day 1.
|
||||
- **Risk: Pilot-to-paid conversion slower than 4-6 weeks.** MSP procurement is
|
||||
notoriously slow. Mitigation: get verbal commits in writing; price as
|
||||
month-to-month with no annual contract to lower the buying friction.
|
||||
- **Risk: ConnectWise ships an equivalent feature in their 2026.x release.**
|
||||
Mitigation: lead the marketing on "we're independent of your PSA" — works with
|
||||
any PSA, not just ConnectWise. The founder's PSA-agnostic FlowPilot is an
|
||||
asset here.
|
||||
- **Kill-switch criterion:** if 0 of 3 pilots produce a verifiable
|
||||
hours-saved-per-week number above 1.0 by week 8, **revisit the wedge**. The
|
||||
product may need to pivot to deterministic-ops territory (Read 1 from the
|
||||
session) or be repositioned. Don't sink another quarter into the current GTM
|
||||
story without this number.
|
||||
|
||||
## The Assignment
|
||||
|
||||
**This week, before any code:**
|
||||
|
||||
Time-track the next 5 escalations in your shop manually. For each, capture:
|
||||
1. Time the senior tech opens the ticket
|
||||
2. Time the senior tech takes their first diagnostic action (not counting the
|
||||
verbal "tell me what you tried" warm-up)
|
||||
3. The delta — that's the wasted time per escalation today
|
||||
|
||||
Average those 5 numbers. **That's the hero stat in your first sales conversation:**
|
||||
"Senior techs at our shop wasted N minutes per escalation just getting up to
|
||||
speed. We built the thing that takes that to zero."
|
||||
|
||||
Don't try to pull this from telemetry — the doc itself notes that retrieval/re-use
|
||||
data isn't queryable yet. Manual stopwatch on the next 5 escalations is the
|
||||
fastest path to a defensible number.
|
||||
|
||||
This is the assignment because it forces the GTM story into the same time-zone as
|
||||
the build, and it's a one-day effort that compounds for every conversation
|
||||
afterward.
|
||||
|
||||
## What I noticed about how you think
|
||||
|
||||
- You contradicted my framing twice in the same session and the second
|
||||
contradiction was sharper than the first. Most founders agree with the
|
||||
diagnostic and walk out with a polished version of what they came in with. You
|
||||
said "I'm just questioning if flows are even the way to go" — and that
|
||||
sentence reset the entire wedge. That's craft.
|
||||
|
||||
- "The senior engineer looking over your shoulder" came out of you spontaneously,
|
||||
not as a prepared pitch. That's the line. Use it. It survives because it's
|
||||
emotional truth (every junior tech has had this, every senior tech has been
|
||||
this), not constructed marketing copy.
|
||||
|
||||
- You're solving your own problem with your own time. 20 hrs/week isn't a
|
||||
hypothetical user pain — it's your Tuesday. Founders who solve their own pain
|
||||
ship sharper products because the feedback loop is instant.
|
||||
|
||||
- The escalation feature emerged from your description, not mine. I was busy
|
||||
cataloging documentation pains. You said "junior to senior escalation? no
|
||||
worries there either" almost as an afterthought. That afterthought is the wedge.
|
||||
Pay attention to which features you describe casually versus which you push hard
|
||||
on — the casual ones are sometimes where the truth lives.
|
||||
|
||||
## GSTACK REVIEW REPORT
|
||||
|
||||
| Review | Trigger | Why | Runs | Status | Findings |
|
||||
|--------|---------|-----|------|--------|----------|
|
||||
| CEO Review | `/plan-ceo-review` | Scope & strategy | 0 | — | not run |
|
||||
| Codex Review | `/codex review` | Independent 2nd opinion | 1 | INFO | 12 findings, 6 applied, 1 partial, 5 rejected |
|
||||
| Eng Review | `/plan-eng-review` | Architecture & tests (required) | 1 | CLEAR (PLAN) | 2 issues, 0 critical gaps, scope reduced |
|
||||
| Design Review | `/plan-design-review` | UI/UX gaps | 1 | CLEAR (FULL) | score 6/10 → 9/10, 8 decisions |
|
||||
| DX Review | `/plan-devex-review` | Developer experience gaps | 0 | — | not run |
|
||||
|
||||
- **CODEX:** 12 findings reviewed. Applied: 2-metric framing (#2), notification routing spec (#3), per-channel delivery model (#4), unread-state fix (#11), Start-here CTA reframe (#9), claim role gate moved in-scope (#8). Rejected: full scope reduction to PSA-brief-only (#6/7/12 — user kept queue UI as demo hero). Partial: scope concern (#5) acknowledged in eng review's email-first/polling-fallback. Misread: #1, #10.
|
||||
- **CROSS-MODEL:** Claude (eng + design reviews) and Codex agree on 6/12 findings. The major disagreement was scope — Codex argued for cutting the queue UI, user rejected. Both agree on metric definition, notification routing, claim auth gating.
|
||||
- **UNRESOLVED:** 0
|
||||
- **VERDICT:** ENG + DESIGN CLEARED, CODEX REVIEWED — ready to implement.
|
||||
33
docs/plans/2026-04-27-escalation-mode-wedge-test-plan.md
Normal file
33
docs/plans/2026-04-27-escalation-mode-wedge-test-plan.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# Test Plan
|
||||
Generated by /plan-eng-review on 2026-04-27
|
||||
Branch: main
|
||||
Repo: chihlasm/resolutionflow
|
||||
|
||||
## Affected Pages/Routes
|
||||
|
||||
- `/escalations` ([EscalationQueuePage.tsx](frontend/src/pages/EscalationQueuePage.tsx)) — senior-tech inbox view; verify queue list, real-time arrival, click-through
|
||||
- `/pilot/:session_id` (FlowPilotSessionPage) — verify post-claim load shows full escalation context (snapshot, ai_assessment, escalation_package)
|
||||
- `GET /api/v1/analytics/escalation-metrics` (NEW) — verify hero metric calculation, account-scoping, role gate
|
||||
|
||||
## Key Interactions to Verify
|
||||
|
||||
- Junior tech clicks **Escalate** in active FlowPilot session → handoff is created → notification fires → senior sees escalation in queue within 30 seconds
|
||||
- Senior tech clicks **Claim** in queue → session reactivates → senior is redirected into FlowPilot session view → ai_assessment + snapshot are visible
|
||||
- Senior types first message in chat after claim → metric query starts attributing time-to-first-action
|
||||
- MSP owner opens analytics page → "minutes recovered per escalation" widget shows current month's rolling average
|
||||
|
||||
## Edge Cases
|
||||
|
||||
- **Two seniors race to claim** the same handoff → one wins, the other gets a "Already claimed by [name]" message
|
||||
- **Senior is offline** when escalation fires → email arrives via existing `EmailService.send_notification_email`
|
||||
- **WebSocket disconnects mid-session** → frontend reconnects; missed events backfilled by re-fetching the queue
|
||||
- **Notification dispatch raises** (SMTP down, WebSocket fanout fails) → handoff is still created (graceful degradation)
|
||||
- **Senior takes non-chat action first** (e.g., posts directly to PSA) → metric falls back to PSA writeback timestamp or remains null; doc the chosen behavior
|
||||
- **Account-scoped multi-tenancy** → senior at MSP A cannot see escalations from MSP B (Phase 4 RLS)
|
||||
- **Role gate on metric endpoint** → only `engineer_or_admin` can hit `/escalation-metrics`
|
||||
|
||||
## Critical Paths
|
||||
|
||||
1. **Magic-moment demo flow** (the entire Loom): junior escalate → senior notification → senior claim → session view → first action recorded → metric updates
|
||||
2. **Email fallback** when senior is offline — must not silently drop
|
||||
3. **Regression: handoff creation succeeds even if notification dispatch raises** — graceful degradation is mandatory
|
||||
@@ -18,6 +18,9 @@ import type {
|
||||
ChatSessionCreateResponse,
|
||||
ChatMessageRequest,
|
||||
ChatMessageResponse,
|
||||
HandoffCreatedEvent,
|
||||
HandoffAssessmentReadyEvent,
|
||||
EscalationStreamHandlers,
|
||||
} from '@/types/ai-session'
|
||||
|
||||
export const aiSessionsApi = {
|
||||
@@ -220,6 +223,80 @@ export const aiSessionsApi = {
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Native EventSource cannot send Authorization headers, so we use fetch +
|
||||
// ReadableStream and parse SSE frames manually (same pattern as
|
||||
// `streamDocumentation`). The returned promise resolves on clean stream
|
||||
// close (server hangs up) and rejects on network/HTTP error so the caller
|
||||
// can decide whether to reconnect with backoff.
|
||||
async streamEscalations(
|
||||
handlers: EscalationStreamHandlers,
|
||||
signal: AbortSignal,
|
||||
): Promise<void> {
|
||||
const token = localStorage.getItem('access_token')
|
||||
const baseUrl = import.meta.env.VITE_API_URL || ''
|
||||
|
||||
const response = await fetch(
|
||||
`${baseUrl}/api/v1/ai-sessions/escalations/stream`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal,
|
||||
},
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Escalation stream failed: HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader()
|
||||
if (!reader) {
|
||||
throw new Error('Escalation stream: no response body')
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) return
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
|
||||
// SSE frames are separated by blank lines. Hold the trailing partial
|
||||
// frame in the buffer until the next chunk completes it.
|
||||
const frames = buffer.split('\n\n')
|
||||
buffer = frames.pop() ?? ''
|
||||
|
||||
for (const frame of frames) {
|
||||
if (!frame) continue
|
||||
let eventType = 'message'
|
||||
let data = ''
|
||||
for (const line of frame.split('\n')) {
|
||||
if (line.startsWith(':')) continue // comment / keepalive
|
||||
if (line.startsWith('event: ')) eventType = line.slice(7).trim()
|
||||
else if (line.startsWith('data: ')) data += line.slice(6)
|
||||
}
|
||||
if (!data) continue
|
||||
try {
|
||||
const parsed = JSON.parse(data) as Record<string, unknown>
|
||||
if (eventType === 'handoff_created' && parsed.type === 'handoff_created') {
|
||||
handlers.onHandoffCreated?.(parsed as unknown as HandoffCreatedEvent)
|
||||
} else if (
|
||||
eventType === 'handoff_assessment_ready' &&
|
||||
parsed.type === 'handoff_assessment_ready'
|
||||
) {
|
||||
handlers.onAssessmentReady?.(
|
||||
parsed as unknown as HandoffAssessmentReadyEvent,
|
||||
)
|
||||
} else if (eventType === 'ready') {
|
||||
handlers.onReady?.()
|
||||
}
|
||||
} catch {
|
||||
// skip malformed frame
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async search(q: string, limit: number = 5): Promise<AISessionSearchResult[]> {
|
||||
const response = await apiClient.get<AISessionSearchResult[]>('/ai-sessions/search', {
|
||||
params: { q, limit },
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import apiClient from './client'
|
||||
import type { FlowPilotDashboard, KnowledgeGapReport, CoverageResponse, FlowQualityResponse, EnhancedPsaMetrics } from '@/types/flowpilot-analytics'
|
||||
import type {
|
||||
FlowPilotDashboard,
|
||||
KnowledgeGapReport,
|
||||
CoverageResponse,
|
||||
FlowQualityResponse,
|
||||
EnhancedPsaMetrics,
|
||||
EscalationMetrics,
|
||||
} from '@/types/flowpilot-analytics'
|
||||
|
||||
export const flowpilotAnalyticsApi = {
|
||||
async getDashboard(period: string = '30d'): Promise<FlowPilotDashboard> {
|
||||
@@ -36,6 +43,13 @@ export const flowpilotAnalyticsApi = {
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
async getEscalationMetrics(period: string = '30d'): Promise<EscalationMetrics> {
|
||||
const response = await apiClient.get<EscalationMetrics>('/analytics/flowpilot/escalations', {
|
||||
params: { period },
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
export default flowpilotAnalyticsApi
|
||||
|
||||
@@ -219,10 +219,31 @@ function ChatItem({
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-[0.8125rem] font-medium truncate">{chat.title}</div>
|
||||
<div className="text-[0.6875rem] text-muted-foreground">
|
||||
{chat.message_count} messages
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<div className="text-[0.8125rem] font-medium truncate">{chat.title}</div>
|
||||
{chat.psa_ticket_id && (
|
||||
<span className="font-mono shrink-0 rounded-md bg-accent-dim px-1.5 py-0.5 text-[0.5625rem] text-accent-text">
|
||||
#{chat.psa_ticket_id}
|
||||
</span>
|
||||
)}
|
||||
{(chat.status === 'escalated' || chat.status === 'requesting_escalation') && (
|
||||
<span className="font-sans shrink-0 rounded-md bg-warning-dim px-1.5 py-0.5 text-[0.5625rem] uppercase tracking-wider text-warning border border-warning/20">
|
||||
Escalated
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Secondary line: problem snippet when the title doesn't already
|
||||
carry it, otherwise the message count. Keeps untitled
|
||||
sessions from collapsing into identical-looking rows. */}
|
||||
{chat.problem_summary && chat.problem_summary !== chat.title ? (
|
||||
<div className="text-[0.6875rem] text-muted-foreground truncate">
|
||||
{chat.problem_summary}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-[0.6875rem] text-muted-foreground">
|
||||
{chat.message_count} messages
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -348,6 +348,15 @@ export function ConcludeSessionModal({
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={e => setNotes(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
// Enter submits, Shift+Enter inserts newline — same
|
||||
// convention as the chat composer. Engineers write
|
||||
// short reasons here; multi-line is rare.
|
||||
if (e.key === 'Enter' && !e.shiftKey && !generating) {
|
||||
e.preventDefault()
|
||||
handleGenerate()
|
||||
}
|
||||
}}
|
||||
placeholder={
|
||||
outcome === 'resolved'
|
||||
? 'Any additional context about the resolution...'
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -13,6 +13,11 @@ interface RichTextInputProps {
|
||||
rows?: number
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
// Enter-to-submit, matching the chat-input convention used elsewhere in
|
||||
// the app: plain Enter calls onSubmit; Shift+Enter inserts a newline.
|
||||
// Parents that want the legacy "Enter = newline only" behavior just
|
||||
// omit this prop.
|
||||
onSubmit?: () => void
|
||||
}
|
||||
|
||||
export function RichTextInput({
|
||||
@@ -24,6 +29,7 @@ export function RichTextInput({
|
||||
rows = 3,
|
||||
className,
|
||||
disabled,
|
||||
onSubmit,
|
||||
}: RichTextInputProps) {
|
||||
const [pendingUploads, setPendingUploads] = useState<PendingUpload[]>([])
|
||||
const [isDragOver, setIsDragOver] = useState(false)
|
||||
@@ -229,6 +235,12 @@ export function RichTextInput({
|
||||
onPaste={handlePaste}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && onSubmit) {
|
||||
e.preventDefault()
|
||||
onSubmit()
|
||||
}
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
disabled={disabled}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
import { AlertTriangle, ChevronDown, ChevronRight, Hash } from 'lucide-react'
|
||||
import { aiSessionsApi } from '@/api/aiSessions'
|
||||
import type { AISessionSummary } from '@/types/ai-session'
|
||||
import { timeAgo } from '@/lib/timeAgo'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function PendingEscalations() {
|
||||
const [escalations, setEscalations] = useState<AISessionSummary[]>([])
|
||||
// Single expansion at a time — keeps the dashboard compact even when
|
||||
// multiple escalations are pending. Click a row again to collapse.
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
@@ -43,35 +47,107 @@ export function PendingEscalations() {
|
||||
</Link>
|
||||
</div>
|
||||
<div>
|
||||
{escalations.slice(0, 3).map((esc, i) => (
|
||||
<div
|
||||
key={esc.id}
|
||||
className="flex items-center gap-3 px-5 py-3"
|
||||
style={{
|
||||
borderBottom: i < Math.min(escalations.length, 3) - 1
|
||||
? '1px solid var(--color-border-default)'
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<span className="h-2 w-2 shrink-0 rounded-full bg-amber-400 animate-pulse" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm text-foreground truncate">
|
||||
{esc.problem_summary || 'Escalated session'}
|
||||
</div>
|
||||
<div className="text-[0.6875rem] text-muted-foreground">
|
||||
{esc.problem_domain || 'General'}
|
||||
<span className="mx-1.5 text-[var(--text-dimmed)]">·</span>
|
||||
<span className="font-sans text-xs">{timeAgo(esc.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigate(`/pilot/${esc.id}?pickup=true`)}
|
||||
className="shrink-0 rounded-lg border border-amber-400/30 bg-amber-400/10 px-3 py-1 text-[0.6875rem] font-medium text-amber-400 hover:bg-amber-400/20 transition-colors"
|
||||
{escalations.slice(0, 3).map((esc, i) => {
|
||||
const isExpanded = expandedId === esc.id
|
||||
const isLast = i >= Math.min(escalations.length, 3) - 1
|
||||
return (
|
||||
<div
|
||||
key={esc.id}
|
||||
style={{
|
||||
borderBottom: !isLast
|
||||
? '1px solid var(--color-border-default)'
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
Pick up
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpandedId(isExpanded ? null : esc.id)}
|
||||
aria-expanded={isExpanded}
|
||||
className="w-full flex items-center gap-3 px-5 py-3 text-left hover:bg-elevated/30 transition-colors"
|
||||
>
|
||||
<span className="h-2 w-2 shrink-0 rounded-full bg-amber-400 animate-pulse" />
|
||||
{isExpanded ? (
|
||||
<ChevronDown size={12} className="shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight size={12} className="shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm text-foreground truncate">
|
||||
{esc.problem_summary || 'Escalated session'}
|
||||
</div>
|
||||
<div className="text-[0.6875rem] text-muted-foreground">
|
||||
{esc.problem_domain || 'General'}
|
||||
<span className="mx-1.5 text-[var(--text-dimmed)]">·</span>
|
||||
<span className="font-sans text-xs">{timeAgo(esc.created_at)}</span>
|
||||
{esc.psa_ticket_id && (
|
||||
<>
|
||||
<span className="mx-1.5 text-[var(--text-dimmed)]">·</span>
|
||||
<span className="inline-flex items-center gap-0.5 font-mono text-[0.625rem] text-accent-text">
|
||||
<Hash size={9} />
|
||||
{esc.psa_ticket_id}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
navigate(`/pilot/${esc.id}?pickup=true`)
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
navigate(`/pilot/${esc.id}?pickup=true`)
|
||||
}
|
||||
}}
|
||||
className="shrink-0 rounded-lg border border-amber-400/30 bg-amber-400/10 px-3 py-1 text-[0.6875rem] font-medium text-amber-400 hover:bg-amber-400/20 transition-colors cursor-pointer"
|
||||
>
|
||||
Pick up
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div
|
||||
className={cn(
|
||||
'px-5 pb-3 pl-12 space-y-2 text-xs animate-fade-in'
|
||||
)}
|
||||
>
|
||||
{esc.escalation_reason && (
|
||||
<div>
|
||||
<p className="font-sans text-[0.5625rem] uppercase tracking-wider text-muted-foreground mb-0.5">
|
||||
Why escalated
|
||||
</p>
|
||||
<p className="text-foreground whitespace-pre-wrap leading-snug">
|
||||
{esc.escalation_reason}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1 text-muted-foreground">
|
||||
<span>
|
||||
<span className="font-medium text-foreground">{esc.step_count}</span>{' '}
|
||||
diagnostic {esc.step_count === 1 ? 'step' : 'steps'} on record
|
||||
</span>
|
||||
{esc.confidence_tier && (
|
||||
<span className="font-sans uppercase tracking-wider text-[0.5625rem]">
|
||||
Confidence: {esc.confidence_tier}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{!esc.escalation_reason && (
|
||||
<p className="italic text-muted-foreground">
|
||||
No reason note from the original engineer. Pick up to see the full session
|
||||
context and AI assessment.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -53,6 +53,7 @@ export function EscalateModal({ open, onClose, onEscalate, isProcessing, hasPsaT
|
||||
sessionId={sessionId}
|
||||
placeholder="e.g. I've exhausted all networking diagnostics and suspect this is a firewall policy issue that requires senior admin access..."
|
||||
rows={4}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
<p className="mt-1 text-[0.625rem] text-text-muted">
|
||||
Minimum 5 characters. This will be shown to the engineer who picks up.
|
||||
|
||||
130
frontend/src/components/flowpilot/EscalationMetricCard.tsx
Normal file
130
frontend/src/components/flowpilot/EscalationMetricCard.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Clock, TrendingUp, AlertCircle } from 'lucide-react'
|
||||
import { flowpilotAnalyticsApi } from '@/api'
|
||||
import type { EscalationMetrics } from '@/types/flowpilot-analytics'
|
||||
|
||||
interface EscalationMetricCardProps {
|
||||
period?: string
|
||||
}
|
||||
|
||||
function formatSeconds(s: number | null): string {
|
||||
if (s === null) return '—'
|
||||
if (s < 60) return `${Math.round(s)}s`
|
||||
const mins = s / 60
|
||||
if (mins < 10) return `${mins.toFixed(1)} min`
|
||||
return `${Math.round(mins)} min`
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the in-product time-to-first-action metric above the EscalationQueue.
|
||||
*
|
||||
* NOTE: this is the in-product metric only. The "minutes recovered" sales
|
||||
* claim requires a manual baseline measurement (see The Assignment in
|
||||
* docs/plans/2026-04-27-escalation-mode-wedge-design.md). Frame the number
|
||||
* as "time-to-first-action with structured handoff," not "minutes saved."
|
||||
*/
|
||||
export function EscalationMetricCard({ period = '30d' }: EscalationMetricCardProps) {
|
||||
const [metrics, setMetrics] = useState<EscalationMetrics | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
const load = async () => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const data = await flowpilotAnalyticsApi.getEscalationMetrics(period)
|
||||
if (!cancelled) setMetrics(data)
|
||||
} catch {
|
||||
if (!cancelled) setError('Failed to load metric')
|
||||
} finally {
|
||||
if (!cancelled) setIsLoading(false)
|
||||
}
|
||||
}
|
||||
load()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [period])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="card-flat p-4 mb-4 animate-pulse">
|
||||
<div className="h-4 w-32 bg-elevated rounded" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="card-flat p-4 mb-4 flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<AlertCircle size={14} />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!metrics || metrics.n_handoffs_claimed === 0) {
|
||||
return (
|
||||
<div className="card-flat p-4 mb-4">
|
||||
<p className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||
Time to first action ({period})
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
No claimed escalations yet. Once your team starts using Pick Up,
|
||||
we'll measure how fast they get into resolution.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const avgLabel = formatSeconds(metrics.avg_seconds_to_first_action)
|
||||
const medianLabel = formatSeconds(metrics.median_seconds_to_first_action)
|
||||
const conversionRate =
|
||||
metrics.n_handoffs_claimed > 0
|
||||
? Math.round(
|
||||
(metrics.n_handoffs_with_action / metrics.n_handoffs_claimed) * 100,
|
||||
)
|
||||
: 0
|
||||
|
||||
return (
|
||||
<div className="card-flat p-4 mb-4">
|
||||
<div className="flex items-center gap-2 text-xs uppercase tracking-wider text-muted-foreground">
|
||||
<TrendingUp size={12} />
|
||||
<span>Time to first action — last {period}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap items-baseline gap-x-6 gap-y-2">
|
||||
<div>
|
||||
<span className="font-heading text-2xl font-bold text-foreground">
|
||||
{avgLabel}
|
||||
</span>
|
||||
<span className="ml-1 text-xs text-muted-foreground">avg</span>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<span className="font-medium text-foreground">{medianLabel}</span> median
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<span className="font-medium text-foreground">
|
||||
{metrics.n_handoffs_with_action}
|
||||
</span>
|
||||
/{metrics.n_handoffs_claimed} claimed escalations
|
||||
<span className="ml-1 text-muted-foreground/70">
|
||||
({conversionRate}% reached first action)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-2 flex items-start gap-1.5 text-[0.6875rem] text-muted-foreground">
|
||||
<Clock size={10} className="mt-0.5 flex-none" />
|
||||
<span>
|
||||
In-product measurement only. The savings claim requires a manual
|
||||
baseline of pre-Escalation-Mode handoff time. See your team's
|
||||
Assignment for the baseline number.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +1,59 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { AlertTriangle, Clock, Hash, Ticket, Loader2, RefreshCw } from 'lucide-react'
|
||||
import { aiSessionsApi } from '@/api'
|
||||
import type { AISessionSummary } from '@/types/ai-session'
|
||||
import { timeAgo } from '@/lib/timeAgo'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface EscalationQueueProps {
|
||||
onPickup?: (sessionId: string) => void
|
||||
onCountChange?: (count: number) => void
|
||||
}
|
||||
|
||||
// Static list sort: oldest-first. Longest waiting = most urgent.
|
||||
const sortOldestFirst = (a: AISessionSummary, b: AISessionSummary) =>
|
||||
new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
|
||||
|
||||
// Live-arrival bucket sort: newest-first so the most recent escalation is at
|
||||
// the very top of the list.
|
||||
const sortNewestFirst = (a: AISessionSummary, b: AISessionSummary) =>
|
||||
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
||||
|
||||
// How long a freshly-arrived card keeps the slide-in animation class. The
|
||||
// keyframe itself runs 200ms; this just keeps the class on the DOM long
|
||||
// enough for the animation to finish before React removes it on the next
|
||||
// state transition.
|
||||
const NEW_CARD_HIGHLIGHT_MS = 800
|
||||
|
||||
// localStorage key for the per-user "seen" set. Tracks session IDs the user
|
||||
// has acknowledged so the unread dot doesn't reappear on refresh. Bounded to
|
||||
// the last `SEEN_CAP` entries to avoid unbounded growth on long-lived
|
||||
// accounts.
|
||||
const SEEN_STORAGE_KEY = 'rf-escalation-seen'
|
||||
const SEEN_CAP = 200
|
||||
|
||||
function loadSeenIds(): Set<string> {
|
||||
try {
|
||||
const raw = localStorage.getItem(SEEN_STORAGE_KEY)
|
||||
if (!raw) return new Set()
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (!Array.isArray(parsed)) return new Set()
|
||||
return new Set(parsed.filter((v): v is string => typeof v === 'string'))
|
||||
} catch {
|
||||
return new Set()
|
||||
}
|
||||
}
|
||||
|
||||
function saveSeenIds(ids: Set<string>): void {
|
||||
try {
|
||||
const arr = Array.from(ids).slice(-SEEN_CAP)
|
||||
localStorage.setItem(SEEN_STORAGE_KEY, JSON.stringify(arr))
|
||||
} catch {
|
||||
// localStorage unavailable / quota — silent. The dot just won't persist.
|
||||
}
|
||||
}
|
||||
|
||||
function waitTimeColor(createdAt: string): string {
|
||||
const hours = (Date.now() - new Date(createdAt).getTime()) / 3_600_000
|
||||
if (hours >= 4) return '#f87171' // danger
|
||||
@@ -22,31 +66,173 @@ export function EscalationQueue({ onPickup, onCountChange }: EscalationQueueProp
|
||||
const [sessions, setSessions] = useState<AISessionSummary[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
// Session IDs that arrived via SSE and should still play the slide-in.
|
||||
const [newIds, setNewIds] = useState<Set<string>>(new Set())
|
||||
// Track count of unseen arrivals while the tab is backgrounded.
|
||||
const [unseenCount, setUnseenCount] = useState(0)
|
||||
// Per-user seen set persisted in localStorage. Cleared on open, claim, or
|
||||
// explicit dismiss (NOT on hover — Codex correction). The unread dot is
|
||||
// shown for any session id NOT in this set.
|
||||
const [seenIds, setSeenIds] = useState<Set<string>>(() => loadSeenIds())
|
||||
|
||||
const loadQueue = async () => {
|
||||
const markSeen = useCallback((sessionId: string) => {
|
||||
setSeenIds(prev => {
|
||||
if (prev.has(sessionId)) return prev
|
||||
const next = new Set(prev)
|
||||
next.add(sessionId)
|
||||
saveSeenIds(next)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Ref mirrors the latest sessions so the SSE handler can diff without
|
||||
// re-binding on every state change.
|
||||
const sessionsRef = useRef<AISessionSummary[]>([])
|
||||
useEffect(() => {
|
||||
sessionsRef.current = sessions
|
||||
}, [sessions])
|
||||
|
||||
const prefersReducedMotion = useMemo(() => {
|
||||
if (typeof window === 'undefined' || !window.matchMedia) return false
|
||||
return window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
}, [])
|
||||
|
||||
// ── Tab title flash ──
|
||||
// Capture the original title once at mount. While unseen > 0, prefix it.
|
||||
const originalTitleRef = useRef<string>('')
|
||||
useEffect(() => {
|
||||
originalTitleRef.current = document.title
|
||||
return () => {
|
||||
// Restore on unmount so a leftover "(N) ..." prefix doesn't bleed
|
||||
// into the next page.
|
||||
document.title = originalTitleRef.current
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const base = originalTitleRef.current || document.title
|
||||
document.title = unseenCount > 0 ? `(${unseenCount}) ${base}` : base
|
||||
}, [unseenCount])
|
||||
|
||||
useEffect(() => {
|
||||
const clearUnseen = () => {
|
||||
if (!document.hidden) setUnseenCount(0)
|
||||
}
|
||||
const onFocus = () => setUnseenCount(0)
|
||||
document.addEventListener('visibilitychange', clearUnseen)
|
||||
window.addEventListener('focus', onFocus)
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', clearUnseen)
|
||||
window.removeEventListener('focus', onFocus)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const loadQueue = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const data = await aiSessionsApi.getEscalationQueue()
|
||||
// Sort oldest-first — longest waiting = most urgent
|
||||
const sorted = [...data].sort(
|
||||
(a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
|
||||
)
|
||||
const sorted = [...data].sort(sortOldestFirst)
|
||||
setSessions(sorted)
|
||||
setNewIds(new Set())
|
||||
onCountChange?.(sorted.length)
|
||||
} catch {
|
||||
setError('Failed to load escalation queue')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
}, [onCountChange])
|
||||
|
||||
useEffect(() => {
|
||||
loadQueue()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- load once on mount
|
||||
}, [])
|
||||
}, [loadQueue])
|
||||
|
||||
// ── SSE subscription ──
|
||||
// Refetch the queue on each `handoff_created` event (the event payload is
|
||||
// intentionally thin — it's a trigger, not the full card data). Diff
|
||||
// against the previous list to identify newly-arrived sessions; prepend
|
||||
// them at the top with the slide-in animation, then keep the rest of the
|
||||
// queue in oldest-first order below.
|
||||
const handleHandoffCreated = useCallback(async () => {
|
||||
let fresh: AISessionSummary[]
|
||||
try {
|
||||
fresh = await aiSessionsApi.getEscalationQueue()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
const prevIds = new Set(sessionsRef.current.map((s) => s.id))
|
||||
const arrived = fresh.filter((s) => !prevIds.has(s.id)).sort(sortNewestFirst)
|
||||
const established = fresh.filter((s) => prevIds.has(s.id)).sort(sortOldestFirst)
|
||||
const next = [...arrived, ...established]
|
||||
setSessions(next)
|
||||
onCountChange?.(next.length)
|
||||
|
||||
if (arrived.length === 0) return
|
||||
|
||||
const arrivedIds = arrived.map((s) => s.id)
|
||||
setNewIds((prev) => {
|
||||
const merged = new Set(prev)
|
||||
arrivedIds.forEach((id) => merged.add(id))
|
||||
return merged
|
||||
})
|
||||
if (document.hidden) {
|
||||
setUnseenCount((c) => c + arrived.length)
|
||||
}
|
||||
window.setTimeout(() => {
|
||||
setNewIds((prev) => {
|
||||
const remaining = new Set(prev)
|
||||
arrivedIds.forEach((id) => remaining.delete(id))
|
||||
return remaining
|
||||
})
|
||||
}, NEW_CARD_HIGHLIGHT_MS)
|
||||
}, [onCountChange])
|
||||
|
||||
useEffect(() => {
|
||||
const abort = new AbortController()
|
||||
let reconnectTimer: number | null = null
|
||||
let attempt = 0
|
||||
let cancelled = false
|
||||
|
||||
const connect = async () => {
|
||||
if (cancelled) return
|
||||
try {
|
||||
await aiSessionsApi.streamEscalations(
|
||||
{
|
||||
onReady: () => {
|
||||
attempt = 0
|
||||
},
|
||||
onHandoffCreated: () => {
|
||||
void handleHandoffCreated()
|
||||
},
|
||||
},
|
||||
abort.signal,
|
||||
)
|
||||
// Stream ended cleanly (server hung up). Reconnect quickly.
|
||||
if (!cancelled) {
|
||||
reconnectTimer = window.setTimeout(connect, 1000)
|
||||
}
|
||||
} catch (err) {
|
||||
if (cancelled || abort.signal.aborted) return
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return
|
||||
// Exponential backoff: 1s, 2s, 4s, 8s, 16s, capped at 30s.
|
||||
const delay = Math.min(30_000, 1000 * 2 ** attempt)
|
||||
attempt += 1
|
||||
reconnectTimer = window.setTimeout(connect, delay)
|
||||
}
|
||||
}
|
||||
|
||||
void connect()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
abort.abort()
|
||||
if (reconnectTimer !== null) window.clearTimeout(reconnectTimer)
|
||||
}
|
||||
}, [handleHandoffCreated])
|
||||
|
||||
const handlePickup = (sessionId: string) => {
|
||||
markSeen(sessionId)
|
||||
if (onPickup) {
|
||||
onPickup(sessionId)
|
||||
} else {
|
||||
@@ -54,6 +240,14 @@ export function EscalationQueue({ onPickup, onCountChange }: EscalationQueueProp
|
||||
}
|
||||
}
|
||||
|
||||
// Click on the card body (anywhere outside Pick Up) marks the session as
|
||||
// seen — the "open" affordance from the unread-dot spec. Pick Up handles
|
||||
// its own marking via handlePickup. Hover deliberately does NOT clear
|
||||
// (Codex correction).
|
||||
const handleCardOpen = (sessionId: string) => {
|
||||
markSeen(sessionId)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
@@ -95,7 +289,10 @@ export function EscalationQueue({ onPickup, onCountChange }: EscalationQueueProp
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<h3 className="font-sans text-[0.625rem] uppercase tracking-wider text-muted-foreground">
|
||||
<h3
|
||||
className="font-sans text-[0.625rem] uppercase tracking-wider text-muted-foreground"
|
||||
aria-label={`${sessions.length} escalations awaiting pickup`}
|
||||
>
|
||||
Awaiting pickup ({sessions.length})
|
||||
</h3>
|
||||
<button
|
||||
@@ -107,54 +304,80 @@ export function EscalationQueue({ onPickup, onCountChange }: EscalationQueueProp
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{sessions.map((session) => (
|
||||
<div key={session.id} className="card-flat p-3 sm:p-4 space-y-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
{session.problem_summary || 'Untitled session'}
|
||||
</p>
|
||||
{session.escalation_reason && (
|
||||
<p className="mt-1 text-xs text-warning line-clamp-2">
|
||||
Reason: {session.escalation_reason}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||
{session.problem_domain && (
|
||||
<span className="font-sans rounded-md bg-accent-dim px-1.5 py-0.5 text-[0.5625rem] uppercase tracking-wider text-accent-text">
|
||||
{session.problem_domain}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1">
|
||||
<Hash size={10} />
|
||||
{session.step_count} steps
|
||||
</span>
|
||||
<span
|
||||
className="flex items-center gap-1 font-medium"
|
||||
style={{ color: waitTimeColor(session.created_at) }}
|
||||
<div role="region" aria-live="polite" className="space-y-3">
|
||||
{sessions.map((session) => {
|
||||
const isNew = newIds.has(session.id)
|
||||
const isUnread = !seenIds.has(session.id)
|
||||
return (
|
||||
<div
|
||||
key={session.id}
|
||||
onClick={() => handleCardOpen(session.id)}
|
||||
className={cn(
|
||||
'relative card-flat p-3 sm:p-4 space-y-3 cursor-pointer',
|
||||
isNew && !prefersReducedMotion && 'animate-slide-in-bottom',
|
||||
isNew && prefersReducedMotion && 'animate-fade-in',
|
||||
)}
|
||||
>
|
||||
<Clock size={10} />
|
||||
{timeAgo(session.created_at)}
|
||||
</span>
|
||||
{session.psa_ticket_id && (
|
||||
<span className="flex items-center gap-1 text-accent-text">
|
||||
<Ticket size={10} />
|
||||
#{session.psa_ticket_id}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Unread indicator: 6px dot, top-right corner. Cleared on
|
||||
open (card click) or claim (Pick Up). Persists across
|
||||
refresh via localStorage. */}
|
||||
{isUnread && (
|
||||
<span
|
||||
aria-label="Unread escalation"
|
||||
className="absolute top-2 right-2 inline-block w-1.5 h-1.5 rounded-full bg-accent"
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
{session.problem_summary || 'Untitled session'}
|
||||
</p>
|
||||
{session.escalation_reason && (
|
||||
<p className="mt-1 text-xs text-warning line-clamp-2">
|
||||
Reason: {session.escalation_reason}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={() => handlePickup(session.id)}
|
||||
className="rounded-lg bg-primary text-white px-4 py-2 text-sm font-semibold hover:brightness-110 active:scale-[0.98] transition-all"
|
||||
>
|
||||
Pick Up
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||
{session.problem_domain && (
|
||||
<span className="font-sans rounded-md bg-accent-dim px-1.5 py-0.5 text-[0.5625rem] uppercase tracking-wider text-accent-text">
|
||||
{session.problem_domain}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1">
|
||||
<Hash size={10} />
|
||||
{session.step_count} steps
|
||||
</span>
|
||||
<span
|
||||
className="flex items-center gap-1 font-medium"
|
||||
style={{ color: waitTimeColor(session.created_at) }}
|
||||
>
|
||||
<Clock size={10} />
|
||||
{timeAgo(session.created_at)}
|
||||
</span>
|
||||
{session.psa_ticket_id && (
|
||||
<span className="flex items-center gap-1 text-accent-text">
|
||||
<Ticket size={10} />
|
||||
#{session.psa_ticket_id}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handlePickup(session.id)
|
||||
}}
|
||||
className="rounded-lg bg-primary text-white px-4 py-2.5 text-sm font-semibold hover:brightness-110 active:scale-[0.98] transition-all"
|
||||
>
|
||||
Pick Up
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
410
frontend/src/components/flowpilot/HandoffContextScreen.tsx
Normal file
410
frontend/src/components/flowpilot/HandoffContextScreen.tsx
Normal file
@@ -0,0 +1,410 @@
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
Brain,
|
||||
Clock,
|
||||
FileText,
|
||||
Hash,
|
||||
Loader2,
|
||||
Sparkles,
|
||||
Target,
|
||||
User,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import type { HandoffResponse } from '@/types/branching'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { timeAgo } from '@/lib/timeAgo'
|
||||
|
||||
// Magic-moment handoff-context screen. Renders BEFORE the FlowPilot session
|
||||
// view when a senior tech picks up an escalated session, then dissolves on
|
||||
// "Start here". Re-openable via toolbar in FlowPilotSessionPage.
|
||||
//
|
||||
// Four sections per the design plan:
|
||||
// 1. Problem summary (top, Bricolage h2)
|
||||
// 2. What's been tried (left column) — engineer notes + step count.
|
||||
// Full step detail isn't in the handoff snapshot today (snapshot =
|
||||
// problem_summary, problem_domain, status, step_count, confidence_tier
|
||||
// per HandoffManager._generate_snapshot); we surface what's there and
|
||||
// promise the timeline post-pickup. Snapshot expansion is a follow-up.
|
||||
// 3. AI assessment (right column) — likely_cause / suggested_steps /
|
||||
// confidence. Renders gracefully when ai_assessment is null (the 5s
|
||||
// timeout from commit 9bdd995 fired).
|
||||
// 4. Start here (primary CTA, electric-blue, ≥44px) — claims the handoff
|
||||
// and dissolves the screen.
|
||||
|
||||
type ConfidenceTier = 'low' | 'medium' | 'high' | string
|
||||
|
||||
interface HandoffContextScreenProps {
|
||||
handoff: HandoffResponse
|
||||
// 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 }) {
|
||||
if (value === null || value === undefined || value === '') return null
|
||||
// Numeric (0..1) or string tier
|
||||
let tier: ConfidenceTier = 'medium'
|
||||
let label = String(value)
|
||||
if (typeof value === 'number') {
|
||||
tier = value >= 0.7 ? 'high' : value >= 0.4 ? 'medium' : 'low'
|
||||
label = `${Math.round(value * 100)}%`
|
||||
} else {
|
||||
const s = String(value).toLowerCase()
|
||||
if (s === 'low' || s === 'medium' || s === 'high') tier = s
|
||||
label = s.charAt(0).toUpperCase() + s.slice(1)
|
||||
}
|
||||
const tone =
|
||||
tier === 'high'
|
||||
? 'bg-success-dim text-success border border-success/20'
|
||||
: tier === 'low'
|
||||
? 'bg-warning-dim text-warning border border-warning/20'
|
||||
: 'bg-accent-dim text-accent-text border border-accent/20'
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'font-sans rounded-md px-1.5 py-0.5 text-[0.5625rem] uppercase tracking-wider',
|
||||
tone,
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function HandoffContextScreen({
|
||||
handoff,
|
||||
onContinue,
|
||||
onAIAnalysis,
|
||||
onOwnThing,
|
||||
onDismiss,
|
||||
dismissible = false,
|
||||
isProcessing = false,
|
||||
hasTaskLane = false,
|
||||
activeOptionKey = null,
|
||||
}: HandoffContextScreenProps) {
|
||||
const startBtnRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
const prefersReducedMotion = useMemo(() => {
|
||||
if (typeof window === 'undefined' || !window.matchMedia) return false
|
||||
return window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
}, [])
|
||||
|
||||
// Esc dismisses when the screen is re-opened post-claim (dismissible mode).
|
||||
// Pre-claim, Esc has no escape hatch — they must Start here or back out via
|
||||
// browser nav.
|
||||
useEffect(() => {
|
||||
if (!dismissible || !onDismiss) return
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onDismiss()
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [dismissible, onDismiss])
|
||||
|
||||
// Focus the primary CTA on mount so keyboard users can hit Enter.
|
||||
useEffect(() => {
|
||||
startBtnRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
const snapshot = handoff.snapshot as Record<string, unknown>
|
||||
const problemSummary =
|
||||
(snapshot.problem_summary as string | undefined) || 'Untitled session'
|
||||
const problemDomain = snapshot.problem_domain as string | undefined
|
||||
const stepCount = (snapshot.step_count as number | undefined) ?? 0
|
||||
const confidenceTier = snapshot.confidence_tier as string | undefined
|
||||
|
||||
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
|
||||
|
||||
const enterClass = prefersReducedMotion ? 'animate-fade-in' : 'animate-slide-up'
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="handoff-context-title"
|
||||
className={cn(
|
||||
'mx-auto w-full max-w-4xl rounded-2xl border border-default bg-card p-6 sm:p-8 shadow-lg',
|
||||
enterClass,
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-4">
|
||||
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-warning-dim">
|
||||
<Sparkles size={18} className="text-warning" />
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-sans text-[0.625rem] uppercase tracking-wider text-muted-foreground">
|
||||
Escalation handoff
|
||||
</p>
|
||||
<h2
|
||||
id="handoff-context-title"
|
||||
className="font-heading text-xl sm:text-2xl font-semibold text-heading leading-tight"
|
||||
>
|
||||
{problemSummary}
|
||||
</h2>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||
{problemDomain && (
|
||||
<span className="font-sans rounded-md bg-accent-dim px-1.5 py-0.5 text-[0.5625rem] uppercase tracking-wider text-accent-text">
|
||||
{problemDomain}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1">
|
||||
<Hash size={10} />
|
||||
{stepCount} {stepCount === 1 ? 'step' : 'steps'}
|
||||
</span>
|
||||
{confidenceTier && (
|
||||
<span className="font-sans uppercase tracking-wider text-[0.5625rem]">
|
||||
Session confidence: {confidenceTier}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock size={10} />
|
||||
Escalated {timeAgo(handoff.created_at)}
|
||||
</span>
|
||||
{handoff.priority === 'elevated' && (
|
||||
<span className="font-sans rounded-md bg-danger-dim px-1.5 py-0.5 text-[0.5625rem] uppercase tracking-wider text-danger border border-danger/20">
|
||||
Elevated
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{dismissible && onDismiss && (
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
aria-label="Close handoff context"
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-elevated transition-colors"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Two-column body */}
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-2">
|
||||
{/* What's been tried */}
|
||||
<section
|
||||
aria-labelledby="handoff-what-tried"
|
||||
className="card-flat p-4 space-y-3"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText size={14} className="text-muted-foreground" />
|
||||
<h3
|
||||
id="handoff-what-tried"
|
||||
className="font-sans text-[0.625rem] uppercase tracking-wider text-muted-foreground"
|
||||
>
|
||||
What's been tried
|
||||
</h3>
|
||||
</div>
|
||||
{handoff.engineer_notes ? (
|
||||
<div>
|
||||
<p className="font-sans text-[0.625rem] uppercase tracking-wider text-muted-foreground mb-1">
|
||||
Why they escalated
|
||||
</p>
|
||||
<p className="text-sm text-foreground whitespace-pre-wrap">
|
||||
{handoff.engineer_notes}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
No notes from the original engineer.
|
||||
</p>
|
||||
)}
|
||||
<div className="rounded-lg bg-elevated px-3 py-2 text-xs text-muted-foreground">
|
||||
<span className="font-medium text-foreground">{stepCount}</span>{' '}
|
||||
diagnostic {stepCount === 1 ? 'step' : 'steps'} on record. Full
|
||||
timeline opens when you start the session.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* AI assessment */}
|
||||
<section
|
||||
aria-labelledby="handoff-ai-assessment"
|
||||
className="card-flat p-4 space-y-3"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Brain size={14} className="text-muted-foreground" />
|
||||
<h3
|
||||
id="handoff-ai-assessment"
|
||||
className="font-sans text-[0.625rem] uppercase tracking-wider text-muted-foreground"
|
||||
>
|
||||
AI assessment
|
||||
</h3>
|
||||
</div>
|
||||
<ConfidenceBadge value={assessmentConfidence} />
|
||||
</div>
|
||||
|
||||
{!assessmentText && !likelyCause && suggestedSteps.length === 0 ? (
|
||||
<div className="flex items-start gap-2 rounded-lg bg-elevated px-3 py-3 text-xs text-muted-foreground">
|
||||
<AlertTriangle size={12} className="mt-0.5 shrink-0 text-warning" />
|
||||
<span>
|
||||
AI assessment is still generating. Reopen this view in a few
|
||||
seconds to see it, or pick up the session to investigate
|
||||
directly.
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{likelyCause && (
|
||||
<div>
|
||||
<p className="font-sans text-[0.625rem] uppercase tracking-wider text-muted-foreground mb-1">
|
||||
Likely cause
|
||||
</p>
|
||||
<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}
|
||||
</p>
|
||||
)}
|
||||
{suggestedSteps.length > 0 && (
|
||||
<div>
|
||||
<p className="font-sans text-[0.625rem] uppercase tracking-wider text-muted-foreground mb-1.5">
|
||||
Suggested next steps
|
||||
</p>
|
||||
<ul className="space-y-1.5">
|
||||
{suggestedSteps.map((step, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="flex items-start gap-2 text-sm text-foreground"
|
||||
>
|
||||
<Target
|
||||
size={12}
|
||||
className="mt-1 shrink-0 text-accent-text"
|
||||
/>
|
||||
<span>{step}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* CTA footer */}
|
||||
{dismissible ? (
|
||||
// Post-claim re-open from toolbar — single close action
|
||||
<div className="mt-6 flex justify-end">
|
||||
<button
|
||||
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"
|
||||
>
|
||||
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'll take it from here</span>
|
||||
<span className="text-xs text-muted-foreground">I know what to try</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -9,7 +9,9 @@ export { AISessionListItem } from './AISessionListItem'
|
||||
export { SessionTicketCard } from './SessionTicketCard'
|
||||
export { EscalateModal } from './EscalateModal'
|
||||
export { EscalationQueue } from './EscalationQueue'
|
||||
export { EscalationMetricCard } from './EscalationMetricCard'
|
||||
export { SessionBriefing } from './SessionBriefing'
|
||||
export { HandoffContextScreen } from './HandoffContextScreen'
|
||||
export { ProposalCard } from './ProposalCard'
|
||||
export { ProposalDetail } from './ProposalDetail'
|
||||
export { InSessionScriptGenerator } from './InSessionScriptGenerator'
|
||||
|
||||
@@ -168,7 +168,7 @@ export function useFlowPilotSession(): UseFlowPilotSession {
|
||||
setIsProcessing(true)
|
||||
try {
|
||||
const result = await aiSessionsApi.escalateSession(session.id, data)
|
||||
setSession(prev => prev ? { ...prev, status: 'requesting_escalation' } : null)
|
||||
setSession(prev => prev ? { ...prev, status: 'escalated' } : null)
|
||||
setDocumentation(result.documentation)
|
||||
setPsaPushStatus(result.psa_push_status)
|
||||
setPsaPushError(result.psa_push_error)
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { useLocation, useNavigate, useParams } from 'react-router-dom'
|
||||
import { Sparkles, Send, Loader2, MessageSquare, Paperclip, Terminal, X, RotateCcw, ImagePlus, ListChecks, FileText, CheckCircle2, ArrowUpRight, MoreHorizontal, Pause, Plus } from 'lucide-react'
|
||||
import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||
import axios from 'axios'
|
||||
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, 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'
|
||||
@@ -63,6 +68,29 @@ export default function AssistantChatPage() {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const { sessionId: urlSessionId } = useParams<{ sessionId?: string }>()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const isPickup = searchParams.get('pickup') === 'true'
|
||||
// Magic-moment handoff-context screen — shown BEFORE the regular chat view
|
||||
// when a senior tech picks up an escalated session via /pilot/:id?pickup=true.
|
||||
// Pre-claim, the senior isn't yet escalated_to_id, so we route around the
|
||||
// regular selectChat path until claim succeeds. "Start here" calls the
|
||||
// /handoffs/{id}/claim endpoint which flips status to active and sets
|
||||
// escalated_to_id; then we drop ?pickup=true and let selectChat run.
|
||||
const [magicState, setMagicState] = useState<'inactive' | 'loading' | 'visible' | 'dismissed'>(
|
||||
isPickup ? 'loading' : 'inactive',
|
||||
)
|
||||
const [magicHandoff, setMagicHandoff] = useState<HandoffResponse | null>(null)
|
||||
const [overlayHandoff, setOverlayHandoff] = useState<HandoffResponse | null>(null)
|
||||
const [overlayLoading, setOverlayLoading] = 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
|
||||
@@ -79,7 +107,21 @@ export default function AssistantChatPage() {
|
||||
const [logContent, setLogContent] = useState('')
|
||||
const [pendingUploads, setPendingUploads] = useState<PendingUpload[]>([])
|
||||
const [isDragOver, setIsDragOver] = useState(false)
|
||||
// Task-lane mount restoration is gated on (a) the persisted chatId
|
||||
// matching whatever activeChatId resolved to, AND (b) the page not being
|
||||
// entered with a prefill in location.state. The prefill case means we're
|
||||
// about to create a brand-new session and discard the previous one's
|
||||
// task lane anyway — restoring it just causes the previous chat's
|
||||
// questions/actions to flash on the first paint before sendPrefill's
|
||||
// resetSessionDerivedState clears them. Same logic for the bell-icon
|
||||
// pickup flow (?pickup=true): the senior is entering an unrelated
|
||||
// session and any leftover task-lane meta from their own prior chat is
|
||||
// noise. Both gates collapse to "are we about to leave the previous
|
||||
// chat behind?" — if yes, start clean.
|
||||
const incomingPrefill = !!(location.state as { prefill?: string } | null)?.prefill
|
||||
const skipTaskLaneRestore = incomingPrefill || isPickup
|
||||
const [activeQuestions, setActiveQuestions] = useState<QuestionItem[]>(() => {
|
||||
if (skipTaskLaneRestore) return []
|
||||
try {
|
||||
const saved = sessionStorage.getItem('rf-tasklane-meta')
|
||||
if (saved) { const d = JSON.parse(saved); if (d.chatId === activeChatId) return d.questions || [] }
|
||||
@@ -87,6 +129,7 @@ export default function AssistantChatPage() {
|
||||
return []
|
||||
})
|
||||
const [activeActions, setActiveActions] = useState<ActionItem[]>(() => {
|
||||
if (skipTaskLaneRestore) return []
|
||||
try {
|
||||
const saved = sessionStorage.getItem('rf-tasklane-meta')
|
||||
if (saved) { const d = JSON.parse(saved); if (d.chatId === activeChatId) return d.actions || [] }
|
||||
@@ -94,12 +137,31 @@ export default function AssistantChatPage() {
|
||||
return []
|
||||
})
|
||||
const [showTaskLane, setShowTaskLane] = useState(() => {
|
||||
if (skipTaskLaneRestore) return false
|
||||
try {
|
||||
const saved = sessionStorage.getItem('rf-tasklane-meta')
|
||||
if (saved) { const d = JSON.parse(saved); return d.show === true && d.chatId === activeChatId }
|
||||
} catch { /* ignore */ }
|
||||
return false
|
||||
})
|
||||
// Task-lane owner: the chatId these in-memory questions/actions/show
|
||||
// values BELONG to, set every time we populate the lane. Render is gated
|
||||
// on `taskLaneOwnerChatId === activeChatId` so any path that flips the
|
||||
// active chat without clearing the lane state (in-place URL change,
|
||||
// mid-flight pickup, etc.) cannot leak the previous chat's task data
|
||||
// into the new view. The mount-time flash protection still lives in
|
||||
// `skipTaskLaneRestore`; this guard handles every other transition.
|
||||
const [taskLaneOwnerChatId, setTaskLaneOwnerChatId] = useState<string | null>(() => {
|
||||
if (skipTaskLaneRestore) return null
|
||||
try {
|
||||
const saved = sessionStorage.getItem('rf-tasklane-meta')
|
||||
if (saved) {
|
||||
const d = JSON.parse(saved)
|
||||
if (typeof d.chatId === 'string' && d.chatId === activeChatId) return d.chatId
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return null
|
||||
})
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(() =>
|
||||
localStorage.getItem('rf-chat-sidebar-collapsed') === 'true'
|
||||
)
|
||||
@@ -196,6 +258,14 @@ export default function AssistantChatPage() {
|
||||
// Tracks the most recently requested active chat ID so in-flight selectChat
|
||||
// calls that complete after the user switches chats don't clobber new state.
|
||||
const currentChatRef = useRef<string | null>(activeChatId)
|
||||
// Tracks which URL chatIds we've already loaded via selectChat in this
|
||||
// page lifecycle. Replaces the old `urlSessionId === activeChatId` gate,
|
||||
// which was buggy after commit 8914391 made activeChatId initialize from
|
||||
// urlSessionId — they MATCH on mount, so the gate bailed and selectChat
|
||||
// never fired for fresh entries (notably the bell-icon → ?pickup=true
|
||||
// path: post-claim the chat surface had no messages and the senior
|
||||
// landed on a blank pane).
|
||||
const loadedChatIdsRef = useRef<Set<string>>(new Set())
|
||||
|
||||
// Persist active chat ID to sessionStorage
|
||||
useEffect(() => {
|
||||
@@ -210,12 +280,205 @@ export default function AssistantChatPage() {
|
||||
loadChats()
|
||||
}, [])
|
||||
|
||||
// If URL has a session ID, load it
|
||||
// If URL has a session ID, load it. While the magic-moment handoff-context
|
||||
// screen is loading or visible, skip selectChat — the senior doesn't yet
|
||||
// own the session and the regular chat surface would race against the
|
||||
// claim flow. Once magicState is 'dismissed' (post-claim, or no handoff
|
||||
// found at all), this effect re-fires and selectChat runs.
|
||||
//
|
||||
// The dedupe is on a "have we loaded this URL session yet" ref instead
|
||||
// of comparing to activeChatId — activeChatId now initializes from
|
||||
// urlSessionId, so the old comparison short-circuited fresh mounts and
|
||||
// selectChat never fired. The ref clears nothing on its own; if you
|
||||
// need to force a reload, call selectChat directly.
|
||||
useEffect(() => {
|
||||
if (urlSessionId && urlSessionId !== activeChatId) {
|
||||
selectChat(urlSessionId)
|
||||
if (!urlSessionId) return
|
||||
if (magicState === 'loading' || magicState === 'visible') return
|
||||
if (loadedChatIdsRef.current.has(urlSessionId)) return
|
||||
loadedChatIdsRef.current.add(urlSessionId)
|
||||
selectChat(urlSessionId)
|
||||
}, [urlSessionId, magicState]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Pickup mode entry: fetch the handoff list (account-scoped via RLS, no
|
||||
// claim required) to find the latest unclaimed escalate handoff. If found,
|
||||
// render the magic-moment screen. If none found (legacy sessions
|
||||
// pre-unification, or the handoff was already claimed by another senior),
|
||||
// dismiss and let the regular chat surface load.
|
||||
useEffect(() => {
|
||||
if (!isPickup || !urlSessionId || magicState !== 'loading') return
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
try {
|
||||
const handoffs = await handoffsApi.listHandoffs(urlSessionId)
|
||||
if (cancelled) return
|
||||
const target = handoffs.find(h => h.intent === 'escalate' && !h.claimed_by)
|
||||
if (target) {
|
||||
setMagicHandoff(target)
|
||||
setMagicState('visible')
|
||||
} else {
|
||||
setMagicState('dismissed')
|
||||
// Strip ?pickup=true so a refresh doesn't re-enter the loading
|
||||
// state needlessly.
|
||||
setSearchParams({})
|
||||
}
|
||||
} catch {
|
||||
if (cancelled) return
|
||||
setMagicState('dismissed')
|
||||
setSearchParams({})
|
||||
}
|
||||
})()
|
||||
return () => { cancelled = true }
|
||||
}, [isPickup, urlSessionId, magicState, 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]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [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) {
|
||||
setOverlayHandoff(magicHandoff)
|
||||
return
|
||||
}
|
||||
setOverlayLoading(true)
|
||||
try {
|
||||
const handoffs = await handoffsApi.listHandoffs(activeChatId)
|
||||
const target = handoffs.find(h => h.intent === 'escalate')
|
||||
if (target) {
|
||||
setOverlayHandoff(target)
|
||||
} else {
|
||||
toast.info('No handoff context available for this session.')
|
||||
}
|
||||
} catch {
|
||||
toast.error('Could not load handoff context')
|
||||
} finally {
|
||||
setOverlayLoading(false)
|
||||
}
|
||||
}, [activeChatId, magicHandoff])
|
||||
|
||||
// Live-refresh the magic-moment / overlay handoff when the background AI
|
||||
// enrichment finishes. The backend publishes `handoff_assessment_ready` on
|
||||
// the escalation bus when `enrich_escalation_async` commits the assessment.
|
||||
// We subscribe while we have a handoff that is still missing its assessment
|
||||
// (the placeholder "still generating" state); on a matching event, refetch
|
||||
// the handoff list and replace state in place. The senior sees the AI
|
||||
// assessment populate without having to manually reopen the overlay.
|
||||
//
|
||||
// Account-scoped at the backend (only handoff.account_id subscribers are
|
||||
// notified). Single subscription regardless of which view (pre-claim screen
|
||||
// or post-claim overlay) is showing — both states key off the same handoff.
|
||||
const trackedHandoffId = magicHandoff?.id ?? overlayHandoff?.id ?? null
|
||||
const trackedSessionId = magicHandoff?.session_id ?? overlayHandoff?.session_id ?? null
|
||||
const assessmentMissing =
|
||||
!!trackedHandoffId &&
|
||||
!((magicHandoff ?? overlayHandoff)?.ai_assessment) &&
|
||||
!((magicHandoff ?? overlayHandoff)?.ai_assessment_data)
|
||||
|
||||
useEffect(() => {
|
||||
if (!assessmentMissing || !trackedHandoffId || !trackedSessionId) return
|
||||
const abort = new AbortController()
|
||||
let reconnectTimer: number | null = null
|
||||
let attempt = 0
|
||||
let cancelled = false
|
||||
|
||||
const refetch = async () => {
|
||||
try {
|
||||
const handoffs = await handoffsApi.listHandoffs(trackedSessionId)
|
||||
const fresh = handoffs.find(h => h.id === trackedHandoffId)
|
||||
if (!fresh || cancelled) return
|
||||
setMagicHandoff(prev => (prev && prev.id === fresh.id ? fresh : prev))
|
||||
setOverlayHandoff(prev => (prev && prev.id === fresh.id ? fresh : prev))
|
||||
} catch {
|
||||
// best-effort; the user can manually reopen
|
||||
}
|
||||
}
|
||||
|
||||
const connect = async () => {
|
||||
if (cancelled) return
|
||||
try {
|
||||
await aiSessionsApi.streamEscalations(
|
||||
{
|
||||
onReady: () => { attempt = 0 },
|
||||
onAssessmentReady: (event) => {
|
||||
if (event.handoff_id !== trackedHandoffId) return
|
||||
void refetch()
|
||||
},
|
||||
},
|
||||
abort.signal,
|
||||
)
|
||||
if (!cancelled) reconnectTimer = window.setTimeout(connect, 1000)
|
||||
} catch (err) {
|
||||
if (cancelled || abort.signal.aborted) return
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return
|
||||
const delay = Math.min(30_000, 1000 * 2 ** attempt)
|
||||
attempt += 1
|
||||
reconnectTimer = window.setTimeout(connect, delay)
|
||||
}
|
||||
}
|
||||
|
||||
void connect()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
abort.abort()
|
||||
if (reconnectTimer !== null) window.clearTimeout(reconnectTimer)
|
||||
}
|
||||
}, [assessmentMissing, trackedHandoffId, trackedSessionId])
|
||||
|
||||
// Restore session from sessionStorage on mount (when URL has no session ID)
|
||||
useEffect(() => {
|
||||
@@ -289,6 +552,7 @@ export default function AssistantChatPage() {
|
||||
setActiveQuestions(response.questions || [])
|
||||
setActiveActions(response.actions || [])
|
||||
setShowTaskLane(true)
|
||||
setTaskLaneOwnerChatId(session.session_id)
|
||||
}
|
||||
// Refetch facts + active fix — the AI may have emitted markers.
|
||||
refreshSessionDerived(session.session_id)
|
||||
@@ -303,17 +567,31 @@ export default function AssistantChatPage() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// Persist task lane metadata to sessionStorage
|
||||
// Render gate: the in-memory task-lane data is shown only when the chatId
|
||||
// it belongs to (taskLaneOwnerChatId) matches activeChatId. Any path that
|
||||
// flips activeChatId without clearing the lane state — in-place URL
|
||||
// navigation, mid-flight pickup, HMR — produces a window where ownerChatId
|
||||
// still tags the previous chat. The render gate keeps the lane hidden
|
||||
// through that window until reset+repopulate runs for the new chat.
|
||||
const taskLaneIsForActiveChat =
|
||||
taskLaneOwnerChatId !== null && taskLaneOwnerChatId === activeChatId
|
||||
|
||||
// Persist task lane metadata to sessionStorage. The chatId field tags
|
||||
// ownership — the chatId these questions/actions belong to, NOT the
|
||||
// currently-active chat. Writing activeChatId here was the original bug:
|
||||
// when activeChatId flipped to B but activeQuestions still had A's data,
|
||||
// the snapshot stamped {chatId: B, questions: [A's]} and a subsequent
|
||||
// restore would happily render A's data for B.
|
||||
useEffect(() => {
|
||||
try {
|
||||
sessionStorage.setItem('rf-tasklane-meta', JSON.stringify({
|
||||
show: showTaskLane,
|
||||
chatId: activeChatId,
|
||||
chatId: taskLaneOwnerChatId,
|
||||
questions: activeQuestions,
|
||||
actions: activeActions,
|
||||
}))
|
||||
} catch { /* ignore */ }
|
||||
}, [showTaskLane, activeChatId, activeQuestions, activeActions])
|
||||
}, [showTaskLane, taskLaneOwnerChatId, activeQuestions, activeActions])
|
||||
|
||||
// Auto-scroll
|
||||
useEffect(() => {
|
||||
@@ -345,6 +623,9 @@ export default function AssistantChatPage() {
|
||||
pinned: false,
|
||||
created_at: s.created_at,
|
||||
updated_at: s.created_at,
|
||||
problem_summary: s.problem_summary,
|
||||
psa_ticket_id: s.psa_ticket_id,
|
||||
status: s.status,
|
||||
})))
|
||||
} catch {
|
||||
// silently handle
|
||||
@@ -366,6 +647,7 @@ export default function AssistantChatPage() {
|
||||
setShowTaskLane(false)
|
||||
setActiveQuestions([])
|
||||
setActiveActions([])
|
||||
setTaskLaneOwnerChatId(null)
|
||||
setFacts([])
|
||||
setActiveFix(null)
|
||||
setPreviewKind(null)
|
||||
@@ -381,6 +663,16 @@ export default function AssistantChatPage() {
|
||||
// Phase 9: tab strip reset
|
||||
setChatTab('chat')
|
||||
setScriptBuilderHasProgress(false)
|
||||
// Belt-and-braces: also wipe the persisted task-lane meta. Without this,
|
||||
// a remount or page reload before the next AI response can re-hydrate
|
||||
// the previous session's questions/actions from sessionStorage even
|
||||
// though the in-memory state has been cleared. The persistence effect
|
||||
// re-saves on the next state change anyway, so the only window where
|
||||
// sessionStorage is empty is between this reset and the next response —
|
||||
// which is exactly the window where stale-tag leakage was happening.
|
||||
try {
|
||||
sessionStorage.removeItem('rf-tasklane-meta')
|
||||
} catch { /* ignore */ }
|
||||
}, [])
|
||||
|
||||
// Phase 2 facts — fetch + handlers. `refreshFacts` is called from selectChat
|
||||
@@ -396,7 +688,12 @@ export default function AssistantChatPage() {
|
||||
// Auto-open the task lane when the session has facts so the engineer
|
||||
// can see them — without this, a session with only facts (no open
|
||||
// questions) would hide the lane and the facts would be invisible.
|
||||
if (list.length > 0) setShowTaskLane(true)
|
||||
// Tag ownership too so the lane render gate accepts it as belonging
|
||||
// to the active chat (the gate is `taskLaneOwnerChatId === activeChatId`).
|
||||
if (list.length > 0) {
|
||||
setShowTaskLane(true)
|
||||
setTaskLaneOwnerChatId(chatId)
|
||||
}
|
||||
} catch {
|
||||
// Best-effort — facts are accessory state. Surfacing a toast on every
|
||||
// refetch failure would be noisy; the empty state explains the absence.
|
||||
@@ -569,7 +866,10 @@ export default function AssistantChatPage() {
|
||||
// TemplateMatchPanel is mounted inside TaskLane.bottomSlot, so the
|
||||
// lane must be visible for the panel to render. On fresh sessions
|
||||
// (no questions/facts) the lane defaults closed, so we open it here.
|
||||
// Tag ownership to the current active chat so the lane render gate
|
||||
// (taskLaneOwnerChatId === activeChatId) accepts it.
|
||||
setShowTaskLane(true)
|
||||
if (activeChatId) setTaskLaneOwnerChatId(activeChatId)
|
||||
setScriptPanelOpen(true)
|
||||
return
|
||||
}
|
||||
@@ -836,6 +1136,7 @@ export default function AssistantChatPage() {
|
||||
setActiveQuestions(q)
|
||||
setActiveActions(a)
|
||||
setShowTaskLane(true)
|
||||
setTaskLaneOwnerChatId(chatId)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -843,6 +1144,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
|
||||
@@ -903,6 +1288,7 @@ export default function AssistantChatPage() {
|
||||
.map((u) => u.preview)
|
||||
setInput('')
|
||||
setPendingUploads([])
|
||||
setChipsHidden(true)
|
||||
setMessages(prev => [...prev, { role: 'user', content: userMessage, imageUrls: imageUrls.length > 0 ? imageUrls : undefined }])
|
||||
setLoading(true)
|
||||
|
||||
@@ -938,6 +1324,7 @@ export default function AssistantChatPage() {
|
||||
setActiveQuestions(response.questions || [])
|
||||
setActiveActions(response.actions || [])
|
||||
setShowTaskLane(true)
|
||||
setTaskLaneOwnerChatId(sentForChatId)
|
||||
}
|
||||
// Phase 8: increment post-apply message counter for nudge logic.
|
||||
// Only increments when fix is still in 'proposed' (verifying) state —
|
||||
@@ -1018,11 +1405,13 @@ export default function AssistantChatPage() {
|
||||
setActiveQuestions(response.questions || [])
|
||||
setActiveActions(response.actions || [])
|
||||
setShowTaskLane(true)
|
||||
setTaskLaneOwnerChatId(sentForChatId)
|
||||
} else {
|
||||
// AI sent no new tasks — clear the lane
|
||||
setShowTaskLane(false)
|
||||
setActiveQuestions([])
|
||||
setActiveActions([])
|
||||
setTaskLaneOwnerChatId(null)
|
||||
}
|
||||
// Phase 8: increment post-apply message counter for nudge logic (mirrors handleSend).
|
||||
// Only increments in 'proposed' (verifying) state — same rationale as handleSend.
|
||||
@@ -1117,6 +1506,7 @@ export default function AssistantChatPage() {
|
||||
setActiveQuestions(response.questions || [])
|
||||
setActiveActions(response.actions || [])
|
||||
setShowTaskLane(true)
|
||||
setTaskLaneOwnerChatId(session.session_id)
|
||||
}
|
||||
// Refetch facts + active fix — resume turn may emit markers.
|
||||
refreshSessionDerived(session.session_id)
|
||||
@@ -1234,6 +1624,39 @@ export default function AssistantChatPage() {
|
||||
// Cleanup blob URLs on unmount
|
||||
useEffect(() => { return () => { pendingUploads.forEach((u) => { if (u.preview) URL.revokeObjectURL(u.preview) }) } }, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Magic-moment handoff-context screen — full-page take-over before claim.
|
||||
// Loading state shows a centered spinner. Visible state shows the screen
|
||||
// with the handoff payload; "Start here" claims and dismisses, after which
|
||||
// the regular chat surface renders.
|
||||
if (magicState === 'loading') {
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="Picking up session…" />
|
||||
<div className="flex h-[calc(100vh-3.5rem)] items-center justify-center">
|
||||
<Loader2 size={24} className="animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
if (magicState === 'visible' && magicHandoff) {
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="Escalation handoff" />
|
||||
<div className="h-[calc(100vh-3.5rem)] overflow-y-auto p-4 sm:p-8">
|
||||
<HandoffContextScreen
|
||||
handoff={magicHandoff}
|
||||
onContinue={handleContinue}
|
||||
onAIAnalysis={handleAIAnalysis}
|
||||
onOwnThing={handleOwnThing}
|
||||
isProcessing={activeOptionKey !== null}
|
||||
hasTaskLane={activeActions.length > 0 || activeQuestions.length > 0}
|
||||
activeOptionKey={activeOptionKey}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="AI Assistant" />
|
||||
@@ -1332,6 +1755,17 @@ export default function AssistantChatPage() {
|
||||
|
||||
{/* Desktop actions — shown when session is active and has messages */}
|
||||
<div className="hidden sm:flex items-center gap-1.5">
|
||||
{magicHandoff && (
|
||||
<button
|
||||
onClick={openHandoffContextOverlay}
|
||||
disabled={overlayLoading}
|
||||
title="Show the handoff context the original engineer sent"
|
||||
className="flex items-center gap-1.5 rounded-lg border border-default px-3 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground hover:border-hover disabled:opacity-40 transition-colors"
|
||||
>
|
||||
<Sparkles size={13} />
|
||||
Context
|
||||
</button>
|
||||
)}
|
||||
{activePsaTicketId && (
|
||||
<button
|
||||
onClick={() => { setSpinOffHint(undefined); setShowNewTicket(true) }}
|
||||
@@ -1557,6 +1991,143 @@ export default function AssistantChatPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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 &&
|
||||
(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
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Rich Input */}
|
||||
<div className="px-3 sm:px-6 py-3 shrink-0">
|
||||
<div
|
||||
@@ -1659,7 +2230,7 @@ export default function AssistantChatPage() {
|
||||
<span className="hidden sm:inline">Paste Logs</span>
|
||||
</button>
|
||||
)}
|
||||
{!showTaskLane && (activeQuestions.length > 0 || activeActions.length > 0) && (
|
||||
{!showTaskLane && taskLaneIsForActiveChat && (activeQuestions.length > 0 || activeActions.length > 0) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowTaskLane(true)}
|
||||
@@ -1732,6 +2303,7 @@ export default function AssistantChatPage() {
|
||||
Shows a count pill when new items are present while closed. */}
|
||||
{isNarrow
|
||||
&& !showTaskLane
|
||||
&& taskLaneIsForActiveChat
|
||||
&& (activeQuestions.length > 0 || activeActions.length > 0 || facts.length > 0 || activeFix !== null) && (
|
||||
<button
|
||||
onClick={() => setShowTaskLane(true)}
|
||||
@@ -1753,7 +2325,7 @@ export default function AssistantChatPage() {
|
||||
Phase 2/3 make the lane the structural home of session diagnostic
|
||||
state, not a transient questions panel.
|
||||
Narrow viewport: the lane renders as a bottom drawer with backdrop. */}
|
||||
{showTaskLane && (activeQuestions.length > 0 || activeActions.length > 0 || facts.length > 0 || activeFix !== null) && (
|
||||
{showTaskLane && taskLaneIsForActiveChat && (activeQuestions.length > 0 || activeActions.length > 0 || facts.length > 0 || activeFix !== null) && (
|
||||
isNarrow ? (
|
||||
<div className="fixed inset-0 z-50 flex flex-col" role="dialog" aria-modal="true">
|
||||
<div
|
||||
@@ -1911,7 +2483,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'}
|
||||
@@ -1963,6 +2541,22 @@ export default function AssistantChatPage() {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Handoff context overlay — re-opened from the toolbar */}
|
||||
{overlayHandoff && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/60 backdrop-blur-sm p-4 sm:p-8 animate-fade-in"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) setOverlayHandoff(null)
|
||||
}}
|
||||
>
|
||||
<HandoffContextScreen
|
||||
handoff={overlayHandoff}
|
||||
onDismiss={() => setOverlayHandoff(null)}
|
||||
dismissible
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
import { EscalationQueue } from '@/components/flowpilot'
|
||||
import { EscalationQueue, EscalationMetricCard } from '@/components/flowpilot'
|
||||
|
||||
export default function EscalationQueuePage() {
|
||||
const [count, setCount] = useState<number | null>(null)
|
||||
@@ -21,6 +21,8 @@ export default function EscalationQueuePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EscalationMetricCard period="30d" />
|
||||
|
||||
<EscalationQueue onCountChange={setCount} />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useParams, useSearchParams, useLocation, useBlocker, useNavigate } from
|
||||
import { Sparkles, Loader2, AlertTriangle, CheckCircle2, ArrowUpRight, FileText, MoreHorizontal, Pause, X } from 'lucide-react'
|
||||
import { useFlowPilotSession } from '@/hooks/useFlowPilotSession'
|
||||
import { useBranching } from '@/hooks/useBranching'
|
||||
import { FlowPilotIntake, FlowPilotSession, SessionBriefing } from '@/components/flowpilot'
|
||||
import { FlowPilotIntake, FlowPilotSession, SessionBriefing, HandoffContextScreen } from '@/components/flowpilot'
|
||||
import { EscalateModal } from '@/components/flowpilot/EscalateModal'
|
||||
import { StatusUpdateModal } from '@/components/flowpilot/StatusUpdateModal'
|
||||
import { HandoffModal } from '@/components/session/HandoffModal'
|
||||
@@ -11,6 +11,7 @@ import { handoffsApi } from '@/api/handoffs'
|
||||
import { aiSessionsApi } from '@/api'
|
||||
import { integrationsApi } from '@/api/integrations'
|
||||
import type { PSATicketInfo } from '@/types/integrations'
|
||||
import type { HandoffResponse } from '@/types/branching'
|
||||
import { toast } from '@/lib/toast'
|
||||
|
||||
export default function FlowPilotSessionPage() {
|
||||
@@ -76,12 +77,95 @@ export default function FlowPilotSessionPage() {
|
||||
|
||||
const [pickingUp, setPickingUp] = useState(false)
|
||||
|
||||
// Load existing session if ID in URL
|
||||
// ── Magic-moment handoff-context screen ──
|
||||
// When the senior arrives via /pilot/:id?pickup=true, the regular session
|
||||
// GET 404s pre-claim (the senior isn't yet escalated_to_id). So we fetch
|
||||
// the handoff list first (account-scoped via RLS, no claim required), find
|
||||
// the most recent unclaimed escalate handoff, and render the magic-moment
|
||||
// screen. "Start here" claims the handoff, then loadSession fires.
|
||||
const [magicState, setMagicState] = useState<'inactive' | 'loading' | 'visible' | 'dismissed'>(
|
||||
isPickup ? 'loading' : 'inactive',
|
||||
)
|
||||
const [magicHandoff, setMagicHandoff] = useState<HandoffResponse | null>(null)
|
||||
const [overlayHandoff, setOverlayHandoff] = useState<HandoffResponse | null>(null)
|
||||
const [overlayLoading, setOverlayLoading] = useState(false)
|
||||
const [claiming, setClaiming] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (sessionId && !fp.session) {
|
||||
if (!isPickup || !sessionId || magicState !== 'loading') return
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
try {
|
||||
const handoffs = await handoffsApi.listHandoffs(sessionId)
|
||||
if (cancelled) return
|
||||
// Newest unclaimed escalate handoff. listHandoffs orders desc by
|
||||
// created_at on the backend, so .find() picks the latest.
|
||||
const target = handoffs.find((h) => h.intent === 'escalate' && !h.claimed_by)
|
||||
if (target) {
|
||||
setMagicHandoff(target)
|
||||
setMagicState('visible')
|
||||
} else {
|
||||
setMagicState('dismissed')
|
||||
}
|
||||
} catch {
|
||||
if (cancelled) return
|
||||
// Fall through to the legacy SessionBriefing path on failure.
|
||||
setMagicState('dismissed')
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [isPickup, sessionId, magicState])
|
||||
|
||||
// Load existing session if ID in URL. Skip while the magic-moment screen is
|
||||
// up — we don't have access to the session detail until claim.
|
||||
useEffect(() => {
|
||||
if (sessionId && !fp.session && magicState !== 'loading' && magicState !== 'visible') {
|
||||
fp.loadSession(sessionId)
|
||||
}
|
||||
}, [sessionId]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [sessionId, magicState]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleStartHere = async () => {
|
||||
if (!sessionId || !magicHandoff) return
|
||||
setClaiming(true)
|
||||
try {
|
||||
await handoffsApi.claimHandoff(sessionId, magicHandoff.id)
|
||||
// Drop the pickup query param and dismiss the screen — the loadSession
|
||||
// effect above will fire because magicState is no longer 'visible'.
|
||||
setSearchParams({})
|
||||
setMagicState('dismissed')
|
||||
} catch (e: unknown) {
|
||||
const message = e instanceof Error ? e.message : 'Failed to pick up session'
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setClaiming(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openHandoffContextOverlay = async () => {
|
||||
if (!sessionId) return
|
||||
// Reuse the in-memory copy when we already loaded the handoff during
|
||||
// pickup, otherwise fetch on demand.
|
||||
if (magicHandoff) {
|
||||
setOverlayHandoff(magicHandoff)
|
||||
return
|
||||
}
|
||||
setOverlayLoading(true)
|
||||
try {
|
||||
const handoffs = await handoffsApi.listHandoffs(sessionId)
|
||||
const target = handoffs.find((h) => h.intent === 'escalate')
|
||||
if (target) {
|
||||
setOverlayHandoff(target)
|
||||
} else {
|
||||
toast.info('No handoff context available for this session.')
|
||||
}
|
||||
} catch {
|
||||
toast.error('Could not load handoff context')
|
||||
} finally {
|
||||
setOverlayLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Load branches when session is branching
|
||||
useEffect(() => {
|
||||
@@ -133,6 +217,28 @@ export default function FlowPilotSessionPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// Magic-moment handoff-context screen — shown before the senior tech claims
|
||||
// an escalated session. Takes priority over session loading because the
|
||||
// senior can't load the session detail until claim succeeds.
|
||||
if (magicState === 'loading') {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[50vh]">
|
||||
<Loader2 size={24} className="animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (magicState === 'visible' && magicHandoff) {
|
||||
return (
|
||||
<div className="h-full overflow-y-auto p-4 sm:p-8">
|
||||
<HandoffContextScreen
|
||||
handoff={magicHandoff}
|
||||
onStartHere={handleStartHere}
|
||||
isProcessing={claiming}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (fp.error && !fp.session) {
|
||||
return (
|
||||
@@ -273,6 +379,17 @@ export default function FlowPilotSessionPage() {
|
||||
<>
|
||||
{/* Desktop actions */}
|
||||
<div className="hidden sm:flex items-center gap-1.5">
|
||||
{magicHandoff && (
|
||||
<button
|
||||
onClick={openHandoffContextOverlay}
|
||||
disabled={overlayLoading}
|
||||
title="Show the handoff context the original engineer sent"
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border-default px-3 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground hover:border-border-hover disabled:opacity-40 transition-colors"
|
||||
>
|
||||
<Sparkles size={13} />
|
||||
Context
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowResolve(true)}
|
||||
disabled={!fp.canResolve || fp.isProcessing}
|
||||
@@ -434,6 +551,23 @@ export default function FlowPilotSessionPage() {
|
||||
|
||||
{/* ── Page-level modals (moved from action bar) ── */}
|
||||
|
||||
{/* Handoff context overlay — re-opened from the toolbar */}
|
||||
{overlayHandoff && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/60 backdrop-blur-sm p-4 sm:p-8 animate-fade-in"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) setOverlayHandoff(null)
|
||||
}}
|
||||
>
|
||||
<HandoffContextScreen
|
||||
handoff={overlayHandoff}
|
||||
onStartHere={() => {}}
|
||||
onDismiss={() => setOverlayHandoff(null)}
|
||||
dismissible
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Resolve modal */}
|
||||
{showResolve && (
|
||||
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
|
||||
@@ -258,3 +258,34 @@ export interface SimilarSession {
|
||||
created_at: string | null
|
||||
similarity: number
|
||||
}
|
||||
|
||||
// ── Escalation SSE bus ──
|
||||
//
|
||||
// Mirrors the `event_generator` payload in
|
||||
// backend/app/api/endpoints/session_handoffs.py — keep this in sync with the
|
||||
// dict published by `HandoffManager.dispatch_escalation_notifications`.
|
||||
|
||||
export interface HandoffCreatedEvent {
|
||||
type: 'handoff_created'
|
||||
handoff_id: string
|
||||
session_id: string
|
||||
priority: string
|
||||
engineer_notes: string
|
||||
created_at: string | null
|
||||
}
|
||||
|
||||
// Published by `enrich_escalation_async` after the background AI enrichment
|
||||
// finishes. Connected magic-moment screens use this to refetch the handoff
|
||||
// and re-render the AI assessment section in place.
|
||||
export interface HandoffAssessmentReadyEvent {
|
||||
type: 'handoff_assessment_ready'
|
||||
handoff_id: string
|
||||
session_id: string
|
||||
has_assessment: boolean
|
||||
}
|
||||
|
||||
export interface EscalationStreamHandlers {
|
||||
onReady?: () => void
|
||||
onHandoffCreated?: (event: HandoffCreatedEvent) => void
|
||||
onAssessmentReady?: (event: HandoffAssessmentReadyEvent) => void
|
||||
}
|
||||
|
||||
@@ -5,6 +5,14 @@ export interface ChatListItem {
|
||||
pinned: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
// Optional secondary fields used by the sidebar to make untitled / generic
|
||||
// sessions distinguishable. `problem_summary` powers the secondary line
|
||||
// when the title doesn't already carry it; `psa_ticket_id` shows as a
|
||||
// monospace badge so PSA-linked sessions are obvious; `status` lets us
|
||||
// tag escalated / picked-up sessions with a color cue.
|
||||
problem_summary?: string | null
|
||||
psa_ticket_id?: string | null
|
||||
status?: string | null
|
||||
}
|
||||
|
||||
export interface RetentionSettings {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -134,3 +134,16 @@ export interface EnhancedPsaMetrics {
|
||||
push_funnel: PsaFunnel
|
||||
daily_trend: PsaDailyTrend[]
|
||||
}
|
||||
|
||||
// Escalation Mode wedge metric — in-product time-to-first-action.
|
||||
// Pair with a manual baseline measurement for the savings claim.
|
||||
// See docs/plans/2026-04-27-escalation-mode-wedge-design.md.
|
||||
export interface EscalationMetrics {
|
||||
period: string
|
||||
n_handoffs_claimed: number
|
||||
n_handoffs_with_action: number
|
||||
avg_seconds_to_first_action: number | null
|
||||
median_seconds_to_first_action: number | null
|
||||
p95_seconds_to_first_action: number | null
|
||||
metric_definition: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user