Compare commits
10 Commits
87bd0b7c56
...
641853a002
| Author | SHA1 | Date | |
|---|---|---|---|
| 641853a002 | |||
| c194ba4a43 | |||
| 8e9d22e0e0 | |||
| f65b65790c | |||
| b8627f4180 | |||
| 02d5c6c08c | |||
| 9bdd9959a8 | |||
| fff8338bf2 | |||
| bc15952857 | |||
| ba46fc5644 |
@@ -2,30 +2,40 @@
|
||||
|
||||
**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:** in-flight on `feat/escalation-mode` (currently `feat/escalation-metric-endpoint`). Backend metric + role gate + email notification shipped. Frontend stat-card mounted. **Next:** WebSocket/SSE push (live-arrival half of the dual-path) and the magic-moment handoff-context screen.
|
||||
**Status:** in-flight on `feat/escalation-metric-endpoint`. Backend is **feature-complete and test-stabilized**. **Frontend live-arrival SSE subscription is shipped** (`EscalationQueue.tsx` subscribes via fetch-based ReadableStream, prepends new arrivals with the locked 200ms slide-in, flashes tab title when backgrounded, respects `prefers-reduced-motion`, exponential-backoff reconnect). **Magic-moment handoff-context screen is shipped** (`HandoffContextScreen.tsx` + integration in `FlowPilotSessionPage.tsx` — renders on Pick Up before claim, claims on "Start here", re-openable from toolbar, gracefully handles null AI assessment). **Next:** push + draft PR, then optional analytics page + Playwright e2e + chat-input suggested-step chips.
|
||||
|
||||
**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. Codex's two-metric correction + claim-role-gate + per-channel notification model all applied to the plan and the code.
|
||||
**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. Codex's two-metric correction + claim role gate + per-channel notification model + SSE bus diagnostics all applied.
|
||||
|
||||
**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) — primary input for `/qa` once the build is feature-complete.
|
||||
**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) — primary input for `/qa` once feature-complete.
|
||||
|
||||
## Done so far on `feat/escalation-metric-endpoint`
|
||||
## Done on `feat/escalation-metric-endpoint` (8 commits, branched from `main` @ `c0ed6d9`)
|
||||
|
||||
| Commit | What it ships |
|
||||
|---|---|
|
||||
| `d51e95c` | Plan + test-plan artifacts checked in |
|
||||
| `52f6d03` | `GET /analytics/flowpilot/escalations` — in-product time-to-first-action; account-scoped, engineer-or-admin gated; 9 tests including multi-tenant isolation |
|
||||
| `7a5b853` | Role-gate POST `/handoffs/{id}/claim` to engineer-or-admin (was viewer-claimable); 2 tests |
|
||||
| `07d0db9` | `HandoffManager.dispatch_escalation_notifications` — emails engineer/admin teammates on intent=escalate; graceful-degradation regression test; 4 tests |
|
||||
| `9f0bfd4` | `EscalationMetricCard` mounted above the queue list; consumes the new endpoint; matches DESIGN-SYSTEM tokens |
|
||||
| `d51e95c` | Plan + test-plan artifacts |
|
||||
| `52f6d03` | `GET /analytics/flowpilot/escalations` — in-product time-to-first-action; account-scoped, engineer-or-admin gated |
|
||||
| `7a5b853` | Role-gate POST `/handoffs/{id}/claim` to engineer-or-admin |
|
||||
| `07d0db9` | `HandoffManager.dispatch_escalation_notifications` — emails engineer/admin teammates on intent=escalate; graceful-degradation regression |
|
||||
| `9f0bfd4` | `EscalationMetricCard` mounted above the queue list |
|
||||
| `a283d0d` | `.ai/` mid-flight refresh |
|
||||
| `87bd0b7` | **WIP** marker for the SSE backend slice (paused for Codex pass) |
|
||||
| `bc15952` | Codex: stabilize SSE backend tests — `Depends(..., scope="function")` releases auth DB deps before the long-lived stream body; SSE handshake test calls the generator directly; AI-assessment stub fixture; bus normalizes string vs UUID account_id |
|
||||
| `fff8338` | Doc-only: track escalation assessment latency follow-up |
|
||||
| `9bdd995` | Bound escalation assessment latency to `ESCALATION_AI_ASSESSMENT_TIMEOUT_SECONDS` (default 5s); handoff still creates if assessment times out |
|
||||
| `b8627f4` | Frontend SSE subscription in `EscalationQueue.tsx` — fetch-based `ReadableStream` reader; `handoff_created` triggers refetch + prepend with locked 200ms slide-in; exponential-backoff reconnect; tab-title flash when backgrounded; `prefers-reduced-motion` honored; ARIA live-region |
|
||||
| `f65b657` | Handoff state docs after frontend SSE slice lands |
|
||||
| `8e9d22e` | Magic-moment handoff-context screen on pickup — `HandoffContextScreen.tsx` (4 sections, graceful null AI assessment, focus management, prefers-reduced-motion); `FlowPilotSessionPage.tsx` integration (pre-claim handoff fetch, claim on Start here, toolbar re-open overlay) |
|
||||
|
||||
20 backend tests green across handoff_manager + session_handoffs_api + flowpilot_analytics_escalations. Frontend `tsc -b` clean. Nothing pushed yet.
|
||||
**Test status:** focused subset (`test_escalation_bus`, `test_handoff_manager`, `test_session_handoffs_api`, `test_flowpilot_analytics_escalations`) → `32 passed in 18.91s` with `-n auto`. Frontend `tsc -b` clean. End-to-end smoke test against the running dev stack confirmed: SSE handshake delivers `ready` frame on connect and `handoff_created` after a posted handoff; `listHandoffs` returns the unclaimed handoff for a senior pre-claim; `claimHandoff` flips session status from `escalated` → `active` and `escalated_to_id` is set so subsequent GET succeeds. Branch not pushed.
|
||||
|
||||
## Remaining work on this branch
|
||||
|
||||
1. **WebSocket/SSE push** for live escalation arrival in the queue — the second half of the notification dual-path. Senior already on the queue page sees a new card slide in within ~1s of the junior hitting Escalate. ~3-4 days of work split across multiple commits (connection manager, auth-scoped fan-out, frontend EventSource handling, reconnect, slide-in animation, tab-title flash).
|
||||
2. **Magic-moment handoff-context screen** — 4-section view (problem summary / what's been tried / AI assessment / Start here CTA) that loads on Pick Up before dissolving into the regular FlowPilot session view. ~1.5-2 days.
|
||||
3. **Owner-facing analytics page** at `/analytics/escalations` — period selector, conversion-rate, trend chart. ~0.5d.
|
||||
4. **Playwright e2e** for the magic-moment demo flow (junior escalates → senior receives → senior claims → opens session). Critical for the GTM Loom not to crash mid-recording.
|
||||
1. **Push + draft PR** — branch is unpushed. Open against `main`.
|
||||
2. **Suggested-step chips below the chat input** (Codex correction, design plan locks this) — surfaces `ai_assessment_data.suggested_steps[]` as clickable chips in `FlowPilotMessageBar` that prefill the input. Threading through `FlowPilotSession` → message bar.
|
||||
3. **Snapshot expansion in `HandoffManager._generate_snapshot`** — include the recent diagnostic steps / conversation tail so the magic-moment screen's "What's been tried" section can render the actual timeline pre-claim instead of "full timeline available after pickup".
|
||||
4. **Toolbar Context button on legacy-arrival sessions** — currently the button only appears when the senior arrived via the magic-moment flow this session. Lazy-fetching the handoff list on session-load (when status was-escalated) would make it work on revisits.
|
||||
5. **Owner-facing analytics page** at `/analytics/escalations` — period selector, conversion-rate, trend chart. ~0.5d. Optional for v1 demo.
|
||||
6. **Playwright e2e** for the magic-moment demo flow (junior escalates → senior receives via SSE → senior claims → opens session). Critical for the GTM Loom not to crash mid-recording.
|
||||
|
||||
## Two-metric framing — read this before quoting numbers to anyone
|
||||
|
||||
@@ -33,10 +43,10 @@ The in-product endpoint measures *post-claim time-to-first-action*. The "minutes
|
||||
|
||||
## Kill-switch
|
||||
|
||||
Week 8: if 0 of 3 pilots produce a verifiable hours-saved-per-week number above 1.0, revisit the wedge. The design doc names the alternative (deterministic-ops territory) for context, but don't pivot before the data lands.
|
||||
Week 8: if 0 of 3 pilots produce a verifiable hours-saved-per-week number above 1.0, revisit the wedge. The design doc names the alternative direction (deterministic-ops territory) for context, but data lands first.
|
||||
|
||||
## Previous task — closed out
|
||||
|
||||
**Task:** Land PR #153 — fix the `AssistantChatPage` prefill `currentChatRef` bug. **Status:** complete (2026-04-26). Merged as `68fcdc6` on `main`. E2e regression test now in the suite.
|
||||
**Task:** Land PR #153 — fix the `AssistantChatPage` prefill `currentChatRef` bug. **Status:** complete (2026-04-26). Merged as `68fcdc6` on `main`.
|
||||
|
||||
**Background CI item, not blocking:** promoting `CI / e2e (pull_request)` to required on `main`. Two consecutive green PR runs (#150 and #153) cleared the threshold. Ops-only.
|
||||
**Background CI item, not blocking:** promoting `CI / e2e (pull_request)` to required on `main`. Two consecutive green runs cleared the threshold. Ops-only.
|
||||
|
||||
@@ -2,49 +2,56 @@
|
||||
|
||||
# HANDOFF.md
|
||||
|
||||
**Last updated:** 2026-04-27 EDT
|
||||
**Last updated:** 2026-04-27 21:30 EDT
|
||||
|
||||
**Active task:** **Escalation Mode** wedge build. See [`CURRENT_TASK.md`](CURRENT_TASK.md) for the full status; this file holds the resume point only.
|
||||
|
||||
**Branch:** `feat/escalation-metric-endpoint` — five commits stacked on top of `main` (`c0ed6d9`). Nothing pushed yet.
|
||||
**Branch:** `feat/escalation-metric-endpoint` — frontend live-arrival SSE slice + magic-moment handoff-context screen are both shipped on top of the test-stabilized backend. Branch is unpushed.
|
||||
|
||||
```
|
||||
9f0bfd4 feat(escalations): mount time-to-first-action stat-card on /escalations
|
||||
07d0db9 feat(handoff): email engineer-or-admin teammates on escalation
|
||||
7a5b853 feat(api): role-gate handoff claim to engineer-or-admin
|
||||
52f6d03 feat(analytics): add escalation time-to-first-action metric endpoint
|
||||
d51e95c docs(plans): add escalation-mode wedge design + test plan
|
||||
```
|
||||
## Status
|
||||
|
||||
Previous session shipped the two remaining frontend slices: live-arrival SSE subscription in `EscalationQueue.tsx`, and the magic-moment `HandoffContextScreen` for senior pickup.
|
||||
|
||||
What landed (commits added to the branch):
|
||||
|
||||
- `b8627f4` feat(escalations): subscribe EscalationQueue to live SSE arrivals — `streamEscalations` in `aiSessions.ts` (fetch-based `ReadableStream` parser; native `EventSource` can't send auth headers); `HandoffCreatedEvent` + `EscalationStreamHandlers` types; `EscalationQueue.tsx` rewrite with `AbortController`-managed subscription, exponential-backoff reconnect (1s → 30s cap, resets on `ready`), prepend-on-arrival with locked 200ms slide-in, tab-title `(N)` prefix while `document.hidden`, `prefers-reduced-motion` swap, ARIA live region.
|
||||
- `f65b657` docs(ai): handoff state after frontend SSE slice lands.
|
||||
- `8e9d22e` feat(escalations): magic-moment handoff-context screen on pickup — new `HandoffContextScreen.tsx` (4 sections; renders gracefully when `ai_assessment` is null per the 5s timeout from `9bdd995`; ARIA dialog + focus on primary CTA + Esc dismiss for re-open overlay; `prefers-reduced-motion` honored). `FlowPilotSessionPage.tsx` integration: on `?pickup=true`, fetch the handoff list first (account-scoped via RLS, no claim required), find the latest unclaimed escalate handoff, render the screen and skip `loadSession` (senior would 404 pre-claim). "Start here" calls `claimHandoff`, drops the pickup query, and dismisses — `loadSession` then fires because senior is now `escalated_to_id`. Toolbar "Context" button on active sessions re-opens the screen as a dismissible overlay (visible only when senior arrived via the magic-moment flow this session).
|
||||
|
||||
Verified:
|
||||
|
||||
- `tsc -b` exit 0 after each commit.
|
||||
- Backend regression: focused subset still `32 passed in 18.91s` with `-n auto`. No backend changes in this session.
|
||||
- Live SSE handshake against the running dev stack: 200 + `text/event-stream`; `ready` frame on connect; `handoff_created` frame with full payload arrived after posting a handoff via the API. Wire format matches the parser exactly.
|
||||
- Live claim flow against the running dev stack: `listHandoffs` returns the unclaimed handoff for a senior pre-claim; `claimHandoff` flips session status from `escalated` → `active` and sets `escalated_to_id`; subsequent `GET /ai-sessions/{id}` succeeds.
|
||||
|
||||
Not yet verified (would need a real browser session): the slide-in animation visually plays, tab title actually updates, reduced-motion media-query path renders, AbortController cleanup on unmount, exponential backoff after a real network blip, the magic-moment screen layout/typography looks right, dissolve transition feels right. Wire contract + integration semantics are confirmed; visuals are next.
|
||||
|
||||
Smoke-test artifact: a single test handoff (`0f6149db…` on session `50ea20d4…`) was claimed during verification and is now an `active` session owned by the engineer test user. Harmless; useful as visual demo data.
|
||||
|
||||
## Resume point
|
||||
|
||||
Pick up the **WebSocket/SSE push** — the live-arrival half of the notification dual-path. Email is already wired (commit `07d0db9`); push is the second channel that makes the demo's "30-second magic moment" undeniable when the receiving senior is online and on the queue page.
|
||||
|
||||
Suggested first slice: a thin server-side SSE endpoint scoped to `current_user.account_id`, fan out from `HandoffManager.dispatch_escalation_notifications` (alongside email), and hook the frontend `EscalationQueue` to subscribe and prepend new cards with the locked 200ms slide-in. Reconnect logic, tab-title flash, and `prefers-reduced-motion` respect are part of this slice per the locked UI spec in the design doc.
|
||||
|
||||
After the dual-path is feature-complete, the **magic-moment handoff-context screen** is next (4 sections, dissolves into the FlowPilot session view on first action).
|
||||
|
||||
## Where things stand
|
||||
|
||||
- CI on `main` still healthy. Branch protection: `CI / frontend (pull_request)` required, `CI / backend (pull_request)` required, `CI / e2e (pull_request)` not yet required (ops-only follow-up — two consecutive green runs cleared the threshold).
|
||||
- 20 backend tests green on this branch (handoff_manager, session_handoffs_api, flowpilot_analytics_escalations). Frontend `tsc -b` clean. Branch has not been pushed; no CI runs yet.
|
||||
- The plan doc at [`docs/plans/2026-04-27-escalation-mode-wedge-design.md`](../docs/plans/2026-04-27-escalation-mode-wedge-design.md) is the source of truth for every UI / metric / scope decision. The embedded **GSTACK REVIEW REPORT** at the bottom shows Eng + Design CLEARED and Codex INFO with the disposition of all 12 of its findings.
|
||||
1. **Visual QA the two new frontend slices in a real browser.** Open `/escalations` as a senior, escalate from a separate session/tab, watch the slide-in + tab-title flash. Then click Pick Up and walk through the magic-moment screen → Start here → confirm the FlowPilot view loads cleanly. The `/qa` skill is the right tool.
|
||||
2. **Push the branch and open a draft PR** against `main`. Title: "Escalation Mode wedge". Body: link the design + test-plan artifacts in `docs/plans/`.
|
||||
3. **Pick up the deferred follow-ups** in `CURRENT_TASK.md` — the highest-leverage one is the suggested-step chips below the chat input (Codex correction, locked in design). The `HandoffManager._generate_snapshot` expansion to include recent steps/conversation is the next-highest leverage so the magic-moment screen can show the diagnostic timeline pre-claim.
|
||||
4. Optional v1: owner-facing `/analytics/escalations` page; Playwright e2e for the GTM Loom demo path.
|
||||
|
||||
## Useful breadcrumbs
|
||||
|
||||
- New endpoint: [`backend/app/api/endpoints/flowpilot_analytics.py`](../backend/app/api/endpoints/flowpilot_analytics.py) — `get_escalation_metrics` at the bottom of the file.
|
||||
- Notification dispatch: [`backend/app/services/handoff_manager.py`](../backend/app/services/handoff_manager.py) — `dispatch_escalation_notifications`. Wired in [`backend/app/api/endpoints/session_handoffs.py`](../backend/app/api/endpoints/session_handoffs.py) **after** `db.commit()` so a rolled-back handoff never emails.
|
||||
- Frontend stat-card: [`frontend/src/components/flowpilot/EscalationMetricCard.tsx`](../frontend/src/components/flowpilot/EscalationMetricCard.tsx). Renders `n_with_action / n_claimed`, avg + median, and the metric_definition disclaimer.
|
||||
- Two-metric framing — required reading before quoting any number to a pilot. The in-product endpoint measures *post-claim time-to-first-action*; the savings claim is `manual_baseline − in_product`. Manual baseline comes from the founder's stopwatch on the next 5 escalations (The Assignment in the design doc).
|
||||
- The `notification_sent` boolean is intentionally NOT being written. Per Codex's correction it should be replaced by per-channel delivery records; v1.x story. For now, application logs are the audit trail.
|
||||
- Two TODOs added during this session: peer-tech escalation (deferred to v2) and the (already moved-in-scope) claim role gate. See [`TODO.md`](TODO.md).
|
||||
- SSE endpoint: [`backend/app/api/endpoints/session_handoffs.py`](../backend/app/api/endpoints/session_handoffs.py) — `stream_escalations`.
|
||||
- Pub/sub bus: [`backend/app/core/escalation_bus.py`](../backend/app/core/escalation_bus.py).
|
||||
- Frontend SSE consumer: [`frontend/src/api/aiSessions.ts`](../frontend/src/api/aiSessions.ts) → `streamEscalations`.
|
||||
- Live-arrival queue UI: [`frontend/src/components/flowpilot/EscalationQueue.tsx`](../frontend/src/components/flowpilot/EscalationQueue.tsx).
|
||||
- Magic-moment screen: [`frontend/src/components/flowpilot/HandoffContextScreen.tsx`](../frontend/src/components/flowpilot/HandoffContextScreen.tsx).
|
||||
- Pickup integration: [`frontend/src/pages/FlowPilotSessionPage.tsx`](../frontend/src/pages/FlowPilotSessionPage.tsx) — `magicState`, `handleStartHere`, `openHandoffContextOverlay`.
|
||||
- Notification dispatch: [`backend/app/services/handoff_manager.py`](../backend/app/services/handoff_manager.py) — `dispatch_escalation_notifications`.
|
||||
- Metric endpoint: [`backend/app/api/endpoints/flowpilot_analytics.py`](../backend/app/api/endpoints/flowpilot_analytics.py).
|
||||
|
||||
## Watch-outs
|
||||
|
||||
- `ai_session_step` has NO `user_id` column — the metric query keys "first action by senior" off `session_id + created_at > claimed_at`, which is fine because session activity post-claim IS the senior's activity (the session is reactivated under `escalated_to_id`). If a future change adds `user_id` to `ai_session_step`, the metric query can become more precise.
|
||||
- `account_id` is denormalized on `ai_session_step` (Phase 4 RLS pattern). The metric query and any new SSE subscription scoping must use it directly, not join through `ai_sessions`.
|
||||
- POST `/handoff` still requires the session owner to be the escalator (`AISession.user_id == current_user.id`). Peer-tech escalation is captured as a v2 TODO. Don't widen this without a UX decision.
|
||||
|
||||
## Kill-switch (week 8)
|
||||
|
||||
If 0 of 3 pilots produce a verifiable hours-saved-per-week number above 1.0, revisit the wedge. The design doc names the alternative direction (deterministic-ops territory) but data lands first.
|
||||
- Do not reintroduce `client.stream()`/ASGITransport tests for infinite SSE responses; test the generator directly.
|
||||
- The bus is acceptable for v1 pilot scale only because Railway is single-replica. Redis pub/sub is the obvious swap when horizontal scaling appears.
|
||||
- `streamEscalations` doesn't drive token refresh on a mid-stream 401 — the Axios interceptor only covers axios calls. Acceptable for v1.
|
||||
- The handoff snapshot today is sparse (`problem_summary, problem_domain, status, step_count, confidence_tier` plus optional branch info). The magic-moment screen's "What's been tried" section currently shows engineer notes + step-count affordance, not the actual step timeline. Snapshot expansion is the right fix.
|
||||
- `HandoffResponse.ai_assessment_data.confidence` is typed `number` on the frontend but the backend currently emits `'low' | 'medium' | 'high'` strings. The `ConfidenceBadge` component handles both shapes at runtime; the type definition is stale and should be widened to `number | 'low' | 'medium' | 'high'`.
|
||||
- The toolbar "Context" button is hidden on revisited active sessions where the senior didn't arrive via magic-moment this session — known scope cut. Lazy-fetching handoff list on session-load (when status was previously `escalated`) is the cleanup.
|
||||
|
||||
@@ -12,6 +12,52 @@
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -901,10 +901,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)
|
||||
|
||||
@@ -156,7 +156,10 @@ _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)],
|
||||
current_user: Annotated[
|
||||
User,
|
||||
Depends(require_engineer_or_admin, scope="function"),
|
||||
],
|
||||
):
|
||||
"""SSE stream of new escalation arrivals for the current user's account.
|
||||
|
||||
|
||||
@@ -111,6 +111,7 @@ 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"
|
||||
ESCALATION_AI_ASSESSMENT_TIMEOUT_SECONDS: int = 5
|
||||
|
||||
# Model tier routing — maps action types to model tiers
|
||||
AI_MODEL_TIERS: dict[str, str] = {
|
||||
|
||||
@@ -38,39 +38,46 @@ class EscalationBus:
|
||||
self._subscribers: dict[UUID, set[asyncio.Queue[dict[str, Any]]]] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def subscribe(self, account_id: UUID) -> asyncio.Queue[dict[str, Any]]:
|
||||
@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(account_id, set()).add(queue)
|
||||
self._subscribers.setdefault(normalized_account_id, set()).add(queue)
|
||||
return queue
|
||||
|
||||
async def unsubscribe(
|
||||
self, account_id: UUID, queue: asyncio.Queue[dict[str, Any]]
|
||||
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(account_id)
|
||||
subs = self._subscribers.get(normalized_account_id)
|
||||
if subs is None:
|
||||
return
|
||||
subs.discard(queue)
|
||||
if not subs:
|
||||
self._subscribers.pop(account_id, None)
|
||||
self._subscribers.pop(normalized_account_id, None)
|
||||
|
||||
async def publish(self, account_id: UUID, event: dict[str, Any]) -> int:
|
||||
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(account_id, ()))
|
||||
subs = list(self._subscribers.get(normalized_account_id, ()))
|
||||
if not subs:
|
||||
return 0
|
||||
delivered = 0
|
||||
@@ -82,14 +89,15 @@ class EscalationBus:
|
||||
logger.warning(
|
||||
"EscalationBus: dropped event for full subscriber queue "
|
||||
"(account_id=%s, event=%s)",
|
||||
account_id,
|
||||
normalized_account_id,
|
||||
event.get("type", "?"),
|
||||
)
|
||||
return delivered
|
||||
|
||||
def subscriber_count(self, account_id: UUID) -> int:
|
||||
def subscriber_count(self, account_id: UUID | str) -> int:
|
||||
"""Diagnostic — number of active subscribers for an account."""
|
||||
return len(self._subscribers.get(account_id, ()))
|
||||
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()`
|
||||
|
||||
@@ -57,7 +57,9 @@ class HandoffManager:
|
||||
ai_assessment = None
|
||||
ai_assessment_data = None
|
||||
if intent == "escalate":
|
||||
ai_assessment, ai_assessment_data = await self._generate_ai_assessment(session)
|
||||
ai_assessment, ai_assessment_data = (
|
||||
await self._generate_ai_assessment_with_timeout(session)
|
||||
)
|
||||
|
||||
handoff = SessionHandoff(
|
||||
session_id=session_id,
|
||||
@@ -311,6 +313,24 @@ class HandoffManager:
|
||||
logger.exception("Failed to generate AI assessment")
|
||||
return None, None
|
||||
|
||||
async def _generate_ai_assessment_with_timeout(
|
||||
self, session: AISession
|
||||
) -> tuple[str | None, dict[str, Any] | None]:
|
||||
"""Generate optional escalation assessment within the click-path budget."""
|
||||
timeout = settings.ESCALATION_AI_ASSESSMENT_TIMEOUT_SECONDS
|
||||
try:
|
||||
return await asyncio.wait_for(
|
||||
self._generate_ai_assessment(session),
|
||||
timeout=timeout,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(
|
||||
"Escalation AI assessment timed out after %ss for session %s",
|
||||
timeout,
|
||||
session.id,
|
||||
)
|
||||
return None, None
|
||||
|
||||
async def generate_briefing(
|
||||
self, handoff_id: UUID, claiming_user_id: UUID
|
||||
) -> str:
|
||||
|
||||
@@ -405,7 +405,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",
|
||||
|
||||
@@ -68,6 +68,21 @@ async def test_subscriber_in_other_account_does_not_receive():
|
||||
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()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Integration tests for HandoffManager service."""
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -9,6 +10,26 @@ 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_ai_assessment",
|
||||
new=AsyncMock(
|
||||
return_value=(
|
||||
"Stub escalation assessment",
|
||||
{
|
||||
"likely_cause": "Stub",
|
||||
"suggested_steps": [],
|
||||
"confidence": "medium",
|
||||
},
|
||||
)
|
||||
),
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_park_handoff(client: AsyncClient, test_user, auth_headers, test_db):
|
||||
"""Parking a session creates a handoff with snapshot."""
|
||||
@@ -81,6 +102,55 @@ async def test_create_escalate_handoff(client: AsyncClient, test_user, auth_head
|
||||
assert "branch_map" in session.escalation_package or "snapshot" in session.escalation_package
|
||||
|
||||
|
||||
@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_assessment(self, session):
|
||||
await asyncio.sleep(0.2)
|
||||
return "too slow", {"confidence": "medium"}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.handoff_manager.settings."
|
||||
"ESCALATION_AI_ASSESSMENT_TIMEOUT_SECONDS",
|
||||
0.01,
|
||||
)
|
||||
with patch.object(
|
||||
HandoffManager,
|
||||
"_generate_ai_assessment",
|
||||
new=slow_assessment,
|
||||
):
|
||||
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
|
||||
async def test_claim_session(client: AsyncClient, test_user, test_admin, auth_headers, test_db):
|
||||
"""Claiming a handoff sets claimed_by and reactivates session."""
|
||||
|
||||
@@ -1,12 +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.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_ai_assessment",
|
||||
new=AsyncMock(
|
||||
return_value=(
|
||||
"Stub escalation assessment",
|
||||
{
|
||||
"likely_cause": "Stub",
|
||||
"suggested_steps": [],
|
||||
"confidence": "medium",
|
||||
},
|
||||
)
|
||||
),
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -137,23 +166,30 @@ async def test_escalations_stream_returns_sse_content_type(
|
||||
):
|
||||
"""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."""
|
||||
async with client.stream(
|
||||
"GET",
|
||||
"/api/v1/ai-sessions/escalations/stream",
|
||||
headers=auth_headers,
|
||||
) as resp:
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["content-type"].startswith("text/event-stream")
|
||||
# First chunk must contain the ready event.
|
||||
first = b""
|
||||
async for chunk in resp.aiter_bytes():
|
||||
first += chunk
|
||||
if b"event: ready" in first and b"\n\n" in first:
|
||||
break
|
||||
assert b"event: ready" in first
|
||||
assert b'"account_id"' in first
|
||||
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
|
||||
|
||||
@@ -18,6 +18,8 @@ import type {
|
||||
ChatSessionCreateResponse,
|
||||
ChatMessageRequest,
|
||||
ChatMessageResponse,
|
||||
HandoffCreatedEvent,
|
||||
EscalationStreamHandlers,
|
||||
} from '@/types/ai-session'
|
||||
|
||||
export const aiSessionsApi = {
|
||||
@@ -220,6 +222,73 @@ 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 === '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,15 +1,31 @@
|
||||
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
|
||||
|
||||
function waitTimeColor(createdAt: string): string {
|
||||
const hours = (Date.now() - new Date(createdAt).getTime()) / 3_600_000
|
||||
if (hours >= 4) return '#f87171' // danger
|
||||
@@ -22,29 +38,156 @@ 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)
|
||||
|
||||
const loadQueue = async () => {
|
||||
// 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) => {
|
||||
if (onPickup) {
|
||||
@@ -95,7 +238,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 +253,66 @@ 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)
|
||||
return (
|
||||
<div
|
||||
key={session.id}
|
||||
className={cn(
|
||||
'card-flat p-3 sm:p-4 space-y-3',
|
||||
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>
|
||||
<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={() => 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>
|
||||
)
|
||||
}
|
||||
|
||||
308
frontend/src/components/flowpilot/HandoffContextScreen.tsx
Normal file
308
frontend/src/components/flowpilot/HandoffContextScreen.tsx
Normal file
@@ -0,0 +1,308 @@
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
Brain,
|
||||
Clock,
|
||||
FileText,
|
||||
Hash,
|
||||
Sparkles,
|
||||
Target,
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
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,
|
||||
onStartHere,
|
||||
onDismiss,
|
||||
dismissible = false,
|
||||
isProcessing = false,
|
||||
}: 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 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>
|
||||
Assessment unavailable — model didn't respond in time. 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>
|
||||
)}
|
||||
{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>
|
||||
|
||||
{/* Start here CTA */}
|
||||
{!dismissible && (
|
||||
<div className="mt-6 flex flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Picking up assigns this session to you and reactivates it.
|
||||
</p>
|
||||
<button
|
||||
ref={startBtnRef}
|
||||
onClick={() => void onStartHere()}
|
||||
disabled={isProcessing}
|
||||
className="flex items-center justify-center gap-2 rounded-lg bg-accent px-5 py-3 min-h-[44px] text-sm font-semibold text-white hover:brightness-110 active:scale-[0.98] disabled:opacity-50 disabled:pointer-events-none transition-all"
|
||||
>
|
||||
<ArrowRight size={14} />
|
||||
{isProcessing ? 'Picking up…' : 'Start here'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -11,6 +11,7 @@ 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'
|
||||
|
||||
@@ -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,23 @@ 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
|
||||
}
|
||||
|
||||
export interface EscalationStreamHandlers {
|
||||
onReady?: () => void
|
||||
onHandoffCreated?: (event: HandoffCreatedEvent) => void
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user