Compare commits
3 Commits
8914391336
...
b7d7ff06d2
| Author | SHA1 | Date | |
|---|---|---|---|
| b7d7ff06d2 | |||
| 665530f812 | |||
| 0f00ee5e01 |
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 ~5–15s 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.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -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?.()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user