3 Commits

Author SHA1 Message Date
b7d7ff06d2 docs(ai): refresh handoff for compute swap
All checks were successful
Mirror to GitHub / mirror (push) Successful in 5s
CI / frontend (pull_request) Successful in 5m8s
CI / backend (pull_request) Successful in 9m46s
CI / e2e (pull_request) Successful in 10m16s
- HANDOFF: rewritten resume point. First action on resume is `git push`
  (commits 0f00ee5 and 665530f are local-only). Visual QA + bug bash is
  the active work; 4 plan-locked items + the structural task-lane fix
  all need real-browser verification.
- CURRENT_TASK: add 0f00ee5 and 665530f to the commit table; reframe
  "Just shipped" as a per-commit summary; flag the task-lane fix as
  needing visual confirmation.
- SESSION_LOG: chronological entry for this session with full detail
  (audit, four polish items, race-condition wiring, structural
  task-lane fix, test status, files touched).
- DECISIONS: new entry "Tag the task-lane state with an owner chatId"
  documenting the structural pattern, what was rejected, and the
  forward implication that future task-lane state slices follow the
  same owner-tagging pattern.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 08:21:23 -04:00
665530f812 fix(assistant-chat): tag task-lane state with owner chatId to kill stale flash
The previous fix (8914391) only blocked the mount-time sessionStorage
restore when the page entered with prefill or ?pickup=true. It didn't
cover any path where the page was already mounted and activeChatId
flipped without the in-memory task-lane state going through reset+
repopulate cleanly — in-place URL navigation, mid-flight pickup,
HMR re-runs, the gap between setActiveChatId(B) and the AI response
that finally populates B's questions/actions.

Root cause: activeQuestions / activeActions / showTaskLane were never
intrinsically tied to a chatId. They were treated as "the active chat's
data" by convention, with no structural enforcement. Any window where
they survived past their owning chat leaked previous-session data into
the new view. The persistence effect made it worse: it stamped the
sessionStorage chatId field with activeChatId at write time, so a
mid-transition snapshot {chatId: B, questions: [A's]} would happily
restore A's data for B on the next mount.

Fix: introduce taskLaneOwnerChatId state that records the chatId those
in-memory questions/actions/show values BELONG to. Set at every site
that populates them (sendPrefill, selectChat, handleSend, handleTaskSubmit,
handleResumeNew, refreshFacts, handleApplyFix). Cleared in
resetSessionDerivedState. The persistence effect now writes ownerChatId
as the chatId tag, not activeChatId — so the snapshot is always
self-consistent.

Render gate: taskLaneIsForActiveChat = ownerChatId === activeChatId.
ANDed into all three render conditions (toolbar Tasks button, narrow-
viewport floating drawer, main side panel). The lane is structurally
unable to display data tagged with a different chat.

The mount-time skipTaskLaneRestore guard stays — it kills the flash
between component mount and the first sendPrefill effect run, which
the owner-gate alone doesn't cover.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 02:42:31 -04:00
0f00ee5e01 feat(escalations): close out plan-locked wedge polish
Four items from the design-plan audit, all flagged as locked-design or
Codex corrections, shipped together so the GTM demo path covers them
end-to-end before bug bash.

1. Live AI assessment refresh on the magic-moment screen. Backend already
   publishes handoff_assessment_ready when enrich_escalation_async commits;
   wire the frontend listener so the senior sees the assessment populate
   without a manual reopen. New event type + onAssessmentReady handler on
   streamEscalations; AssistantChatPage opens a scoped SSE subscription
   whenever it tracks a handoff missing its assessment, refetches on match,
   and replaces magicHandoff / overlayHandoff in place. Closes the loop on
   the async-assessment commit e8ba74e.

2. Suggested-step chips below the chat input. Locked design from the plan
   (Codex correction). Chip strip renders above the composer post-claim
   when ai_assessment_data.suggested_steps[] is non-empty. Click prefills
   the input and focuses; first send or explicit X hides for the session.

3. Unread 6px dot on EscalationQueue cards. localStorage-persisted seen
   set (rf-escalation-seen, capped 200). Dot top-right when not seen.
   Cleared on open (card click) or claim (Pick Up) — NOT on hover, per
   Codex correction. Pick Up stops propagation so it doesn't double-fire.

4. Race-condition toast on claim conflict. The /claim endpoint previously
   silently overwrote claimed_by — both seniors thought they owned the
   session. New HandoffAlreadyClaimedError carries the winner's id/name/
   timestamp; claim_session rejects different-user re-claims (same-user is
   idempotent for double-click safety); endpoint returns 409 with
   structured detail. AssistantChatPage.handleStartHere extracts and
   surfaces "Already claimed by {name} {time_ago}." via toast, drops
   ?pickup=true, dismisses magic-moment so the loser flows back to queue.

Tests: 2 new unit tests in test_handoff_manager.py (conflict raises,
same-user idempotent). Full handoff + escalation suite (34 tests) green.
Frontend tsc -b clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 01:59:28 -04:00
11 changed files with 511 additions and 55 deletions

View File

@@ -8,7 +8,7 @@
**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 on `feat/escalation-metric-endpoint` (8 commits, branched from `main` @ `c0ed6d9`)
## Done on `feat/escalation-metric-endpoint` (branched from `main` @ `c0ed6d9`)
| Commit | What it ships |
|---|---|
@@ -29,17 +29,30 @@
| `641853a` | Bell-icon notification opens the pickup flow — notification link template adds `?pickup=true`; GET `/ai-sessions/{id}` allows account-scoped read for `requesting_escalation` / `escalated` states |
| `2a2329a` | Handoff state docs after bell-icon fix; record draft PR #155 |
| `029680a` | Unify `/escalate` through `HandoffManager` — single canonical path for every escalation. `HandoffCreateRequest.target_user_id`, `create_handoff` does the legacy enriched-package work + sets `escalation_reason`, `finalize_escalation` runs documentation + PSA push + `notify()` pre-commit, `dispatch_escalation_notifications` keeps only fire-and-forget IO post-commit. `pickup_session` accepts either status for in-flight migration. `flowpilot_engine.escalate_session` no longer called from any endpoint |
| `8914391` | First task-lane race fix — initializer-time guards (`incomingPrefill || isPickup`) + eager `sessionStorage.removeItem` in `resetSessionDerivedState`. Insufficient (only covered mount-time entry paths) |
| `0f00ee5` | Four plan-locked wedge polish items in one commit — see "Just shipped" section below |
| `665530f` | **Structural fix for the task-lane stale-flash bug.** `taskLaneOwnerChatId` state tags the chatId the in-memory questions/actions belong to. Set at every populate site (sendPrefill, selectChat, handleSend, handleTaskSubmit, handleResumeNew, refreshFacts, handleApplyFix); cleared in `resetSessionDerivedState`. Persistence effect now writes `chatId: ownerChatId` (was `activeChatId` — that was the original write-side bug). Render gate `taskLaneIsForActiveChat = ownerChatId === activeChatId` ANDed into all three render conditions. Stale data is now structurally unable to display. See DECISIONS entry for full rationale |
**Test status:** full backend suite → `1103 passed in 259.63s` with `-n auto` after the unification. Frontend `tsc -b` clean. End-to-end smoke test against the running dev stack confirmed: SSE handshake delivers `ready` + `handoff_created` frames; `listHandoffs` returns the unclaimed handoff for a senior pre-claim; `claimHandoff` flips session status `escalated``active`; senior (non-owner, non-target) can `GET` an in-transit session detail; **a single legacy `/escalate` call now produces status='escalated', SessionDocumentation, SessionHandoff row, AppNotification with link `/pilot/{id}?pickup=true` for the team admin, and a PSA push attempt** — all from one funneled HandoffManager call. Branch pushed; draft PR #155 open.
## Remaining work on this branch
1. **Visual QA in a real browser** via `/qa` — slide-in animation, tab-title flash, magic-moment layout, dissolve, full junior-escalates → senior-receives → senior-claims demo path.
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.
1. **Visual QA + bug bash** in a real browser — full pickup demo path with the four new pieces below; this is the next active step.
2. **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".
3. **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.
4. **Owner-facing analytics page** at `/analytics/escalations` — period selector, conversion-rate, trend chart. ~0.5d. Optional for v1 demo.
5. **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.
## Just shipped (this session — 2 commits)
**Commit `0f00ee5`** — four plan-locked wedge polish items:
- **Live AI assessment refresh on the magic-moment screen.** New `HandoffAssessmentReadyEvent` type + `onAssessmentReady` handler on `streamEscalations`. `AssistantChatPage` opens a scoped SSE subscription whenever it has a tracked handoff with no AI assessment yet; on a matching event it refetches and replaces both `magicHandoff` and `overlayHandoff` in place. Closes the loop on the async-assessment commit `e8ba74e`.
- **Suggested-step chips below the chat input.** New `chipsHidden` state in `AssistantChatPage` defaulting to false; a chip strip renders above the composer when `magicHandoff?.ai_assessment_data?.suggested_steps[]` is non-empty and the magic-moment has dissolved. Click prefills input + focus; first send hides the strip; explicit X also hides. Per-session lifetime (Codex correction locked design).
- **Unread 6px dot on `EscalationQueue` cards.** localStorage-persisted seen set (`rf-escalation-seen`, capped 200). Dot renders top-right of any card not yet seen. Cleared on **open (card click) or claim (Pick Up)** — NOT on hover (Codex correction). Pick Up onClick now stops propagation so the wrapper's open handler isn't double-fired.
- **Race-condition toast on claim conflict.** New `HandoffAlreadyClaimedError` exception class in `handoff_manager.py`. `claim_session` now eager-loads `claimed_by_user`, rejects different-user re-claims (idempotent for same-user), and raises with the winner's id/name/timestamp. Endpoint translates to 409 with structured detail. `AssistantChatPage.handleStartHere` extracts the detail, formats `"Already claimed by {name} {time_ago}."` via `timeAgo()`, drops `?pickup=true`, and dismisses the magic-moment so the loser flows back to the queue. Backed by 2 new unit tests in `test_handoff_manager.py`.
**Commit `665530f`** — structural fix for the recurring stale-task-lane bug. Owner-tagging pattern applied to `activeQuestions` / `activeActions` / `showTaskLane`. See [`DECISIONS.md`](DECISIONS.md) for the architecture write-up. **User-reported on next session: needs visual verification.**
## Two-metric framing — read this before quoting numbers to anyone

View File

@@ -13,6 +13,27 @@
---
## 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.

View File

@@ -2,62 +2,54 @@
# HANDOFF.md
**Last updated:** 2026-04-27 22:30 EDT
**Last updated:** 2026-04-28 02:00 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.
**Active task:** **Escalation Mode** wedge build. Full status in [`CURRENT_TASK.md`](CURRENT_TASK.md); this file is the resume point.
**Branch:** `feat/escalation-metric-endpoint` — pushed (latest: `029680a`). **Draft PR #155** open against `main` ([gitea.resolutionflow.com/chihlasm/resolutionflow/pulls/155](https://gitea.resolutionflow.com/chihlasm/resolutionflow/pulls/155)). Wedge is feature-complete pending visual QA + the deferred follow-ups in `CURRENT_TASK.md`. **/escalate and /handoff are unified** — every escalation goes through `HandoffManager` and produces the full set of artifacts (handoff row, AppNotification, SSE bus event, Slack/Teams via `notify()`, per-user emails, documentation, PSA push) regardless of which URL it entered through.
**Branch:** `feat/escalation-metric-endpoint`. Local tip is `665530f`. **Remote (origin) is at `8914391`** — the last two commits (`0f00ee5`, `665530f`) are local-only because the user is swapping computers and asked for the docs/handoff first. **Push needed on next session before continuing work.** Draft PR #155 is open against `main`.
## Status
## What this session did
Previous session shipped the two remaining frontend slices: live-arrival SSE subscription in `EscalationQueue.tsx`, and the magic-moment `HandoffContextScreen` for senior pickup.
Two commits, both untested in a real browser:
What landed (commits added to the branch):
1. **`0f00ee5` feat(escalations): close out plan-locked wedge polish.** Four items from the design-plan audit ([`docs/plans/2026-04-27-escalation-mode-wedge-design.md`](../docs/plans/2026-04-27-escalation-mode-wedge-design.md)):
- **Live AI assessment refresh** — frontend listener for the `handoff_assessment_ready` SSE event, refetches the handoff and updates `magicHandoff` / `overlayHandoff` in place. Closes the async-assessment loop from `e8ba74e`.
- **Suggested-step chips** below the composer in `AssistantChatPage` — surfaces `ai_assessment_data.suggested_steps[]` post-claim, click prefills the input, hides on first send or explicit X.
- **Unread 6px dot** on `EscalationQueue` cards — localStorage-persisted seen set (`rf-escalation-seen`), clears on open OR claim (NOT hover; Codex correction).
- **Race-condition toast on claim conflict** — new `HandoffAlreadyClaimedError` exception, endpoint returns 409 with structured `{claimed_by_id, claimed_by_name, claimed_at}`, frontend shows `"Already claimed by {name} {time_ago}."` and bounces the loser back to the queue. Backed by 2 new tests; full handoff/escalation suite (34 tests) green.
- `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).
- `c194ba4` docs(ai): handoff state after magic-moment screen lands.
- `641853a` fix(escalations): bell-icon notification opens the pickup flow — `_build_notification_link` for `session.escalated` now ends with `?pickup=true` so notification clicks route through the senior-pickup flow. `GET /ai-sessions/{id}` now allows account-scoped read for `requesting_escalation` / `escalated` status (RLS already enforces tenant boundary; the owner-only guard was overly restrictive for explicitly-shared in-transit states). Without these two fixes the user observed bell-icon clicks "just clearing the notification" — the navigation was happening but landing on a 404 the senior couldn't escape from.
- `2a2329a` docs(ai): handoff state after bell-icon fix; record draft PR #155.
- `029680a` feat(escalations): unify `/escalate` through `HandoffManager` — single canonical path for every escalation. `HandoffCreateRequest.target_user_id` added (rejects self-targeting). `HandoffManager.create_handoff` for intent='escalate' now sets `session.escalation_reason` + `escalated_to_id`, builds the legacy AI-enhanced escalation_package via Sonnet (lazy-import from flowpilot_engine, graceful fallback), and merges handoff metadata into it; eager-loads `session.steps` + `session.user` to dodge async lazy-load greenlet errors. New `HandoffManager.finalize_escalation` runs `_generate_documentation` + `_push_to_psa` + `notify()` pre-commit so the AppNotification rows and PSA writes land atomically with the handoff. `dispatch_escalation_notifications` keeps only fire-and-forget IO (bus publish + per-user emails) post-commit. The `/escalate` endpoint is 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` and `escalated` for in-flight migration. Escalation queue list + sidebar count match either status.
2. **`665530f` fix(assistant-chat): tag task-lane state with owner chatId.** Structural fix for the recurring "new session shows previous session's task lane" bug. The earlier fix `8914391` only covered the mount-time entry path; this change makes stale data structurally unable to display by adding `taskLaneOwnerChatId` state and a render gate `taskLaneOwnerChatId === activeChatId` ANDed into all three render conditions. Persistence effect now writes ownership chatId, not active chatId — that was the original write-side bug. See [`DECISIONS.md`](DECISIONS.md) for the architecture write-up.
Verified:
- `tsc -b` exit 0 after each frontend commit.
- Full backend test suite after unification: `1103 passed in 259.63s` with `-n auto`.
- 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.
- Live access-policy verification: senior (non-owner, non-target) can now `GET` an in-transit escalated session detail.
- Live unification verification: a single legacy `/escalate` call from a junior produced status='escalated', a `SessionDocumentation`, a `SessionHandoff` row, an attempted PSA push (`no_psa` since no ticket linked), AND an `AppNotification` row for the team admin with title "Session escalated by Jordan Tech" and link `/pilot/{session_id}?pickup=true`. The bell-icon click now lands the senior in the magic-moment flow with the actual handoff data.
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.
Verified: `tsc -b` clean after both. Backend handoff/escalation suite (34 tests) green. **Not verified:** anything in a real browser. The user explicitly asked for a debugging session after implementation — that's the next thing.
## Resume point
1. **Visual QA via `/qa` against the dev stack.** End-to-end demo flow: junior escalates via EscalateModal → senior gets bell-icon notification → senior clicks the notification (now routes through `?pickup=true`) → magic-moment screen renders with the rich handoff data → Start here → FlowPilot session view loads. Also: open `/escalations` as senior with a second session escalating in the background, watch the slide-in + tab-title flash. The PR description has a checklist mirroring this.
2. **Pick up the deferred follow-ups** in `CURRENT_TASK.md`. Highest-leverage: suggested-step chips below the chat input (Codex correction, locked design — needs threading through `FlowPilotSession``FlowPilotMessageBar`). Next: `HandoffManager._generate_snapshot` expansion to include the recent diagnostic timeline pre-claim — though this is lower-priority now that the unified path already merges the legacy enriched escalation_package into the dual-write, so the magic-moment screen has access to `steps_tried` / `remaining_hypotheses` / `suggested_next_steps` once it's wired to read them.
3. Optional v1: owner-facing `/analytics/escalations` page; Playwright e2e for the GTM Loom demo path.
4. Eventual cleanup: `flowpilot_engine.escalate_session` is no longer called by any endpoint and could be deleted; the legacy `SessionBriefing` render branch in `FlowPilotSessionPage.tsx` is effectively dead code for any new escalation (magic-moment takes over) but still useful for in-flight legacy `requesting_escalation` sessions during the transition window. Both can come out after pilots have run a couple of weeks on the unified path.
1. **First action: `git push` the two local commits.** `0f00ee5` and `665530f` are local-only.
2. **Visual QA + bug bash.** End-to-end demo flow:
- Junior escalates → senior gets bell-icon notification → click → magic-moment screen with **placeholder AI assessment** (because it's now async/background) → assessment populates **in place** within ~515s without manual reopen → Start here → chat surface loads with **suggested-step chips** above the composer → click a chip prefills input.
- On `/escalations`: backgrounded tab gets `(N)` title prefix when an arrival fires; new card has **6px accent dot** top-right; clicking the card body OR Pick Up clears the dot (verify it persists across refresh, doesn't clear on hover).
- Race condition: claim the same handoff from two browsers; loser sees toast `"Already claimed by {name} {time_ago}."` and bounces.
- **Task-lane regression check:** create a new session via dashboard prefill / pickup / "New Chat" — the lane must NOT flash the previous session's questions/actions. The user previously reported this happening repeatedly; the fix in `665530f` should kill it. If it still happens, that's the next debug target.
3. **Deferred follow-ups in `CURRENT_TASK.md`:** snapshot expansion, owner-facing `/analytics/escalations` page, Playwright e2e for the GTM Loom demo path, eventual cleanup of `flowpilot_engine.escalate_session` and the dead `FlowPilotSessionPage.tsx` magic-moment branch.
## Useful breadcrumbs
- 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`.
- Frontend SSE consumer: [`frontend/src/api/aiSessions.ts`](../frontend/src/api/aiSessions.ts) → `streamEscalations` (now dispatches `handoff_created` AND `handoff_assessment_ready`).
- 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`.
- Pickup integration + magic state machine + suggested-step chips + assessment-ready subscription + claim 409 handling + task-lane owner tagging: [`frontend/src/pages/AssistantChatPage.tsx`](../frontend/src/pages/AssistantChatPage.tsx).
- Claim conflict exception: [`backend/app/services/handoff_manager.py`](../backend/app/services/handoff_manager.py) — `HandoffAlreadyClaimedError`, `claim_session`, `enrich_escalation_async`.
- Metric endpoint: [`backend/app/api/endpoints/flowpilot_analytics.py`](../backend/app/api/endpoints/flowpilot_analytics.py).
## Watch-outs
- The two new commits are **local-only** until pushed. Run `git push` before any other work.
- The assessment-ready subscription opens a fresh SSE connection scoped by `assessmentMissing && trackedHandoffId`. If you change the magic-moment lifecycle, double-check the cleanup deps don't churn the subscription.
- The claim conflict path is currently only wired into `AssistantChatPage.handleStartHere`. `useHandoff` (used by `SessionQueuePage`) and `FlowPilotSessionPage.tsx` (dead) were not updated. If `SessionQueuePage` claims start mattering, mirror the same `axios.isAxiosError(e) && e.response?.status === 409` extraction.
- The handoff snapshot is still sparse (`problem_summary, problem_domain, status, step_count, confidence_tier`). Magic-moment "What's been tried" still only shows engineer notes + step count pre-claim.
- `HandoffResponse.ai_assessment_data.confidence` is typed `number` on the frontend but the backend currently emits `'low' | 'medium' | 'high'`. Runtime handles both; type definition is stale.
- Toolbar "Context" button is hidden on revisited active sessions where the senior didn't arrive via magic-moment this session — known scope cut.
- 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.
- Bus is acceptable for v1 pilot scale only (Railway single-replica). Redis pub/sub is the obvious swap when horizontal scaling appears.

View File

@@ -12,6 +12,20 @@
---
## 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`.

View File

@@ -22,7 +22,7 @@ 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,
@@ -129,6 +129,19 @@ async def claim_handoff(
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 ValueError as e:
raise HTTPException(status_code=404, detail=str(e))

View File

@@ -36,6 +36,30 @@ 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."""
@@ -398,14 +422,31 @@ 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.
"""
result = await self.db.execute(
select(SessionHandoff).where(SessionHandoff.id == handoff_id)
select(SessionHandoff)
.options(selectinload(SessionHandoff.claimed_by_user))
.where(SessionHandoff.id == handoff_id)
)
handoff = result.scalar_one_or_none()
if not handoff:
raise ValueError(f"Handoff {handoff_id} not found")
if handoff.claimed_by is not None and handoff.claimed_by != claiming_user_id:
claimer = handoff.claimed_by_user
raise HandoffAlreadyClaimedError(
claimed_by_id=handoff.claimed_by,
claimed_by_name=claimer.name if claimer else "another engineer",
claimed_at=handoff.claimed_at or datetime.now(timezone.utc),
)
handoff.claimed_by = claiming_user_id
handoff.claimed_at = datetime.now(timezone.utc)

View File

@@ -189,6 +189,99 @@ async def test_claim_session(client: AsyncClient, test_user, test_admin, auth_he
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)
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 — owner of the original session,
# 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=test_user["user_data"]["id"],
)
err = exc_info.value
assert 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 first.claimed_by == second.claimed_by == test_admin["user_data"]["id"]
# ─── Notification dispatch ────────────────────────────────────────────────────

View File

@@ -19,6 +19,7 @@ import type {
ChatMessageRequest,
ChatMessageResponse,
HandoffCreatedEvent,
HandoffAssessmentReadyEvent,
EscalationStreamHandlers,
} from '@/types/ai-session'
@@ -279,6 +280,13 @@ export const aiSessionsApi = {
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?.()
}

View File

@@ -26,6 +26,34 @@ const sortNewestFirst = (a: AISessionSummary, b: AISessionSummary) =>
// 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
@@ -42,6 +70,20 @@ export function EscalationQueue({ onPickup, onCountChange }: EscalationQueueProp
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 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.
@@ -190,6 +232,7 @@ export function EscalationQueue({ onPickup, onCountChange }: EscalationQueueProp
}, [handleHandoffCreated])
const handlePickup = (sessionId: string) => {
markSeen(sessionId)
if (onPickup) {
onPickup(sessionId)
} else {
@@ -197,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">
@@ -256,15 +307,26 @@ export function EscalationQueue({ onPickup, onCountChange }: EscalationQueueProp
<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(
'card-flat p-3 sm:p-4 space-y-3',
'relative card-flat p-3 sm:p-4 space-y-3 cursor-pointer',
isNew && !prefersReducedMotion && 'animate-slide-in-bottom',
isNew && prefersReducedMotion && 'animate-fade-in',
)}
>
{/* 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'}
@@ -303,7 +365,10 @@ export function EscalationQueue({ onPickup, onCountChange }: EscalationQueueProp
<div className="flex justify-end">
<button
onClick={() => handlePickup(session.id)}
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

View File

@@ -1,6 +1,8 @@
import { useState, useEffect, useRef, useCallback } from '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, MoreHorizontal, Pause, Plus } from 'lucide-react'
@@ -81,6 +83,12 @@ export default function AssistantChatPage() {
const [overlayHandoff, setOverlayHandoff] = useState<HandoffResponse | null>(null)
const [overlayLoading, setOverlayLoading] = useState(false)
const [claiming, setClaiming] = useState(false)
// 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 [chats, setChats] = useState<ChatListItem[]>([])
const [activeChatId, setActiveChatId] = useState<string | null>(() => {
if (urlSessionId) return urlSessionId
@@ -134,6 +142,24 @@ export default function AssistantChatPage() {
} 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'
)
@@ -299,6 +325,24 @@ export default function AssistantChatPage() {
setSearchParams({})
setMagicState('dismissed')
} catch (e: unknown) {
// Race-condition path (locked design): the loser of the simultaneous
// Pick Up gets a 409 with structured detail so we can name the
// winner and approximate "how long ago." Drop the magic-moment
// (the session is no longer theirs to claim) and let them go back
// to the queue.
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 {
@@ -328,6 +372,75 @@ export default function AssistantChatPage() {
}
}, [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(() => {
if (!urlSessionId && activeChatId) {
@@ -400,6 +513,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)
@@ -414,17 +528,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(() => {
@@ -480,6 +608,7 @@ export default function AssistantChatPage() {
setShowTaskLane(false)
setActiveQuestions([])
setActiveActions([])
setTaskLaneOwnerChatId(null)
setFacts([])
setActiveFix(null)
setPreviewKind(null)
@@ -520,7 +649,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.
@@ -693,7 +827,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
}
@@ -960,6 +1097,7 @@ export default function AssistantChatPage() {
setActiveQuestions(q)
setActiveActions(a)
setShowTaskLane(true)
setTaskLaneOwnerChatId(chatId)
}
}
} catch {
@@ -1027,6 +1165,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)
@@ -1062,6 +1201,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 —
@@ -1142,11 +1282,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.
@@ -1241,6 +1383,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)
@@ -1721,6 +1864,47 @@ export default function AssistantChatPage() {
/>
)}
{/* Suggested-step chips (Codex correction, locked design):
visible after the magic-moment dissolves (post-claim) so the
senior can pull the AI's suggested next steps into the
composer with one click. Hides on first send or explicit X. */}
{!chipsHidden &&
magicHandoff?.ai_assessment_data?.suggested_steps &&
magicHandoff.ai_assessment_data.suggested_steps.length > 0 &&
magicState === 'dismissed' && (
<div className="px-3 sm:px-6 pt-2 shrink-0">
<div className="max-w-3xl mx-auto flex items-start gap-2">
<p className="font-sans text-[0.625rem] uppercase tracking-wider text-muted-foreground pt-1.5 shrink-0">
Suggested
</p>
<div className="flex flex-wrap gap-1.5 flex-1 min-w-0">
{magicHandoff.ai_assessment_data.suggested_steps.map((step, i) => (
<button
key={i}
type="button"
onClick={() => {
setInput(step)
inputRef.current?.focus()
}}
className="rounded-full border border-default bg-elevated px-3 py-1 text-xs text-foreground hover:bg-accent-dim hover:text-accent-text hover:border-accent/30 transition-colors text-left max-w-full truncate"
title={step}
>
{step}
</button>
))}
</div>
<button
type="button"
onClick={() => setChipsHidden(true)}
aria-label="Hide suggestions"
className="p-1 rounded text-muted-foreground hover:text-foreground hover:bg-elevated transition-colors shrink-0"
>
<X size={12} />
</button>
</div>
</div>
)}
{/* Rich Input */}
<div className="px-3 sm:px-6 py-3 shrink-0">
<div
@@ -1823,7 +2007,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)}
@@ -1896,6 +2080,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)}
@@ -1917,7 +2102,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

View File

@@ -274,7 +274,18 @@ export interface HandoffCreatedEvent {
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
}