Compare commits
5 Commits
fb2dc222fd
...
f10649abc2
| Author | SHA1 | Date | |
|---|---|---|---|
| f10649abc2 | |||
| ab5e0deaf7 | |||
| f601a0db58 | |||
| dc69c9ddfb | |||
| db717b0b3f |
@@ -2,61 +2,41 @@
|
|||||||
|
|
||||||
**Task:** Build **Escalation Mode** — the wedge for ResolutionFlow's GTM (first paying-customer push). When a junior tech escalates a FlowPilot session, the senior tech sees structured handoff context in seconds instead of running a 5-minute verbal "tell me what you tried" call.
|
**Task:** Build **Escalation Mode** — the wedge for ResolutionFlow's GTM (first paying-customer push). When a junior tech escalates a FlowPilot session, the senior tech sees structured handoff context in seconds instead of running a 5-minute verbal "tell me what you tried" call.
|
||||||
|
|
||||||
**Status:** in-flight on `feat/escalation-metric-endpoint`. Branch pushed; **draft PR #155** open ([gitea.resolutionflow.com/chihlasm/resolutionflow/pulls/155](https://gitea.resolutionflow.com/chihlasm/resolutionflow/pulls/155)). Live QA found one architectural issue blocking the demo — see "Active blocker" below.
|
**Status:** ✅ **Engineering complete.** Browser QA passed (2026-04-30). Branch `feat/escalation-metric-endpoint`; PR #155 ready to mark ready-for-review.
|
||||||
|
|
||||||
**Plan:** [`docs/plans/2026-04-27-escalation-mode-wedge-design.md`](../docs/plans/2026-04-27-escalation-mode-wedge-design.md). Reviewed by `/office-hours`, `/plan-eng-review`, `/plan-design-review`, `/codex review`. Eng + Design CLEARED.
|
**Plan:** [`docs/plans/2026-04-27-escalation-mode-wedge-design.md`](../docs/plans/2026-04-27-escalation-mode-wedge-design.md). Reviewed by `/office-hours`, `/plan-eng-review`, `/plan-design-review`, `/codex review`. Eng + Design CLEARED.
|
||||||
|
|
||||||
**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).
|
**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).
|
||||||
|
|
||||||
## Active blocker — AI assessment still empty after pickup
|
## What's done (all sessions combined)
|
||||||
|
|
||||||
**The bug** (live-test confirmed 2026-04-29): senior picks up an escalation, magic-moment screen renders with the "AI assessment is still generating" placeholder, and **the placeholder never clears**. Bus event fires with `has_assessment: false` because `_generate_ai_assessment` is hitting Sonnet tail latency or some other generation issue we haven't traced yet. Bumping `ESCALATION_AI_ASSESSMENT_TIMEOUT_SECONDS` from 15 → 45 (commit `0d1b305`) didn't fix it in the field.
|
All plan items complete. Key commits on `feat/escalation-metric-endpoint`:
|
||||||
|
|
||||||
**Why patching is the wrong move:** the real architectural issue is that we make **three** AI calls per escalation, all summarizing the same source material:
|
| Commit | What it ships |
|
||||||
|
|---|---|
|
||||||
|
| `d51e95c` | Plan + test-plan artifacts |
|
||||||
|
| `52f6d03` | `GET /analytics/flowpilot/escalations` — time-to-first-action metric |
|
||||||
|
| `7a5b853` | Role-gate claim to engineer-or-admin |
|
||||||
|
| `07d0db9` | Email notifications on escalation |
|
||||||
|
| `9f0bfd4` | `EscalationMetricCard` on `/escalations` |
|
||||||
|
| `b8627f4` | SSE live-arrival animations in `EscalationQueue` |
|
||||||
|
| `8e9d22e` | Magic-moment handoff-context screen |
|
||||||
|
| `641853a` | Bell-icon opens pickup flow |
|
||||||
|
| `029680a` | Unify `/escalate` through `HandoffManager` |
|
||||||
|
| `0f00ee5` | Plan-locked polish: chips, unread dot, race toast, AI refresh |
|
||||||
|
| `665530f` | Structural task-lane race fix |
|
||||||
|
| `db717b0` | 3-option CTA, copy button fix, post-escalation redirect, claim 500 fix |
|
||||||
|
| `dc69c9d` | Allow `escalated_to_id` to send chat (GET AI analysis fix) |
|
||||||
|
|
||||||
1. `_build_escalation_package_enhanced` (Sonnet) — rich JSON payload, runs in the background.
|
**Browser QA results (2026-04-30):**
|
||||||
2. `_generate_ai_assessment` (Sonnet, 500 tokens) — magic-moment fields (`likely_cause`, `suggested_steps[]`, `confidence`), background.
|
|
||||||
3. `generate_status_update` (Sonnet) — the PSA prose the engineer clicks "Ticket Notes" / "Client Update" / "Email Draft" to produce in `ConcludeSessionModal`, on demand.
|
|
||||||
|
|
||||||
User's correct observation (2026-04-29): the engineer is *typically* generating a status update during the escalate flow anyway. There's no reason to do that work three times.
|
- ✅ Post-escalation redirect (dashboard + toast)
|
||||||
|
- ✅ Magic-moment screen: header, AI assessment, 2-option CTA
|
||||||
**Next active task: consolidate the three calls into one.** See `## Active task — AI generation consolidation` below.
|
- ✅ "I'll take it from here": claim → dismiss → composer focused
|
||||||
|
- ✅ "Get AI analysis": claim → briefing → AI responds → task lane populates
|
||||||
## Active task — AI generation consolidation
|
- ✅ Task lane copy button: toast + checkmark
|
||||||
|
- ✅ Chip expansion: inline detail + "Open in Tasks panel"
|
||||||
**Goal:** ONE AI call per escalation that produces a single structured payload covering both the magic-moment screen's diagnostic fields AND the PSA-ready prose. Magic-moment populates immediately. The conclude modal's audience buttons become tone-shift transformations of the saved payload, not fresh API calls.
|
- ✅ Post-claim overlay: dismissible mode, Close only
|
||||||
|
|
||||||
**Proposed shape** (decide during implementation):
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Persist on SessionHandoff:
|
|
||||||
{
|
|
||||||
"summary_prose": "<PSA-flavored ticket-notes paragraph>",
|
|
||||||
"what_we_know": ["<one-liner>", ...],
|
|
||||||
"likely_cause": "<one sentence>",
|
|
||||||
"suggested_steps": ["<short step>", "<short step>"],
|
|
||||||
"confidence": "low" | "medium" | "high",
|
|
||||||
"audience_variants": {
|
|
||||||
# Filled lazily on first request; transformations not regenerations.
|
|
||||||
"client_update": null,
|
|
||||||
"email_draft": null,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Implementation order (suggested):**
|
|
||||||
|
|
||||||
1. **Backend:** Replace `_generate_ai_assessment` with `_generate_handoff_summary` (or rename — pick the right noun). One Sonnet call, structured JSON response, persisted to `handoff.ai_assessment_data` + a new `handoff.summary_prose` column (migration needed) OR repurpose the existing `ai_assessment` text column to hold the prose.
|
|
||||||
2. **Backend:** Make `generate_status_update` for `audience='ticket_notes'` / `context='escalation'` read from the saved payload first; only call the model if the payload is missing (fallback for legacy sessions). For `client_update` / `email_draft`, run a cheaper transformation pass (Haiku is fine for tone-shift) over the saved prose.
|
|
||||||
3. **Backend:** Drop `_build_escalation_package_enhanced` from the background path — its content overlaps heavily with the new summary, and the magic-moment screen already gets what it needs from the structured fields. Keep it only if downstream PSA push depends on it (verify by grep). Migration concern: the `ai_session.escalation_package` JSON column has live data — leave it readable, just stop *writing* the enhanced payload from `enrich_escalation_async`.
|
|
||||||
4. **Frontend:** `HandoffContextScreen` reads from the new structured fields. The `ConcludeSessionModal`'s "Ticket Notes" button stops generating fresh — it just copies the saved prose to clipboard / posts to PSA. "Client Update" and "Email Draft" buttons trigger the transformation endpoint.
|
|
||||||
5. **Test plan:** Magic-moment screen populates within ~5s instead of ~25s. Engineer's "Ticket Notes" button is instant. Token spend per escalation drops by ~60%.
|
|
||||||
|
|
||||||
**Watch-outs:**
|
|
||||||
|
|
||||||
- The schema for the structured response needs to be enforced — past calls returned freeform prose that the frontend can't parse into chips. Use Anthropic's tool-use / structured output if needed.
|
|
||||||
- Don't break the existing `escalation_package` JSON readers (PSA push, queue summaries). Stop *writing* the enhanced one but keep the dual-write of the basic snapshot.
|
|
||||||
- `_generate_ai_assessment` is referenced in tests (`test_handoff_manager.py` stubs it via `AsyncMock`). Update test fixtures when renaming.
|
|
||||||
|
|
||||||
## Done on `feat/escalation-metric-endpoint` (branched from `main` @ `c0ed6d9`)
|
## Done on `feat/escalation-metric-endpoint` (branched from `main` @ `c0ed6d9`)
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,25 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-04-30 — Allow `escalated_to_id` to send chat messages in claimed sessions
|
||||||
|
|
||||||
|
**Context:** During browser QA, clicking "Get AI analysis" on the magic-moment screen returned `POST /ai-sessions/{id}/chat → 400`. The senior tech who claimed the session is stored as `escalated_to_id` on `AISession`, not `user_id` (which remains the junior who created the session). `unified_chat_service.send_chat_message` queried `WHERE ai_sessions.user_id = :user_id`, so the senior's ID never matched and the endpoint rejected the request.
|
||||||
|
|
||||||
|
**Decision:** Extend the ownership check in `send_chat_message` to `OR ai_sessions.escalated_to_id = :user_id` using SQLAlchemy `or_()`. This is the minimal, correct fix: the session model already has a semantically valid "also owns" field for the claiming senior; extending the WHERE clause makes that ownership real.
|
||||||
|
|
||||||
|
**Rejected:**
|
||||||
|
|
||||||
|
- **Transfer `user_id` to the senior on claim.** Breaks the audit trail — `user_id` is the originating engineer throughout the session lifecycle. Any query scoped to "sessions this engineer worked on" would silently lose the junior's history.
|
||||||
|
- **A separate `can_send_message` service method.** Adds indirection with no benefit for v1. One `or_()` line in the existing query is sufficient.
|
||||||
|
- **Checking a role/permission flag instead.** Role gating (engineer/admin) already happens at the claim endpoint. The chat-send check is about session ownership, not role. Mixing the two concerns would be confusing.
|
||||||
|
|
||||||
|
**Consequences:**
|
||||||
|
- Seniors can send AI briefings and continue chat work in sessions they have claimed. Core escalation pickup flow unblocked.
|
||||||
|
- Any future caller of `send_chat_message` should be aware that "user_id or escalated_to_id" is the ownership rule. The service-level check is the single enforcement point.
|
||||||
|
- `user_id` remains the originating engineer for all audit, history, and analytics queries. No data migration needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2026-04-29 — Consolidate the three per-escalation AI calls into one structured generation
|
## 2026-04-29 — Consolidate the three per-escalation AI calls into one structured generation
|
||||||
|
|
||||||
**Context:** A single user-initiated escalation currently triggers three separate Sonnet calls, all summarizing the same source material (session state, steps taken, "what we know") from slightly different angles:
|
**Context:** A single user-initiated escalation currently triggers three separate Sonnet calls, all summarizing the same source material (session state, steps taken, "what we know") from slightly different angles:
|
||||||
|
|||||||
@@ -2,64 +2,55 @@
|
|||||||
|
|
||||||
# HANDOFF.md
|
# HANDOFF.md
|
||||||
|
|
||||||
**Last updated:** 2026-04-29 04:30 EDT
|
**Last updated:** 2026-04-30 (Codex review-fix pass)
|
||||||
|
|
||||||
**Active task:** **Escalation Mode** wedge — AI generation consolidation. Full status + design in [`CURRENT_TASK.md`](CURRENT_TASK.md). The wedge demo is **demo-blocked** by an empty AI assessment that didn't fix with a timeout bump. Architectural cause: 3 redundant AI calls per escalation; the right fix is to consolidate.
|
**Active task:** **Escalation Mode** wedge — BROWSER QA COMPLETE + review fixes applied. Branch: `feat/escalation-metric-endpoint`. PR #155 ready to mark ready-for-review after committing this fix pass.
|
||||||
|
|
||||||
**Branch:** `feat/escalation-metric-endpoint` at `0d1b305`. Pushed to origin. Draft PR #155 open.
|
## Where this session ended
|
||||||
|
|
||||||
## Where the previous session ended
|
Code-review fixes were applied after browser QA:
|
||||||
|
|
||||||
Live QA bash on the wedge demo. Branch state: 4 commits added this session (`0f00ee5`, `665530f`, `b7d7ff0`, `0d1b305`).
|
- `claim_session` now uses atomic conditional `UPDATE ... WHERE claimed_by IS NULL` instead of read-then-write, so simultaneous senior pickup cannot silently overwrite `claimed_by`.
|
||||||
|
- Original escalators cannot claim their own handoff. The escalation queue also excludes the current user's own escalated sessions, preventing the post-escalation dashboard from showing the junior their own handoff.
|
||||||
|
- `session.escalation_package["handoff_id"]` is now populated from a preassigned UUID instead of `None` before flush.
|
||||||
|
- Frontend build blockers removed: deleted unused legacy `claiming` / `handleStartHere` path in `AssistantChatPage` and unused `onStartHere` destructuring in `HandoffContextScreen`.
|
||||||
|
|
||||||
**Confirmed working in browser:**
|
**Validation:**
|
||||||
|
|
||||||
- Junior escalates → senior bell-icon notification
|
- `git diff --check` ✅
|
||||||
- Senior Pick Up → magic-moment screen with handoff data
|
- `cd backend && pytest --override-ini='addopts=' tests/test_handoff_manager.py tests/test_session_handoffs_api.py tests/test_escalation_bus.py` ✅ `28 passed in 42.23s`
|
||||||
- Senior Start Here → chat surface loads with conversation history (`0d1b305` fixed the selectChat-gating bug — was rendering blank before)
|
- `cd frontend && /config/.bun/bin/bunx tsc -p tsconfig.app.json --noEmit --pretty false && /config/.bun/bin/bunx tsc -p tsconfig.node.json --noEmit --pretty false` ✅
|
||||||
- Sidebar shows picked-up session with "Escalated" pill (`0d1b305`'s `loadChats()` after claim)
|
- Full frontend build could not complete because generated dirs are root-owned in this workspace: `frontend/node_modules/.tmp`, `frontend/node_modules/.vite-temp`, and likely `frontend/dist` produce EACCES. Type errors from review are fixed.
|
||||||
- Suggested-step chips render below the composer
|
|
||||||
- Unread 6px dot on queue cards persists across refresh
|
|
||||||
- Task-lane regression killed — no stale flash on new sessions
|
|
||||||
- Enter-to-submit (Shift+Enter for newline) on `EscalateModal` and `ConcludeSessionModal`
|
|
||||||
- `PendingEscalations` rows on dashboard expand to show escalation reason + step count + ticket #
|
|
||||||
|
|
||||||
**Active blocker:**
|
**Not testable in dev (known limitations):**
|
||||||
|
- "Continue where X left off": requires senior to have existing task lane for session (won't occur on first pickup)
|
||||||
- **AI assessment never populates** on the magic-moment screen. Bumping the timeout 15s → 45s in `0d1b305` did not fix it in the field. Backend logs from earlier in session showed Sonnet timing out at 15s; the assumption was the call would complete with more headroom, but live test still empty. May be a different failure mode (assessment generating but the bus event firing with `has_assessment: false`, or the frontend subscription not refetching, or the call genuinely failing past 45s).
|
- Browser-level 409 race toast still requires two distinct senior accounts. Backend claim write is now atomic and covered by service/API tests for conflict, self-claim, and idempotent same-user retry.
|
||||||
|
|
||||||
## Resume point — DO THIS NEXT
|
## Resume point — DO THIS NEXT
|
||||||
|
|
||||||
**Replace the three redundant AI calls with a single structured generation.** Full implementation plan in [`CURRENT_TASK.md`](CURRENT_TASK.md) under "Active task — AI generation consolidation." Summary:
|
**Ship:** Commit this review-fix pass, then mark PR #155 ready-for-review and demo to stakeholder.
|
||||||
|
|
||||||
1. **Backend:** Replace `_generate_ai_assessment` with one Sonnet call returning structured JSON: `summary_prose` (PSA-flavored) + `what_we_know[]` + `likely_cause` + `suggested_steps[]` + `confidence`. Persist to `SessionHandoff`. Use Anthropic structured output / tool-use to enforce the schema.
|
Optional before shipping:
|
||||||
2. **Backend:** Make `generate_status_update` for `audience='ticket_notes'` / `context='escalation'` read the saved payload (instant). For `client_update` and `email_draft`, run a cheaper Haiku transformation over the saved prose, not a full re-summarization.
|
- Record Loom demo walking through the escalation flow end-to-end
|
||||||
3. **Backend:** Stop calling `_build_escalation_package_enhanced` from the background path — overlapping content. Verify nothing downstream depends on the *enhanced* enriched payload before removing.
|
|
||||||
4. **Frontend:** `HandoffContextScreen` reads from the consolidated structured fields. `ConcludeSessionModal`'s "Ticket Notes" button stops generating, just copies the saved prose. "Client Update" / "Email Draft" trigger the cheap transformation.
|
|
||||||
5. **Test plan:** magic-moment populates in ~5s. Token spend down ~60%. AI summary blocker resolved.
|
|
||||||
|
|
||||||
**Implementation order (suggested):** 1 → 4 (so the magic moment shows the new fields) → 2 → 3 (cleanup) → tests.
|
## Key files changed this session
|
||||||
|
|
||||||
**Watch-outs:**
|
- `backend/app/services/handoff_manager.py` — `_generate_handoff_summary` replaces old assessment pair; `enrich_escalation_async` unified; `claim_session` eager-loads `handed_off_by_user`
|
||||||
|
- `backend/app/api/endpoints/ai_sessions.py` — escalation queue excludes the current user's own escalations
|
||||||
|
- `backend/app/api/endpoints/session_handoffs.py` — self-claim returns 403
|
||||||
|
- `backend/app/services/flowpilot_engine.py` — `generate_status_update` early-returns saved prose for `context='escalation'`
|
||||||
|
- `backend/app/schemas/session_handoff.py` — `handed_off_by_name: str | None = None` added
|
||||||
|
- `backend/app/api/endpoints/session_handoffs.py` — both create + claim endpoints pass `handed_off_by_name`
|
||||||
|
- `frontend/src/types/branching.ts` — `HandoffResponse` updated with `summary_prose`, `what_we_know`, `confidence: string`, `handed_off_by_name`
|
||||||
|
- `frontend/src/components/flowpilot/HandoffContextScreen.tsx` — 3-option CTA; `hasTaskLane`, `activeOptionKey`, `onContinue/onAIAnalysis/onOwnThing` props
|
||||||
|
- `frontend/src/components/assistant/TaskLane.tsx` — `id="task-lane-card-{idx}"` on all card variants
|
||||||
|
- `frontend/src/pages/AssistantChatPage.tsx` — `handleContinue`, `handleAIAnalysis`, `handleOwnThing` handlers; chip → card navigation; `activeOptionKey` state
|
||||||
|
- `backend/tests/test_handoff_manager.py`, `backend/tests/test_session_handoffs_api.py` — regression coverage for atomic/idempotent claim, self-claim rejection, queue self-exclusion, and pre-flush handoff ID
|
||||||
|
|
||||||
- Schema enforcement matters. Past calls returned freeform prose that doesn't parse into chips. Anthropic structured output / tool-use is the right tool.
|
## Watch-outs
|
||||||
- `escalation_package` JSON column has live data on existing sessions — keep it READABLE, just stop *writing* the enhanced payload from `enrich_escalation_async`. Dual-write the basic snapshot if downstream queue summaries need it.
|
|
||||||
- `_generate_ai_assessment` is stubbed in `test_handoff_manager.py` and `test_session_handoffs_api.py` via `AsyncMock`. Update test fixtures when renaming.
|
|
||||||
- The frontend assessment-ready SSE subscription (added in `0f00ee5`) is fine as-is — it'll dispatch on the new event payload. No client changes for the live-refresh path.
|
|
||||||
|
|
||||||
## Useful breadcrumbs
|
- Dev stack: backend `:8000`, frontend `:5173`, postgres `:5433` (docker-compose). HMR works.
|
||||||
|
- Test users (Acme MSP, password `TestPass123!`): `engineer@resolutionflow.example.com` (junior), `teamadmin@resolutionflow.example.com` (senior).
|
||||||
- AI assessment current impl: [`backend/app/services/handoff_manager.py`](../backend/app/services/handoff_manager.py) — `_generate_ai_assessment`, `_generate_ai_assessment_with_timeout`, `enrich_escalation_async`.
|
- `handleAIAnalysis` pre-adds `urlSessionId` to `loadedChatIdsRef` before dismissing so the normal selectChat effect doesn't double-fire. It then calls `selectChat` manually before sending the briefing.
|
||||||
- Status update current impl: [`backend/app/services/flowpilot_engine.py`](../backend/app/services/flowpilot_engine.py) — `generate_status_update`, `_build_status_update_prompt`, `_build_status_update_context`.
|
- Legacy `claiming` / `handleStartHere` on `AssistantChatPage` was removed; `activeOptionKey !== null` is the active pre-claim processing signal.
|
||||||
- Enhanced package builder: [`backend/app/services/flowpilot_engine.py`](../backend/app/services/flowpilot_engine.py) — `_build_escalation_package_enhanced` (line ~1694).
|
|
||||||
- Magic-moment screen: [`frontend/src/components/flowpilot/HandoffContextScreen.tsx`](../frontend/src/components/flowpilot/HandoffContextScreen.tsx).
|
|
||||||
- Conclude modal: [`frontend/src/components/assistant/ConcludeSessionModal.tsx`](../frontend/src/components/assistant/ConcludeSessionModal.tsx) — see `handleGenerateStatusUpdate`.
|
|
||||||
- Magic-moment integration + suggested-step chips: [`frontend/src/pages/AssistantChatPage.tsx`](../frontend/src/pages/AssistantChatPage.tsx).
|
|
||||||
- Test fixtures stubbing the assessment: `backend/tests/test_handoff_manager.py`, `backend/tests/test_session_handoffs_api.py`.
|
|
||||||
|
|
||||||
## Watch-outs (general)
|
|
||||||
|
|
||||||
- Dev stack on this machine: backend `:8000`, frontend `:5173`, postgres `:5433`. All running via docker-compose. HMR works.
|
|
||||||
- Test users (Acme MSP shared account, password `TestPass123!`): `engineer@resolutionflow.example.com` (junior), `teamadmin@resolutionflow.example.com` (senior).
|
|
||||||
- The bus is acceptable for v1 pilot scale only (Railway single-replica). Redis pub/sub is the swap when horizontal scaling appears.
|
- The bus is acceptable for v1 pilot scale only (Railway single-replica). Redis pub/sub is the swap when horizontal scaling appears.
|
||||||
- `streamEscalations` doesn't drive token refresh on a mid-stream 401. Acceptable for v1.
|
|
||||||
|
|||||||
@@ -12,6 +12,37 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-04-30 06:25 UTC — Codex — Apply Escalation Mode review fixes
|
||||||
|
|
||||||
|
- Reviewed the recent Escalation Mode wedge work and fixed the actionable findings before PR #155 is marked ready.
|
||||||
|
- Reworked `HandoffManager.claim_session` from read-then-write to an atomic conditional update, preserving idempotent same-user retries and returning a typed conflict for a different claimant.
|
||||||
|
- Blocked original engineers from claiming their own handoffs and filtered their own escalated sessions out of `/ai-sessions/escalation-queue`, preventing the post-escalation dashboard from showing a junior their own handoff.
|
||||||
|
- Fixed the compatibility payload so `session.escalation_package["handoff_id"]` is populated from a preassigned UUID before flush.
|
||||||
|
- Removed unused legacy frontend pickup state (`claiming`, `handleStartHere`, unused `onStartHere` destructuring) that made `tsc -b` fail under `noUnusedLocals`.
|
||||||
|
- Added regression coverage for pre-flush handoff IDs, conflict handling, self-claim rejection, successful non-owner claim, and own-escalation queue exclusion.
|
||||||
|
- Verified `git diff --check`; focused backend tests passed (`28 passed in 42.23s`); frontend `tsc --noEmit` checks passed for app and node configs. Full Vite/build script remains blocked by root-owned generated directories under `frontend/node_modules` / `frontend/dist` in this workspace, not by TypeScript errors.
|
||||||
|
- Files touched: `backend/app/services/handoff_manager.py`, `backend/app/api/endpoints/ai_sessions.py`, `backend/app/api/endpoints/session_handoffs.py`, `backend/tests/test_handoff_manager.py`, `backend/tests/test_session_handoffs_api.py`, `frontend/src/components/flowpilot/HandoffContextScreen.tsx`, `frontend/src/pages/AssistantChatPage.tsx`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`.
|
||||||
|
|
||||||
|
## 2026-04-30 — Claude Code — Browser QA pass complete; chat ownership bug found and fixed; PR #155 ready
|
||||||
|
|
||||||
|
- Ran full browser QA pass on the escalation mode feature using gstack `/qa` skill.
|
||||||
|
- **Critical bug found and fixed (commit `dc69c9d`):** `POST /ai-sessions/{id}/chat → 400` when senior clicked "Get AI analysis" on the magic-moment screen. Root cause: `unified_chat_service.send_chat_message` checked `AISession.user_id == user_id` only; senior is stored as `escalated_to_id`, not `user_id`. Fix: `or_(AISession.user_id == user_id, AISession.escalated_to_id == user_id)` in the WHERE clause.
|
||||||
|
- **All 7 QA scenarios passed:**
|
||||||
|
- Post-escalation redirect: junior routed to `/` with "Session escalated" toast.
|
||||||
|
- Magic-moment screen: header, metadata, two-column AI assessment, 2-option CTA rendered correctly.
|
||||||
|
- "I'll take it from here": claim → dismiss overlay → composer focused.
|
||||||
|
- "Get AI analysis": claim → briefing sent → AI responded → task lane populated (after `dc69c9d` fix).
|
||||||
|
- Task lane copy button: toast + checkmark visual feedback.
|
||||||
|
- Chip expansion: inline detail card + "Open in Tasks panel" scroll.
|
||||||
|
- Post-claim toolbar re-open: dismissible mode with Close-only CTA.
|
||||||
|
- **Known non-blockers:** "Continue where X left off" path untestable on first pickup (`hasTaskLane=false` is correct v1 behavior). 409 race condition untestable with one senior account; backend logic code-reviewed and correct.
|
||||||
|
- Backend tests: 17/17 pass.
|
||||||
|
- Updated `HANDOFF.md` to reflect QA complete; updated `CURRENT_TASK.md` status to engineering+QA complete; appended architectural decision to `DECISIONS.md`.
|
||||||
|
- Branch `feat/escalation-metric-endpoint` is ready for PR #155 to be marked ready-for-review.
|
||||||
|
- **Files touched this session:** `backend/app/services/unified_chat_service.py`, `.ai/HANDOFF.md`, `.ai/CURRENT_TASK.md`, `.ai/DECISIONS.md`, `.ai/SESSION_LOG.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2026-04-29 04:30 EDT — Claude Code — Live QA bash, pickup bug fixes, AI summary consolidation surfaced
|
## 2026-04-29 04:30 EDT — Claude Code — Live QA bash, pickup bug fixes, AI summary consolidation surfaced
|
||||||
|
|
||||||
- User on a freshly swapped computer ran the live QA flow. Identified two bugs missed by static analysis from the previous session:
|
- User on a freshly swapped computer ran the live QA flow. Identified two bugs missed by static analysis from the previous session:
|
||||||
|
|||||||
@@ -689,6 +689,7 @@ async def get_escalation_queue(
|
|||||||
.where(
|
.where(
|
||||||
scope_filter,
|
scope_filter,
|
||||||
AISession.status.in_(("requesting_escalation", "escalated")),
|
AISession.status.in_(("requesting_escalation", "escalated")),
|
||||||
|
AISession.user_id != current_user.id,
|
||||||
)
|
)
|
||||||
.order_by(AISession.created_at.desc())
|
.order_by(AISession.created_at.desc())
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -90,7 +90,9 @@ async def create_handoff(
|
|||||||
enrich_escalation_async, handoff.id, current_user.id
|
enrich_escalation_async, handoff.id, current_user.id
|
||||||
)
|
)
|
||||||
|
|
||||||
return HandoffResponse.model_validate(handoff)
|
return HandoffResponse.model_validate(handoff).model_copy(
|
||||||
|
update={"handed_off_by_name": current_user.name}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/handoffs", response_model=list[HandoffResponse])
|
@router.get("/handoffs", response_model=list[HandoffResponse])
|
||||||
@@ -142,11 +144,20 @@ async def claim_handoff(
|
|||||||
"claimed_at": e.claimed_at.isoformat(),
|
"claimed_at": e.claimed_at.isoformat(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
except PermissionError as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return HandoffResponse.model_validate(handoff)
|
handed_off_by_name = (
|
||||||
|
handoff.handed_off_by_user.name
|
||||||
|
if handoff.handed_off_by_user
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
return HandoffResponse.model_validate(handoff).model_copy(
|
||||||
|
update={"handed_off_by_name": handed_off_by_name}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@queue_router.get("/queue")
|
@queue_router.get("/queue")
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ class HandoffResponse(BaseModel):
|
|||||||
id: UUID
|
id: UUID
|
||||||
session_id: UUID
|
session_id: UUID
|
||||||
handed_off_by: UUID
|
handed_off_by: UUID
|
||||||
|
handed_off_by_name: str | None = None
|
||||||
intent: str
|
intent: str
|
||||||
source_branch_id: UUID | None
|
source_branch_id: UUID | None
|
||||||
snapshot: dict[str, Any]
|
snapshot: dict[str, Any]
|
||||||
|
|||||||
@@ -913,6 +913,41 @@ async def generate_status_update(
|
|||||||
"""Generate a status update for ticket notes, client communication, or email draft."""
|
"""Generate a status update for ticket notes, client communication, or email draft."""
|
||||||
session = await _load_session(session_id, user_id, db)
|
session = await _load_session(session_id, user_id, db)
|
||||||
|
|
||||||
|
# For escalation/ticket_notes, return the pre-generated handoff prose immediately
|
||||||
|
# if enrich_escalation_async has already populated it. This eliminates the
|
||||||
|
# redundant Sonnet re-summarization on every "Ticket Notes" click.
|
||||||
|
if request.context == "escalation" and request.audience == "ticket_notes":
|
||||||
|
from app.models.session_handoff import SessionHandoff
|
||||||
|
|
||||||
|
handoff_q = await db.execute(
|
||||||
|
select(SessionHandoff)
|
||||||
|
.where(
|
||||||
|
SessionHandoff.session_id == session_id,
|
||||||
|
SessionHandoff.intent == "escalate",
|
||||||
|
)
|
||||||
|
.order_by(SessionHandoff.created_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
escalation_handoff = handoff_q.scalar_one_or_none()
|
||||||
|
saved_data = (
|
||||||
|
escalation_handoff.ai_assessment_data or {}
|
||||||
|
) if escalation_handoff else {}
|
||||||
|
prose = saved_data.get("summary_prose") or (
|
||||||
|
escalation_handoff.ai_assessment if escalation_handoff else None
|
||||||
|
)
|
||||||
|
if prose:
|
||||||
|
return StatusUpdateResponse(
|
||||||
|
content=prose,
|
||||||
|
audience=request.audience,
|
||||||
|
length=request.length,
|
||||||
|
context=request.context,
|
||||||
|
session_status=session.status,
|
||||||
|
steps_completed=session.step_count or 0,
|
||||||
|
time_spent_display=None,
|
||||||
|
client_name=None,
|
||||||
|
generated_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
# Build conversation summary from session steps
|
# Build conversation summary from session steps
|
||||||
steps_summary = []
|
steps_summary = []
|
||||||
for step in sorted(session.steps, key=lambda s: s.step_order):
|
for step in sorted(session.steps, key=lambda s: s.step_order):
|
||||||
|
|||||||
@@ -14,15 +14,17 @@ on top of per-user emails. The `/escalate` endpoint is now a thin shim
|
|||||||
calling these in sequence.
|
calling these in sequence.
|
||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from uuid import UUID
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.core.ai_provider import get_ai_provider
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.email import EmailService
|
from app.core.email import EmailService
|
||||||
from app.core.escalation_bus import bus as escalation_bus
|
from app.core.escalation_bus import bus as escalation_bus
|
||||||
@@ -86,6 +88,10 @@ class HandoffManager:
|
|||||||
to produce), and merges the handoff metadata into it. Self-targeting
|
to produce), and merges the handoff metadata into it. Self-targeting
|
||||||
is rejected with ValueError, matching legacy behavior.
|
is rejected with ValueError, matching legacy behavior.
|
||||||
"""
|
"""
|
||||||
|
user_id = UUID(str(user_id))
|
||||||
|
if target_user_id:
|
||||||
|
target_user_id = UUID(str(target_user_id))
|
||||||
|
|
||||||
# Eager-load steps + user — _build_escalation_package_enhanced and
|
# Eager-load steps + user — _build_escalation_package_enhanced and
|
||||||
# finalize_escalation iterate over session.steps to compose the
|
# finalize_escalation iterate over session.steps to compose the
|
||||||
# legacy enriched package and the SessionDocumentation, and the
|
# legacy enriched package and the SessionDocumentation, and the
|
||||||
@@ -123,7 +129,9 @@ class HandoffManager:
|
|||||||
# immediately with `ai_assessment=None`; the magic-moment screen
|
# immediately with `ai_assessment=None`; the magic-moment screen
|
||||||
# shows "Assessment still computing" until enrich_async finishes
|
# shows "Assessment still computing" until enrich_async finishes
|
||||||
# and the senior refreshes (or, eventually, polls).
|
# and the senior refreshes (or, eventually, polls).
|
||||||
|
handoff_id = uuid4()
|
||||||
handoff = SessionHandoff(
|
handoff = SessionHandoff(
|
||||||
|
id=handoff_id,
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
account_id=session.account_id,
|
account_id=session.account_id,
|
||||||
handed_off_by=user_id,
|
handed_off_by=user_id,
|
||||||
@@ -157,7 +165,7 @@ class HandoffManager:
|
|||||||
"snapshot": snapshot,
|
"snapshot": snapshot,
|
||||||
"intent": intent,
|
"intent": intent,
|
||||||
"engineer_notes": engineer_notes,
|
"engineer_notes": engineer_notes,
|
||||||
"handoff_id": str(handoff.id),
|
"handoff_id": str(handoff_id),
|
||||||
}
|
}
|
||||||
|
|
||||||
await self.db.flush()
|
await self.db.flush()
|
||||||
@@ -430,26 +438,49 @@ class HandoffManager:
|
|||||||
the API can return 409 with the data the loser's toast needs. A
|
the API can return 409 with the data the loser's toast needs. A
|
||||||
re-claim by the same user is idempotent.
|
re-claim by the same user is idempotent.
|
||||||
"""
|
"""
|
||||||
|
claiming_user_id = UUID(str(claiming_user_id))
|
||||||
|
claimed_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
update_result = await self.db.execute(
|
||||||
|
update(SessionHandoff)
|
||||||
|
.where(
|
||||||
|
SessionHandoff.id == handoff_id,
|
||||||
|
SessionHandoff.claimed_by.is_(None),
|
||||||
|
SessionHandoff.handed_off_by != claiming_user_id,
|
||||||
|
)
|
||||||
|
.values(claimed_by=claiming_user_id, claimed_at=claimed_at)
|
||||||
|
.returning(SessionHandoff.id)
|
||||||
|
)
|
||||||
|
claimed_now = update_result.scalar_one_or_none() is not None
|
||||||
|
|
||||||
result = await self.db.execute(
|
result = await self.db.execute(
|
||||||
select(SessionHandoff)
|
select(SessionHandoff)
|
||||||
.options(selectinload(SessionHandoff.claimed_by_user))
|
.options(
|
||||||
|
selectinload(SessionHandoff.claimed_by_user),
|
||||||
|
selectinload(SessionHandoff.handed_off_by_user),
|
||||||
|
)
|
||||||
.where(SessionHandoff.id == handoff_id)
|
.where(SessionHandoff.id == handoff_id)
|
||||||
)
|
)
|
||||||
handoff = result.scalar_one_or_none()
|
handoff = result.scalar_one_or_none()
|
||||||
if not handoff:
|
if not handoff:
|
||||||
raise ValueError(f"Handoff {handoff_id} not found")
|
raise ValueError(f"Handoff {handoff_id} not found")
|
||||||
|
|
||||||
if handoff.claimed_by is not None and handoff.claimed_by != claiming_user_id:
|
handed_off_by = UUID(str(handoff.handed_off_by))
|
||||||
|
claimed_by = (
|
||||||
|
UUID(str(handoff.claimed_by)) if handoff.claimed_by is not None else None
|
||||||
|
)
|
||||||
|
|
||||||
|
if handed_off_by == claiming_user_id:
|
||||||
|
raise PermissionError("Cannot claim your own handoff")
|
||||||
|
|
||||||
|
if not claimed_now and claimed_by != claiming_user_id:
|
||||||
claimer = handoff.claimed_by_user
|
claimer = handoff.claimed_by_user
|
||||||
raise HandoffAlreadyClaimedError(
|
raise HandoffAlreadyClaimedError(
|
||||||
claimed_by_id=handoff.claimed_by,
|
claimed_by_id=claimed_by,
|
||||||
claimed_by_name=claimer.name if claimer else "another engineer",
|
claimed_by_name=claimer.name if claimer else "another engineer",
|
||||||
claimed_at=handoff.claimed_at or datetime.now(timezone.utc),
|
claimed_at=handoff.claimed_at or datetime.now(timezone.utc),
|
||||||
)
|
)
|
||||||
|
|
||||||
handoff.claimed_by = claiming_user_id
|
|
||||||
handoff.claimed_at = datetime.now(timezone.utc)
|
|
||||||
|
|
||||||
# Reactivate session
|
# Reactivate session
|
||||||
session_result = await self.db.execute(
|
session_result = await self.db.execute(
|
||||||
select(AISession).where(AISession.id == handoff.session_id)
|
select(AISession).where(AISession.id == handoff.session_id)
|
||||||
@@ -463,61 +494,111 @@ class HandoffManager:
|
|||||||
await self.db.flush()
|
await self.db.flush()
|
||||||
return handoff
|
return handoff
|
||||||
|
|
||||||
async def _generate_ai_assessment(
|
async def _generate_handoff_summary(
|
||||||
self, session: AISession
|
self, session: AISession
|
||||||
) -> tuple[str | None, dict[str, Any] | None]:
|
) -> dict[str, Any] | None:
|
||||||
"""Generate AI diagnostic assessment for escalation handoffs."""
|
"""Single structured AI call for the escalation magic-moment screen.
|
||||||
try:
|
|
||||||
from app.services.assistant_chat_service import _call_ai
|
|
||||||
|
|
||||||
context = f"Problem: {session.problem_summary or 'Unknown'}\nDomain: {session.problem_domain or 'Unknown'}"
|
Returns a dict with summary_prose, what_we_know, likely_cause,
|
||||||
msgs = session.conversation_messages or []
|
suggested_steps, and confidence. Returns None on timeout or error.
|
||||||
# Include last 10 messages for context
|
Replaces the old _generate_ai_assessment + _generate_ai_assessment_with_timeout
|
||||||
recent = "\n".join(
|
pair, which returned freeform prose with no usable structured fields.
|
||||||
f"[{m.get('role', '?')}]: {m.get('content', '')[:200]}"
|
"""
|
||||||
for m in msgs[-10:]
|
|
||||||
)
|
|
||||||
|
|
||||||
assessment_text, _, _ = await _call_ai(
|
|
||||||
system_base="You are a diagnostic assessment generator for MSP escalations.",
|
|
||||||
rag_context="",
|
|
||||||
history=[],
|
|
||||||
new_message=(
|
|
||||||
f"Generate a brief diagnostic assessment for this escalation.\n"
|
|
||||||
f"{context}\n\nRecent conversation:\n{recent}\n\n"
|
|
||||||
f"Return: 1) Most likely cause, 2) Suggested next steps, 3) Confidence (low/medium/high)"
|
|
||||||
),
|
|
||||||
max_tokens=500,
|
|
||||||
)
|
|
||||||
|
|
||||||
assessment_data = {
|
|
||||||
"likely_cause": "See assessment text",
|
|
||||||
"suggested_steps": [],
|
|
||||||
"confidence": "medium",
|
|
||||||
}
|
|
||||||
|
|
||||||
return assessment_text, assessment_data
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Failed to generate AI assessment")
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
async def _generate_ai_assessment_with_timeout(
|
|
||||||
self, session: AISession
|
|
||||||
) -> tuple[str | None, dict[str, Any] | None]:
|
|
||||||
"""Generate optional escalation assessment within the click-path budget."""
|
|
||||||
timeout = settings.ESCALATION_AI_ASSESSMENT_TIMEOUT_SECONDS
|
timeout = settings.ESCALATION_AI_ASSESSMENT_TIMEOUT_SECONDS
|
||||||
try:
|
try:
|
||||||
return await asyncio.wait_for(
|
return await asyncio.wait_for(
|
||||||
self._generate_ai_assessment(session),
|
self._generate_handoff_summary_inner(session),
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Escalation AI assessment timed out after %ss for session %s",
|
"Handoff summary timed out after %ss for session %s",
|
||||||
timeout,
|
timeout,
|
||||||
session.id,
|
session.id,
|
||||||
)
|
)
|
||||||
return None, None
|
return None
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"Handoff summary failed for session %s", session.id
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _generate_handoff_summary_inner(
|
||||||
|
self, session: AISession
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
steps = session.steps or []
|
||||||
|
steps_tried = []
|
||||||
|
for step in sorted(steps, key=lambda s: s.step_order):
|
||||||
|
content = step.content or {}
|
||||||
|
text = content.get("text", "").strip()
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
entry = text
|
||||||
|
if step.selected_option:
|
||||||
|
entry += f" → {step.selected_option}"
|
||||||
|
elif step.free_text_input:
|
||||||
|
entry += f" → {step.free_text_input[:100]}"
|
||||||
|
elif step.was_skipped:
|
||||||
|
entry += " (skipped)"
|
||||||
|
steps_tried.append(entry)
|
||||||
|
steps_text = (
|
||||||
|
"\n".join(f"- {s}" for s in steps_tried[:15])
|
||||||
|
or "No diagnostic steps recorded."
|
||||||
|
)
|
||||||
|
|
||||||
|
msgs = session.conversation_messages or []
|
||||||
|
recent_msgs = "\n".join(
|
||||||
|
f"[{m.get('role', '?')}]: {m.get('content', '')[:200]}"
|
||||||
|
for m in msgs[-10:]
|
||||||
|
)
|
||||||
|
|
||||||
|
prompt = (
|
||||||
|
"Generate a structured escalation handoff summary.\n\n"
|
||||||
|
f"Problem: {session.problem_summary or 'Unknown'}\n"
|
||||||
|
f"Domain: {session.problem_domain or 'Unknown'}\n"
|
||||||
|
f"Escalation reason: {session.escalation_reason or 'Not provided'}\n\n"
|
||||||
|
f"Diagnostic steps taken:\n{steps_text}\n\n"
|
||||||
|
f"Recent conversation:\n{recent_msgs}\n\n"
|
||||||
|
"Respond with ONLY a valid JSON object matching this schema exactly:\n"
|
||||||
|
'{"summary_prose": "<2-3 sentences suitable for PSA ticket notes>",\n'
|
||||||
|
' "what_we_know": ["<confirmed fact 1>", "<confirmed fact 2>"],\n'
|
||||||
|
' "likely_cause": "<one sentence root cause hypothesis>",\n'
|
||||||
|
' "suggested_steps": ["<next step 1>", "<next step 2>"],\n'
|
||||||
|
' "confidence": "<low or medium or high>"}'
|
||||||
|
)
|
||||||
|
|
||||||
|
provider = get_ai_provider(settings.get_model_for_action("escalation_package"))
|
||||||
|
raw, _, _ = await provider.generate_json(
|
||||||
|
system_prompt=(
|
||||||
|
"You are a diagnostic assessment generator for MSP tech support escalations. "
|
||||||
|
"Always respond with valid JSON and nothing else. "
|
||||||
|
"Be concise and factual."
|
||||||
|
),
|
||||||
|
messages=[{"role": "user", "content": prompt}],
|
||||||
|
max_tokens=700,
|
||||||
|
)
|
||||||
|
|
||||||
|
cleaned = raw.strip()
|
||||||
|
if cleaned.startswith("```"):
|
||||||
|
lines = cleaned.split("\n", 1)
|
||||||
|
cleaned = lines[1] if len(lines) > 1 else cleaned
|
||||||
|
if cleaned.endswith("```"):
|
||||||
|
cleaned = cleaned[:-3].rstrip()
|
||||||
|
|
||||||
|
result = json.loads(cleaned)
|
||||||
|
|
||||||
|
if not isinstance(result.get("suggested_steps"), list):
|
||||||
|
result["suggested_steps"] = []
|
||||||
|
if not isinstance(result.get("what_we_know"), list):
|
||||||
|
result["what_we_know"] = []
|
||||||
|
if result.get("confidence") not in ("low", "medium", "high"):
|
||||||
|
result["confidence"] = "medium"
|
||||||
|
if not isinstance(result.get("summary_prose"), str) or not result.get("summary_prose"):
|
||||||
|
result["summary_prose"] = result.get("likely_cause", "Assessment generated.")
|
||||||
|
if not isinstance(result.get("likely_cause"), str):
|
||||||
|
result["likely_cause"] = ""
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
async def generate_briefing(
|
async def generate_briefing(
|
||||||
self, handoff_id: UUID, claiming_user_id: UUID
|
self, handoff_id: UUID, claiming_user_id: UUID
|
||||||
@@ -671,37 +752,29 @@ async def enrich_escalation_async(handoff_id: UUID, user_id: UUID) -> None:
|
|||||||
|
|
||||||
manager = HandoffManager(db)
|
manager = HandoffManager(db)
|
||||||
|
|
||||||
# Build the enhanced package (Sonnet). Don't fail the whole
|
# Single consolidated AI call — replaces the old
|
||||||
# task if it errors — the assessment is independently useful.
|
# _generate_ai_assessment + _build_enhanced_escalation_package pair.
|
||||||
try:
|
try:
|
||||||
enhanced_pkg = await manager._build_enhanced_escalation_package(
|
summary = await manager._generate_handoff_summary(session)
|
||||||
session, user_id
|
if summary:
|
||||||
)
|
# ai_assessment (text) holds the PSA prose for backward compat
|
||||||
if enhanced_pkg:
|
# (push_to_psa reads it; generate_status_update falls back to it).
|
||||||
enhanced_pkg["intent"] = "escalate"
|
handoff.ai_assessment = summary.get("summary_prose")
|
||||||
enhanced_pkg["engineer_notes"] = handoff.engineer_notes
|
handoff.ai_assessment_data = summary
|
||||||
enhanced_pkg["handoff_id"] = str(handoff.id)
|
# Keep suggested_next_steps in escalation_package so
|
||||||
if isinstance(session.escalation_package, dict):
|
# psa_documentation_service can read it without a handoff join.
|
||||||
enhanced_pkg.setdefault(
|
existing_pkg = (
|
||||||
"snapshot", session.escalation_package.get("snapshot")
|
session.escalation_package
|
||||||
)
|
if isinstance(session.escalation_package, dict)
|
||||||
session.escalation_package = enhanced_pkg
|
else {}
|
||||||
|
)
|
||||||
|
session.escalation_package = {
|
||||||
|
**existing_pkg,
|
||||||
|
"suggested_next_steps": summary.get("suggested_steps", []),
|
||||||
|
}
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"enrich_escalation_async: enhanced package build failed for handoff %s",
|
"enrich_escalation_async: summary generation failed for handoff %s",
|
||||||
handoff_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Generate the diagnostic AI assessment.
|
|
||||||
try:
|
|
||||||
ai_assessment, ai_assessment_data = (
|
|
||||||
await manager._generate_ai_assessment_with_timeout(session)
|
|
||||||
)
|
|
||||||
handoff.ai_assessment = ai_assessment
|
|
||||||
handoff.ai_assessment_data = ai_assessment_data
|
|
||||||
except Exception:
|
|
||||||
logger.exception(
|
|
||||||
"enrich_escalation_async: assessment generation failed for handoff %s",
|
|
||||||
handoff_id,
|
handoff_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -714,7 +787,7 @@ async def enrich_escalation_async(handoff_id: UUID, user_id: UUID) -> None:
|
|||||||
"type": "handoff_assessment_ready",
|
"type": "handoff_assessment_ready",
|
||||||
"handoff_id": str(handoff.id),
|
"handoff_id": str(handoff.id),
|
||||||
"session_id": str(handoff.session_id),
|
"session_id": str(handoff.session_id),
|
||||||
"has_assessment": handoff.ai_assessment is not None,
|
"has_assessment": handoff.ai_assessment_data is not None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -583,10 +583,14 @@ async def send_chat_message(
|
|||||||
|
|
||||||
Returns (ai_content, suggested_flows, session, fork_metadata, actions_data, questions_data).
|
Returns (ai_content, suggested_flows, session, fork_metadata, actions_data, questions_data).
|
||||||
"""
|
"""
|
||||||
|
from sqlalchemy import or_
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(AISession).where(
|
select(AISession).where(
|
||||||
AISession.id == session_id,
|
AISession.id == session_id,
|
||||||
AISession.user_id == user_id,
|
or_(
|
||||||
|
AISession.user_id == user_id,
|
||||||
|
AISession.escalated_to_id == user_id,
|
||||||
|
),
|
||||||
AISession.session_type == "chat",
|
AISession.session_type == "chat",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -15,16 +15,15 @@ def stub_ai_assessment():
|
|||||||
"""Keep handoff tests focused on handoff behavior, not external AI calls."""
|
"""Keep handoff tests focused on handoff behavior, not external AI calls."""
|
||||||
with patch.object(
|
with patch.object(
|
||||||
HandoffManager,
|
HandoffManager,
|
||||||
"_generate_ai_assessment",
|
"_generate_handoff_summary",
|
||||||
new=AsyncMock(
|
new=AsyncMock(
|
||||||
return_value=(
|
return_value={
|
||||||
"Stub escalation assessment",
|
"summary_prose": "Stub escalation assessment",
|
||||||
{
|
"what_we_know": [],
|
||||||
"likely_cause": "Stub",
|
"likely_cause": "Stub",
|
||||||
"suggested_steps": [],
|
"suggested_steps": [],
|
||||||
"confidence": "medium",
|
"confidence": "medium",
|
||||||
},
|
}
|
||||||
)
|
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
yield
|
yield
|
||||||
@@ -100,6 +99,7 @@ async def test_create_escalate_handoff(client: AsyncClient, test_user, auth_head
|
|||||||
assert session.status == "escalated"
|
assert session.status == "escalated"
|
||||||
assert session.escalation_package is not None
|
assert session.escalation_package is not None
|
||||||
assert "branch_map" in session.escalation_package or "snapshot" in session.escalation_package
|
assert "branch_map" in session.escalation_package or "snapshot" in session.escalation_package
|
||||||
|
assert session.escalation_package["handoff_id"] == str(handoff.id)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -120,9 +120,9 @@ async def test_create_escalate_handoff_does_not_wait_on_slow_ai_assessment(
|
|||||||
test_db.add(session)
|
test_db.add(session)
|
||||||
await test_db.flush()
|
await test_db.flush()
|
||||||
|
|
||||||
async def slow_assessment(self, session):
|
async def slow_summary(self, session):
|
||||||
await asyncio.sleep(0.2)
|
await asyncio.sleep(0.2)
|
||||||
return "too slow", {"confidence": "medium"}
|
return {"summary_prose": "too slow", "confidence": "medium"}
|
||||||
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"app.services.handoff_manager.settings."
|
"app.services.handoff_manager.settings."
|
||||||
@@ -131,8 +131,8 @@ async def test_create_escalate_handoff_does_not_wait_on_slow_ai_assessment(
|
|||||||
)
|
)
|
||||||
with patch.object(
|
with patch.object(
|
||||||
HandoffManager,
|
HandoffManager,
|
||||||
"_generate_ai_assessment",
|
"_generate_handoff_summary_inner",
|
||||||
new=slow_assessment,
|
new=slow_summary,
|
||||||
):
|
):
|
||||||
manager = HandoffManager(test_db)
|
manager = HandoffManager(test_db)
|
||||||
handoff = await manager.create_handoff(
|
handoff = await manager.create_handoff(
|
||||||
@@ -182,7 +182,7 @@ async def test_claim_session(client: AsyncClient, test_user, test_admin, auth_he
|
|||||||
claiming_user_id=test_admin["user_data"]["id"],
|
claiming_user_id=test_admin["user_data"]["id"],
|
||||||
)
|
)
|
||||||
|
|
||||||
assert claimed.claimed_by == test_admin["user_data"]["id"]
|
assert str(claimed.claimed_by) == test_admin["user_data"]["id"]
|
||||||
assert claimed.claimed_at is not None
|
assert claimed.claimed_at is not None
|
||||||
|
|
||||||
await test_db.refresh(session)
|
await test_db.refresh(session)
|
||||||
@@ -213,6 +213,15 @@ async def test_claim_session_conflict_raises_already_claimed(
|
|||||||
conversation_messages=[],
|
conversation_messages=[],
|
||||||
)
|
)
|
||||||
test_db.add(session)
|
test_db.add(session)
|
||||||
|
loser = User(
|
||||||
|
email="race-loser@example.com",
|
||||||
|
password_hash="x",
|
||||||
|
name="Race Loser",
|
||||||
|
role="engineer",
|
||||||
|
account_id=test_user["user_data"]["account_id"],
|
||||||
|
account_role="engineer",
|
||||||
|
)
|
||||||
|
test_db.add(loser)
|
||||||
await test_db.flush()
|
await test_db.flush()
|
||||||
|
|
||||||
manager = HandoffManager(test_db)
|
manager = HandoffManager(test_db)
|
||||||
@@ -229,16 +238,16 @@ async def test_claim_session_conflict_raises_already_claimed(
|
|||||||
claiming_user_id=test_admin["user_data"]["id"],
|
claiming_user_id=test_admin["user_data"]["id"],
|
||||||
)
|
)
|
||||||
|
|
||||||
# Second claim by a different user — owner of the original session,
|
# Second claim by a different user — standing in for the other senior who
|
||||||
# standing in for "the other senior who lost the race."
|
# lost the race.
|
||||||
with pytest.raises(HandoffAlreadyClaimedError) as exc_info:
|
with pytest.raises(HandoffAlreadyClaimedError) as exc_info:
|
||||||
await manager.claim_session(
|
await manager.claim_session(
|
||||||
handoff_id=handoff.id,
|
handoff_id=handoff.id,
|
||||||
claiming_user_id=test_user["user_data"]["id"],
|
claiming_user_id=loser.id,
|
||||||
)
|
)
|
||||||
|
|
||||||
err = exc_info.value
|
err = exc_info.value
|
||||||
assert err.claimed_by_id == test_admin["user_data"]["id"]
|
assert str(err.claimed_by_id) == test_admin["user_data"]["id"]
|
||||||
assert err.claimed_by_name # populated from User.name
|
assert err.claimed_by_name # populated from User.name
|
||||||
assert err.claimed_at is not None
|
assert err.claimed_at is not None
|
||||||
|
|
||||||
@@ -279,7 +288,40 @@ async def test_claim_session_idempotent_for_same_user(
|
|||||||
claiming_user_id=test_admin["user_data"]["id"],
|
claiming_user_id=test_admin["user_data"]["id"],
|
||||||
)
|
)
|
||||||
|
|
||||||
assert first.claimed_by == second.claimed_by == test_admin["user_data"]["id"]
|
assert str(first.claimed_by) == str(second.claimed_by) == test_admin["user_data"]["id"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_claim_session_rejects_self_claim(
|
||||||
|
client: AsyncClient, test_user, auth_headers, test_db
|
||||||
|
):
|
||||||
|
"""The engineer who escalated a session cannot pick up their own handoff."""
|
||||||
|
session = AISession(
|
||||||
|
user_id=test_user["user_data"]["id"],
|
||||||
|
account_id=test_user["user_data"]["account_id"],
|
||||||
|
session_type="guided",
|
||||||
|
intake_type="free_text",
|
||||||
|
intake_content={"text": "test"},
|
||||||
|
status="active",
|
||||||
|
confidence_tier="discovery",
|
||||||
|
conversation_messages=[],
|
||||||
|
)
|
||||||
|
test_db.add(session)
|
||||||
|
await test_db.flush()
|
||||||
|
|
||||||
|
manager = HandoffManager(test_db)
|
||||||
|
handoff = await manager.create_handoff(
|
||||||
|
session_id=session.id,
|
||||||
|
intent="escalate",
|
||||||
|
engineer_notes="Need help",
|
||||||
|
user_id=test_user["user_data"]["id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(PermissionError):
|
||||||
|
await manager.claim_session(
|
||||||
|
handoff_id=handoff.id,
|
||||||
|
claiming_user_id=test_user["user_data"]["id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ─── Notification dispatch ────────────────────────────────────────────────────
|
# ─── Notification dispatch ────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from sqlalchemy import select
|
|||||||
from app.api.endpoints.session_handoffs import stream_escalations
|
from app.api.endpoints.session_handoffs import stream_escalations
|
||||||
from app.core.escalation_bus import bus as escalation_bus
|
from app.core.escalation_bus import bus as escalation_bus
|
||||||
from app.models.ai_session import AISession
|
from app.models.ai_session import AISession
|
||||||
|
from app.models.session_handoff import SessionHandoff
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.services.handoff_manager import HandoffManager
|
from app.services.handoff_manager import HandoffManager
|
||||||
|
|
||||||
@@ -23,16 +24,15 @@ def stub_ai_assessment():
|
|||||||
"""Endpoint tests should not wait on the external AI assessment path."""
|
"""Endpoint tests should not wait on the external AI assessment path."""
|
||||||
with patch.object(
|
with patch.object(
|
||||||
HandoffManager,
|
HandoffManager,
|
||||||
"_generate_ai_assessment",
|
"_generate_handoff_summary",
|
||||||
new=AsyncMock(
|
new=AsyncMock(
|
||||||
return_value=(
|
return_value={
|
||||||
"Stub escalation assessment",
|
"summary_prose": "Stub escalation assessment",
|
||||||
{
|
"what_we_know": [],
|
||||||
"likely_cause": "Stub",
|
"likely_cause": "Stub",
|
||||||
"suggested_steps": [],
|
"suggested_steps": [],
|
||||||
"confidence": "medium",
|
"confidence": "medium",
|
||||||
},
|
}
|
||||||
)
|
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
yield
|
yield
|
||||||
@@ -197,8 +197,19 @@ async def test_claim_allowed_for_engineer_role(
|
|||||||
client: AsyncClient, test_user, auth_headers, test_db
|
client: AsyncClient, test_user, auth_headers, test_db
|
||||||
):
|
):
|
||||||
"""POST /handoffs/{id}/claim succeeds for engineer-or-admin roles."""
|
"""POST /handoffs/{id}/claim succeeds for engineer-or-admin roles."""
|
||||||
|
original_engineer = User(
|
||||||
|
email="original-engineer@example.com",
|
||||||
|
password_hash="x",
|
||||||
|
name="Original Engineer",
|
||||||
|
role="engineer",
|
||||||
|
account_id=test_user["user_data"]["account_id"],
|
||||||
|
account_role="engineer",
|
||||||
|
)
|
||||||
|
test_db.add(original_engineer)
|
||||||
|
await test_db.flush()
|
||||||
|
|
||||||
session = AISession(
|
session = AISession(
|
||||||
user_id=test_user["user_data"]["id"],
|
user_id=original_engineer.id,
|
||||||
account_id=test_user["user_data"]["account_id"],
|
account_id=test_user["user_data"]["account_id"],
|
||||||
session_type="guided",
|
session_type="guided",
|
||||||
intake_type="free_text",
|
intake_type="free_text",
|
||||||
@@ -208,21 +219,106 @@ async def test_claim_allowed_for_engineer_role(
|
|||||||
conversation_messages=[],
|
conversation_messages=[],
|
||||||
)
|
)
|
||||||
test_db.add(session)
|
test_db.add(session)
|
||||||
await test_db.commit()
|
await test_db.flush()
|
||||||
|
|
||||||
create_resp = await client.post(
|
handoff = SessionHandoff(
|
||||||
f"/api/v1/ai-sessions/{session.id}/handoff",
|
session_id=session.id,
|
||||||
headers=auth_headers,
|
account_id=test_user["user_data"]["account_id"],
|
||||||
json={"intent": "escalate", "engineer_notes": "Need help"},
|
handed_off_by=original_engineer.id,
|
||||||
|
intent="escalate",
|
||||||
|
snapshot={"problem_summary": "test"},
|
||||||
|
engineer_notes="Need help",
|
||||||
)
|
)
|
||||||
assert create_resp.status_code == 201
|
test_db.add(handoff)
|
||||||
handoff_id = create_resp.json()["id"]
|
await test_db.commit()
|
||||||
|
|
||||||
# Default test_user role is "owner", which passes engineer-or-admin.
|
# Default test_user role is "owner", which passes engineer-or-admin.
|
||||||
claim_resp = await client.post(
|
claim_resp = await client.post(
|
||||||
f"/api/v1/ai-sessions/{session.id}/handoffs/{handoff_id}/claim",
|
f"/api/v1/ai-sessions/{session.id}/handoffs/{handoff.id}/claim",
|
||||||
headers=auth_headers,
|
headers=auth_headers,
|
||||||
)
|
)
|
||||||
assert claim_resp.status_code == 200
|
assert claim_resp.status_code == 200
|
||||||
assert claim_resp.json()["claimed_by"] == test_user["user_data"]["id"]
|
assert claim_resp.json()["claimed_by"] == test_user["user_data"]["id"]
|
||||||
assert claim_resp.json()["claimed_at"] is not None
|
assert claim_resp.json()["claimed_at"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_claim_rejects_self_claim(
|
||||||
|
client: AsyncClient, test_user, auth_headers, test_db
|
||||||
|
):
|
||||||
|
"""POST /handoffs/{id}/claim returns 403 for the original escalator."""
|
||||||
|
session = AISession(
|
||||||
|
user_id=test_user["user_data"]["id"],
|
||||||
|
account_id=test_user["user_data"]["account_id"],
|
||||||
|
session_type="guided",
|
||||||
|
intake_type="free_text",
|
||||||
|
intake_content={"text": "test"},
|
||||||
|
status="escalated",
|
||||||
|
confidence_tier="discovery",
|
||||||
|
conversation_messages=[],
|
||||||
|
)
|
||||||
|
test_db.add(session)
|
||||||
|
await test_db.flush()
|
||||||
|
|
||||||
|
handoff = SessionHandoff(
|
||||||
|
session_id=session.id,
|
||||||
|
account_id=test_user["user_data"]["account_id"],
|
||||||
|
handed_off_by=test_user["user_data"]["id"],
|
||||||
|
intent="escalate",
|
||||||
|
snapshot={"problem_summary": "test"},
|
||||||
|
engineer_notes="Need help",
|
||||||
|
)
|
||||||
|
test_db.add(handoff)
|
||||||
|
await test_db.commit()
|
||||||
|
|
||||||
|
claim_resp = await client.post(
|
||||||
|
f"/api/v1/ai-sessions/{session.id}/handoffs/{handoff.id}/claim",
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert claim_resp.status_code == 403
|
||||||
|
assert "own handoff" in claim_resp.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_escalation_queue_excludes_own_escalations(
|
||||||
|
client: AsyncClient, test_user, auth_headers, test_db
|
||||||
|
):
|
||||||
|
"""The post-escalation dashboard queue should not show your own handoff."""
|
||||||
|
own_session = AISession(
|
||||||
|
user_id=test_user["user_data"]["id"],
|
||||||
|
account_id=test_user["user_data"]["account_id"],
|
||||||
|
session_type="chat",
|
||||||
|
intake_type="free_text",
|
||||||
|
intake_content={"text": "own"},
|
||||||
|
status="escalated",
|
||||||
|
confidence_tier="discovery",
|
||||||
|
conversation_messages=[],
|
||||||
|
)
|
||||||
|
other_engineer = User(
|
||||||
|
email="other-engineer@example.com",
|
||||||
|
password_hash="x",
|
||||||
|
name="Other Engineer",
|
||||||
|
role="engineer",
|
||||||
|
account_id=test_user["user_data"]["account_id"],
|
||||||
|
account_role="engineer",
|
||||||
|
)
|
||||||
|
test_db.add_all([own_session, other_engineer])
|
||||||
|
await test_db.flush()
|
||||||
|
other_session = AISession(
|
||||||
|
user_id=other_engineer.id,
|
||||||
|
account_id=test_user["user_data"]["account_id"],
|
||||||
|
session_type="chat",
|
||||||
|
intake_type="free_text",
|
||||||
|
intake_content={"text": "other"},
|
||||||
|
status="escalated",
|
||||||
|
confidence_tier="discovery",
|
||||||
|
conversation_messages=[],
|
||||||
|
)
|
||||||
|
test_db.add(other_session)
|
||||||
|
await test_db.commit()
|
||||||
|
|
||||||
|
resp = await client.get("/api/v1/ai-sessions/escalation-queue", headers=auth_headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
ids = {item["id"] for item in resp.json()}
|
||||||
|
assert str(own_session.id) not in ids
|
||||||
|
assert str(other_session.id) in ids
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
|||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const [showRunAll, setShowRunAll] = useState(false)
|
const [showRunAll, setShowRunAll] = useState(false)
|
||||||
const [showPreview, setShowPreview] = useState(false)
|
const [showPreview, setShowPreview] = useState(false)
|
||||||
|
const [copiedKey, setCopiedKey] = useState<string | null>(null)
|
||||||
|
|
||||||
// ── Resize state ──
|
// ── Resize state ──
|
||||||
const DEFAULT_WIDTH = 340
|
const DEFAULT_WIDTH = 340
|
||||||
@@ -208,8 +209,26 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
|||||||
`# ── ${i + 1}. ${a.label} ──\n${a.command}`
|
`# ── ${i + 1}. ${a.label} ──\n${a.command}`
|
||||||
)).join('\n\n')
|
)).join('\n\n')
|
||||||
|
|
||||||
const handleCopy = (text: string) => {
|
const handleCopy = async (text: string) => {
|
||||||
navigator.clipboard.writeText(text)
|
try {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
} catch {
|
||||||
|
// Fallback for HTTP or focus-restricted contexts
|
||||||
|
try {
|
||||||
|
const el = document.createElement('textarea')
|
||||||
|
el.value = text
|
||||||
|
el.style.cssText = 'position:fixed;opacity:0;pointer-events:none'
|
||||||
|
document.body.appendChild(el)
|
||||||
|
el.select()
|
||||||
|
document.execCommand('copy')
|
||||||
|
document.body.removeChild(el)
|
||||||
|
} catch {
|
||||||
|
toast.error('Copy failed — select the text and copy manually')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setCopiedKey(text)
|
||||||
|
setTimeout(() => setCopiedKey(k => k === text ? null : k), 1500)
|
||||||
toast.success('Copied to clipboard')
|
toast.success('Copied to clipboard')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -325,7 +344,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
|||||||
|
|
||||||
if (q.state === 'done') {
|
if (q.state === 'done') {
|
||||||
return (
|
return (
|
||||||
<div key={idx} className="rounded-lg border-l-[3px] border-l-success border border-success/25 bg-success-dim/30 p-3 mb-2 cursor-pointer hover:border-success/40 transition-colors" onClick={() => updateTask(idx, { state: 'active' })}>
|
<div key={idx} id={`task-lane-card-${idx}`} className="rounded-lg border-l-[3px] border-l-success border border-success/25 bg-success-dim/30 p-3 mb-2 cursor-pointer hover:border-success/40 transition-colors" onClick={() => updateTask(idx, { state: 'active' })}>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<Check size={12} className="text-success shrink-0" />
|
<Check size={12} className="text-success shrink-0" />
|
||||||
<span className="text-[0.8125rem] text-foreground">{q.text}</span>
|
<span className="text-[0.8125rem] text-foreground">{q.text}</span>
|
||||||
@@ -337,7 +356,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
|||||||
|
|
||||||
if (q.state === 'skipped') {
|
if (q.state === 'skipped') {
|
||||||
return (
|
return (
|
||||||
<div key={idx} className="rounded-lg border border-default/50 bg-elevated/20 p-3 mb-2 opacity-60 cursor-pointer hover:opacity-80 hover:border-default transition-all" onClick={() => updateTask(idx, { state: 'pending' })} title="Click to restore">
|
<div key={idx} id={`task-lane-card-${idx}`} className="rounded-lg border border-default/50 bg-elevated/20 p-3 mb-2 opacity-60 cursor-pointer hover:opacity-80 hover:border-default transition-all" onClick={() => updateTask(idx, { state: 'pending' })} title="Click to restore">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<div className="text-[0.8125rem] text-muted-foreground line-through">{q.text}</div>
|
<div className="text-[0.8125rem] text-muted-foreground line-through">{q.text}</div>
|
||||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Skipped</span>
|
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Skipped</span>
|
||||||
@@ -347,7 +366,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={idx} className="rounded-lg border border-default bg-card p-3 mb-2">
|
<div key={idx} id={`task-lane-card-${idx}`} className="rounded-lg border border-default bg-card p-3 mb-2">
|
||||||
<div className="text-[0.8125rem] text-heading leading-relaxed">{q.text}</div>
|
<div className="text-[0.8125rem] text-heading leading-relaxed">{q.text}</div>
|
||||||
{q.context && (
|
{q.context && (
|
||||||
<div className="text-[0.6875rem] text-muted-foreground mt-1">{q.context}</div>
|
<div className="text-[0.6875rem] text-muted-foreground mt-1">{q.context}</div>
|
||||||
@@ -430,10 +449,11 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
|||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Combined script</span>
|
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Combined script</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleCopy(combinedScript)}
|
onClick={() => void handleCopy(combinedScript)}
|
||||||
className="flex items-center gap-1 text-[0.75rem] text-muted-foreground hover:text-heading"
|
className="flex items-center gap-1 text-[0.75rem] text-muted-foreground hover:text-heading transition-colors"
|
||||||
>
|
>
|
||||||
<Copy size={11} /> Copy
|
{copiedKey === combinedScript ? <Check size={11} className="text-success" /> : <Copy size={11} />}
|
||||||
|
{copiedKey === combinedScript ? 'Copied' : 'Copy'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<pre className="text-[0.75rem] font-mono text-heading whitespace-pre-wrap overflow-x-auto">{combinedScript}</pre>
|
<pre className="text-[0.75rem] font-mono text-heading whitespace-pre-wrap overflow-x-auto">{combinedScript}</pre>
|
||||||
@@ -448,7 +468,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
|||||||
|
|
||||||
if (a.state === 'done') {
|
if (a.state === 'done') {
|
||||||
return (
|
return (
|
||||||
<div key={idx} className="rounded-lg border-l-[3px] border-l-success border border-success/25 bg-success-dim/30 p-3 mb-2 cursor-pointer hover:border-success/40 transition-colors" onClick={() => updateTask(idx, { state: 'active' })}>
|
<div key={idx} id={`task-lane-card-${idx}`} className="rounded-lg border-l-[3px] border-l-success border border-success/25 bg-success-dim/30 p-3 mb-2 cursor-pointer hover:border-success/40 transition-colors" onClick={() => updateTask(idx, { state: 'active' })}>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<Check size={12} className="text-success shrink-0" />
|
<Check size={12} className="text-success shrink-0" />
|
||||||
<span className="text-[0.8125rem] font-medium text-foreground flex-1">{a.label}</span>
|
<span className="text-[0.8125rem] font-medium text-foreground flex-1">{a.label}</span>
|
||||||
@@ -459,7 +479,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
|||||||
|
|
||||||
if (a.state === 'skipped') {
|
if (a.state === 'skipped') {
|
||||||
return (
|
return (
|
||||||
<div key={idx} className="rounded-lg border border-default/50 bg-elevated/20 p-3 mb-2 opacity-60 cursor-pointer hover:opacity-80 hover:border-default transition-all" onClick={() => updateTask(idx, { state: 'pending' })} title="Click to restore">
|
<div key={idx} id={`task-lane-card-${idx}`} className="rounded-lg border border-default/50 bg-elevated/20 p-3 mb-2 opacity-60 cursor-pointer hover:opacity-80 hover:border-default transition-all" onClick={() => updateTask(idx, { state: 'pending' })} title="Click to restore">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<div className="text-[0.8125rem] text-muted-foreground line-through">{a.label}</div>
|
<div className="text-[0.8125rem] text-muted-foreground line-through">{a.label}</div>
|
||||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Skipped</span>
|
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Skipped</span>
|
||||||
@@ -469,7 +489,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={idx} className="rounded-lg border border-default bg-card p-3 mb-2 hover:border-hover transition-colors">
|
<div key={idx} id={`task-lane-card-${idx}`} className="rounded-lg border border-default bg-card p-3 mb-2 hover:border-hover transition-colors">
|
||||||
<div className="text-[0.8125rem] font-medium text-heading">{a.label}</div>
|
<div className="text-[0.8125rem] font-medium text-heading">{a.label}</div>
|
||||||
{a.description && (
|
{a.description && (
|
||||||
<div className="text-[0.6875rem] text-muted-foreground mt-0.5 leading-relaxed">{a.description}</div>
|
<div className="text-[0.6875rem] text-muted-foreground mt-0.5 leading-relaxed">{a.description}</div>
|
||||||
@@ -477,9 +497,16 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
|||||||
|
|
||||||
{a.command && (
|
{a.command && (
|
||||||
<div className="mt-2 flex items-center gap-2 rounded bg-code px-2.5 py-1.5">
|
<div className="mt-2 flex items-center gap-2 rounded bg-code px-2.5 py-1.5">
|
||||||
<code className="flex-1 text-[0.6875rem] font-mono text-heading truncate">{a.command}</code>
|
<code className="flex-1 text-[0.6875rem] font-mono text-heading whitespace-pre-wrap break-all">{a.command}</code>
|
||||||
<button onClick={() => handleCopy(a.command!)} className="shrink-0 text-muted-foreground hover:text-heading" title="Copy">
|
<button
|
||||||
<Copy size={11} />
|
onClick={() => void handleCopy(a.command!)}
|
||||||
|
className="shrink-0 text-muted-foreground hover:text-heading transition-colors p-0.5 rounded"
|
||||||
|
title={copiedKey === a.command ? 'Copied!' : 'Copy command'}
|
||||||
|
>
|
||||||
|
{copiedKey === a.command
|
||||||
|
? <Check size={11} className="text-success" />
|
||||||
|
: <Copy size={11} />
|
||||||
|
}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
FileText,
|
FileText,
|
||||||
Hash,
|
Hash,
|
||||||
|
Loader2,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Target,
|
Target,
|
||||||
|
User,
|
||||||
X,
|
X,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import type { HandoffResponse } from '@/types/branching'
|
import type { HandoffResponse } from '@/types/branching'
|
||||||
@@ -35,12 +37,21 @@ type ConfidenceTier = 'low' | 'medium' | 'high' | string
|
|||||||
|
|
||||||
interface HandoffContextScreenProps {
|
interface HandoffContextScreenProps {
|
||||||
handoff: HandoffResponse
|
handoff: HandoffResponse
|
||||||
onStartHere: () => Promise<void> | void
|
// Pre-claim entry point: one of three choices is made before claiming.
|
||||||
|
// Post-claim re-open (dismissible=true) keeps the legacy onStartHere path.
|
||||||
|
onContinue?: () => Promise<void> | void
|
||||||
|
onAIAnalysis?: () => Promise<void> | void
|
||||||
|
onOwnThing?: () => Promise<void> | void
|
||||||
|
// Legacy single-CTA — used when dismissible=true (post-claim toolbar re-open)
|
||||||
|
onStartHere?: () => Promise<void> | void
|
||||||
onDismiss?: () => void
|
onDismiss?: () => void
|
||||||
// When true, renders an "X" close affordance in the corner. Used when the
|
// When true, renders an "X" close affordance in the corner. Used when the
|
||||||
// screen is re-opened from the FlowPilot toolbar (post-claim re-read).
|
// screen is re-opened from the FlowPilot toolbar (post-claim re-read).
|
||||||
dismissible?: boolean
|
dismissible?: boolean
|
||||||
isProcessing?: boolean
|
isProcessing?: boolean
|
||||||
|
// Whether the task lane has items — drives the 3-option vs 2-option layout
|
||||||
|
hasTaskLane?: boolean
|
||||||
|
activeOptionKey?: 'continue' | 'ai' | 'own' | null
|
||||||
}
|
}
|
||||||
|
|
||||||
function ConfidenceBadge({ value }: { value: number | string | null | undefined }) {
|
function ConfidenceBadge({ value }: { value: number | string | null | undefined }) {
|
||||||
@@ -76,10 +87,14 @@ function ConfidenceBadge({ value }: { value: number | string | null | undefined
|
|||||||
|
|
||||||
export function HandoffContextScreen({
|
export function HandoffContextScreen({
|
||||||
handoff,
|
handoff,
|
||||||
onStartHere,
|
onContinue,
|
||||||
|
onAIAnalysis,
|
||||||
|
onOwnThing,
|
||||||
onDismiss,
|
onDismiss,
|
||||||
dismissible = false,
|
dismissible = false,
|
||||||
isProcessing = false,
|
isProcessing = false,
|
||||||
|
hasTaskLane = false,
|
||||||
|
activeOptionKey = null,
|
||||||
}: HandoffContextScreenProps) {
|
}: HandoffContextScreenProps) {
|
||||||
const startBtnRef = useRef<HTMLButtonElement>(null)
|
const startBtnRef = useRef<HTMLButtonElement>(null)
|
||||||
|
|
||||||
@@ -114,6 +129,7 @@ export function HandoffContextScreen({
|
|||||||
|
|
||||||
const assessment = handoff.ai_assessment_data
|
const assessment = handoff.ai_assessment_data
|
||||||
const likelyCause = assessment?.likely_cause
|
const likelyCause = assessment?.likely_cause
|
||||||
|
const whatWeKnow = assessment?.what_we_know ?? []
|
||||||
const suggestedSteps = assessment?.suggested_steps ?? []
|
const suggestedSteps = assessment?.suggested_steps ?? []
|
||||||
const assessmentConfidence = assessment?.confidence
|
const assessmentConfidence = assessment?.confidence
|
||||||
const assessmentText = handoff.ai_assessment
|
const assessmentText = handoff.ai_assessment
|
||||||
@@ -256,6 +272,21 @@ export function HandoffContextScreen({
|
|||||||
<p className="text-sm text-foreground">{likelyCause}</p>
|
<p className="text-sm text-foreground">{likelyCause}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{whatWeKnow.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="font-sans text-[0.625rem] uppercase tracking-wider text-muted-foreground mb-1.5">
|
||||||
|
What we know
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{whatWeKnow.map((fact, i) => (
|
||||||
|
<li key={i} className="text-sm text-foreground flex items-start gap-2">
|
||||||
|
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/50" />
|
||||||
|
<span>{fact}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{assessmentText && !likelyCause && (
|
{assessmentText && !likelyCause && (
|
||||||
<p className="text-sm text-foreground whitespace-pre-wrap">
|
<p className="text-sm text-foreground whitespace-pre-wrap">
|
||||||
{assessmentText}
|
{assessmentText}
|
||||||
@@ -287,22 +318,92 @@ export function HandoffContextScreen({
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Start here CTA */}
|
{/* CTA footer */}
|
||||||
{!dismissible && (
|
{dismissible ? (
|
||||||
<div className="mt-6 flex flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-between">
|
// Post-claim re-open from toolbar — single close action
|
||||||
<p className="text-xs text-muted-foreground">
|
<div className="mt-6 flex justify-end">
|
||||||
Picking up assigns this session to you and reactivates it.
|
|
||||||
</p>
|
|
||||||
<button
|
<button
|
||||||
ref={startBtnRef}
|
onClick={() => onDismiss?.()}
|
||||||
onClick={() => void onStartHere()}
|
className="px-4 py-2 rounded-lg text-sm text-muted-foreground hover:text-foreground bg-input border border-border hover:border-border-hover transition-all"
|
||||||
disabled={isProcessing}
|
|
||||||
className="flex items-center justify-center gap-2 rounded-lg bg-accent px-5 py-3 min-h-[44px] text-sm font-semibold text-white hover:brightness-110 active:scale-[0.98] disabled:opacity-50 disabled:pointer-events-none transition-all"
|
|
||||||
>
|
>
|
||||||
<ArrowRight size={14} />
|
Close
|
||||||
{isProcessing ? 'Picking up…' : 'Start here'}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
// Pre-claim: 3 options (task lane exists) or 2 options (empty lane)
|
||||||
|
<div className="mt-6 space-y-2">
|
||||||
|
<p className="text-xs text-muted-foreground mb-3">
|
||||||
|
How would you like to approach this session?
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Continue — only when task lane has items */}
|
||||||
|
{hasTaskLane && onContinue && (
|
||||||
|
<button
|
||||||
|
ref={startBtnRef}
|
||||||
|
onClick={() => void onContinue()}
|
||||||
|
disabled={isProcessing}
|
||||||
|
className={cn(
|
||||||
|
'w-full flex items-center gap-3 rounded-lg px-4 py-3 min-h-[52px] text-sm font-semibold transition-all',
|
||||||
|
'bg-accent text-white hover:brightness-110 active:scale-[0.98] disabled:opacity-50 disabled:pointer-events-none',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{activeOptionKey === 'continue' ? (
|
||||||
|
<Loader2 size={16} className="shrink-0 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<ArrowRight size={16} className="shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className="flex-1 text-left">
|
||||||
|
Continue where{' '}
|
||||||
|
<span className="font-bold">
|
||||||
|
{handoff.handed_off_by_name ?? 'the original engineer'}
|
||||||
|
</span>{' '}
|
||||||
|
left off
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* AI analysis */}
|
||||||
|
{onAIAnalysis && (
|
||||||
|
<button
|
||||||
|
ref={!hasTaskLane ? startBtnRef : undefined}
|
||||||
|
onClick={() => void onAIAnalysis()}
|
||||||
|
disabled={isProcessing}
|
||||||
|
className={cn(
|
||||||
|
'w-full flex items-center gap-3 rounded-lg border px-4 py-3 min-h-[52px] text-sm font-semibold transition-all disabled:opacity-50 disabled:pointer-events-none',
|
||||||
|
hasTaskLane
|
||||||
|
? 'border-border bg-card text-foreground hover:bg-elevated hover:border-border-hover active:scale-[0.98]'
|
||||||
|
: 'bg-accent text-white border-transparent hover:brightness-110 active:scale-[0.98]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{activeOptionKey === 'ai' ? (
|
||||||
|
<Loader2 size={16} className="shrink-0 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Sparkles size={16} className="shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className="flex-1 text-left">Get AI analysis</span>
|
||||||
|
<span className="text-xs font-normal opacity-70">
|
||||||
|
{hasTaskLane ? 'Fresh take on what\'s been tried' : 'Generate diagnostic steps'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Own approach */}
|
||||||
|
{onOwnThing && (
|
||||||
|
<button
|
||||||
|
onClick={() => void onOwnThing()}
|
||||||
|
disabled={isProcessing}
|
||||||
|
className="w-full flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3 min-h-[52px] text-sm text-foreground hover:bg-elevated hover:border-border-hover active:scale-[0.98] disabled:opacity-50 disabled:pointer-events-none transition-all"
|
||||||
|
>
|
||||||
|
{activeOptionKey === 'own' ? (
|
||||||
|
<Loader2 size={16} className="shrink-0 animate-spin text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<User size={16} className="shrink-0 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<span className="flex-1 text-left">I'll take it from here</span>
|
||||||
|
<span className="text-xs text-muted-foreground">I know what to try</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { handoffsApi } from '@/api/handoffs'
|
|||||||
import { timeAgo } from '@/lib/timeAgo'
|
import { timeAgo } from '@/lib/timeAgo'
|
||||||
import type { HandoffResponse } from '@/types/branching'
|
import type { HandoffResponse } from '@/types/branching'
|
||||||
import { HandoffContextScreen } from '@/components/flowpilot/HandoffContextScreen'
|
import { HandoffContextScreen } from '@/components/flowpilot/HandoffContextScreen'
|
||||||
import { Sparkles, Send, Loader2, MessageSquare, Paperclip, Terminal, X, RotateCcw, ImagePlus, ListChecks, FileText, CheckCircle2, ArrowUpRight, MoreHorizontal, Pause, Plus } from 'lucide-react'
|
import { Sparkles, Send, Loader2, MessageSquare, Paperclip, Terminal, X, RotateCcw, ImagePlus, ListChecks, FileText, CheckCircle2, ArrowUpRight, ArrowRight, MoreHorizontal, Pause, Plus, Copy, Check } from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { uploadsApi } from '@/api/uploads'
|
import { uploadsApi } from '@/api/uploads'
|
||||||
import type { PendingUpload } from '@/types/upload'
|
import type { PendingUpload } from '@/types/upload'
|
||||||
@@ -82,13 +82,15 @@ export default function AssistantChatPage() {
|
|||||||
const [magicHandoff, setMagicHandoff] = useState<HandoffResponse | null>(null)
|
const [magicHandoff, setMagicHandoff] = useState<HandoffResponse | null>(null)
|
||||||
const [overlayHandoff, setOverlayHandoff] = useState<HandoffResponse | null>(null)
|
const [overlayHandoff, setOverlayHandoff] = useState<HandoffResponse | null>(null)
|
||||||
const [overlayLoading, setOverlayLoading] = useState(false)
|
const [overlayLoading, setOverlayLoading] = useState(false)
|
||||||
const [claiming, setClaiming] = useState(false)
|
const [activeOptionKey, setActiveOptionKey] = useState<'continue' | 'ai' | 'own' | null>(null)
|
||||||
// Codex correction (locked design): once the magic-moment dissolves, the
|
// Codex correction (locked design): once the magic-moment dissolves, the
|
||||||
// AI's `suggested_steps[]` should still be reachable as chips below the
|
// AI's `suggested_steps[]` should still be reachable as chips below the
|
||||||
// composer. Click prefills the input; first send hides the strip; explicit
|
// composer. Click prefills the input; first send hides the strip; explicit
|
||||||
// X also hides. Per-session lifetime — a refresh wipes the state, which is
|
// X also hides. Per-session lifetime — a refresh wipes the state, which is
|
||||||
// fine because the senior can re-open the Context overlay.
|
// fine because the senior can re-open the Context overlay.
|
||||||
const [chipsHidden, setChipsHidden] = useState(false)
|
const [chipsHidden, setChipsHidden] = useState(false)
|
||||||
|
const [selectedChipCardIdx, setSelectedChipCardIdx] = useState<number | null>(null)
|
||||||
|
const [copiedChipCmd, setCopiedChipCmd] = useState(false)
|
||||||
const [chats, setChats] = useState<ChatListItem[]>([])
|
const [chats, setChats] = useState<ChatListItem[]>([])
|
||||||
const [activeChatId, setActiveChatId] = useState<string | null>(() => {
|
const [activeChatId, setActiveChatId] = useState<string | null>(() => {
|
||||||
if (urlSessionId) return urlSessionId
|
if (urlSessionId) return urlSessionId
|
||||||
@@ -328,32 +330,15 @@ export default function AssistantChatPage() {
|
|||||||
return () => { cancelled = true }
|
return () => { cancelled = true }
|
||||||
}, [isPickup, urlSessionId, magicState, setSearchParams])
|
}, [isPickup, urlSessionId, magicState, setSearchParams])
|
||||||
|
|
||||||
const handleStartHere = useCallback(async () => {
|
const handleContinue = useCallback(async () => {
|
||||||
if (!urlSessionId || !magicHandoff) return
|
if (!urlSessionId || !magicHandoff) return
|
||||||
setClaiming(true)
|
setActiveOptionKey('continue')
|
||||||
try {
|
try {
|
||||||
await handoffsApi.claimHandoff(urlSessionId, magicHandoff.id)
|
await handoffsApi.claimHandoff(urlSessionId, magicHandoff.id)
|
||||||
// Drop ?pickup=true and dismiss the magic-moment. The session-load
|
|
||||||
// effect above will then fire because magicState !== 'loading'/'visible'
|
|
||||||
// and selectChat will populate the chat surface — the senior is now
|
|
||||||
// escalated_to_id, so GET succeeds and the conversation_messages render
|
|
||||||
// as chat history.
|
|
||||||
setSearchParams({})
|
setSearchParams({})
|
||||||
setMagicState('dismissed')
|
setMagicState('dismissed')
|
||||||
// Refresh the sidebar list. Pre-claim the session was invisible to
|
|
||||||
// listSessions because escalated_to_id was null (junior didn't
|
|
||||||
// specify a target on /escalate). Post-claim claim_session sets
|
|
||||||
// escalated_to_id = teamadmin.id, so the session is now in scope.
|
|
||||||
// Without this re-fetch the senior lands on a session with no
|
|
||||||
// sidebar entry — looks like the page navigated to a different
|
|
||||||
// session.
|
|
||||||
void loadChats()
|
void loadChats()
|
||||||
} catch (e: unknown) {
|
} 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) {
|
if (axios.isAxiosError(e) && e.response?.status === 409) {
|
||||||
const detail = e.response.data?.detail as
|
const detail = e.response.data?.detail as
|
||||||
| { error?: string; claimed_by_name?: string; claimed_at?: string }
|
| { error?: string; claimed_by_name?: string; claimed_at?: string }
|
||||||
@@ -370,7 +355,37 @@ export default function AssistantChatPage() {
|
|||||||
const message = e instanceof Error ? e.message : 'Failed to pick up session'
|
const message = e instanceof Error ? e.message : 'Failed to pick up session'
|
||||||
toast.error(message)
|
toast.error(message)
|
||||||
} finally {
|
} finally {
|
||||||
setClaiming(false)
|
setActiveOptionKey(null)
|
||||||
|
}
|
||||||
|
}, [urlSessionId, magicHandoff, setSearchParams])
|
||||||
|
|
||||||
|
const handleOwnThing = useCallback(async () => {
|
||||||
|
if (!urlSessionId || !magicHandoff) return
|
||||||
|
setActiveOptionKey('own')
|
||||||
|
try {
|
||||||
|
await handoffsApi.claimHandoff(urlSessionId, magicHandoff.id)
|
||||||
|
setSearchParams({})
|
||||||
|
setMagicState('dismissed')
|
||||||
|
void loadChats()
|
||||||
|
setTimeout(() => inputRef.current?.focus(), 300)
|
||||||
|
} catch (e: unknown) {
|
||||||
|
if (axios.isAxiosError(e) && e.response?.status === 409) {
|
||||||
|
const detail = e.response.data?.detail as
|
||||||
|
| { error?: string; claimed_by_name?: string; claimed_at?: string }
|
||||||
|
| undefined
|
||||||
|
if (detail?.error === 'already_claimed') {
|
||||||
|
const name = detail.claimed_by_name || 'another engineer'
|
||||||
|
const when = detail.claimed_at ? timeAgo(detail.claimed_at) : 'just now'
|
||||||
|
toast.info(`Already claimed by ${name} ${when}.`)
|
||||||
|
setSearchParams({})
|
||||||
|
setMagicState('dismissed')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const message = e instanceof Error ? e.message : 'Failed to pick up session'
|
||||||
|
toast.error(message)
|
||||||
|
} finally {
|
||||||
|
setActiveOptionKey(null)
|
||||||
}
|
}
|
||||||
}, [urlSessionId, magicHandoff, setSearchParams])
|
}, [urlSessionId, magicHandoff, setSearchParams])
|
||||||
|
|
||||||
@@ -1129,6 +1144,90 @@ export default function AssistantChatPage() {
|
|||||||
}
|
}
|
||||||
}, [refreshSessionDerived])
|
}, [refreshSessionDerived])
|
||||||
|
|
||||||
|
const handleAIAnalysis = useCallback(async () => {
|
||||||
|
if (!urlSessionId || !magicHandoff) return
|
||||||
|
setActiveOptionKey('ai')
|
||||||
|
const sentForChatId = urlSessionId
|
||||||
|
try {
|
||||||
|
await handoffsApi.claimHandoff(urlSessionId, magicHandoff.id)
|
||||||
|
loadedChatIdsRef.current.add(urlSessionId)
|
||||||
|
setSearchParams({})
|
||||||
|
setMagicState('dismissed')
|
||||||
|
void loadChats()
|
||||||
|
await selectChat(urlSessionId)
|
||||||
|
if (currentChatRef.current !== sentForChatId) return
|
||||||
|
|
||||||
|
const assessment = magicHandoff.ai_assessment_data
|
||||||
|
const snapshot = magicHandoff.snapshot as Record<string, unknown>
|
||||||
|
const problemSummary = (snapshot.problem_summary as string) || 'Untitled session'
|
||||||
|
const stepCount = (snapshot.step_count as number) ?? 0
|
||||||
|
const lines: string[] = [
|
||||||
|
`I just picked up this escalated session. Here's what's known so far:`,
|
||||||
|
``,
|
||||||
|
`**Problem:** ${problemSummary}`,
|
||||||
|
]
|
||||||
|
if (assessment?.likely_cause) {
|
||||||
|
lines.push(`**Likely cause:** ${assessment.likely_cause}`)
|
||||||
|
}
|
||||||
|
if (assessment?.what_we_know && assessment.what_we_know.length > 0) {
|
||||||
|
lines.push(`**What we know:**`)
|
||||||
|
assessment.what_we_know.forEach(fact => lines.push(`- ${fact}`))
|
||||||
|
}
|
||||||
|
if (stepCount > 0) {
|
||||||
|
lines.push(`**Steps on record:** ${stepCount} diagnostic steps.`)
|
||||||
|
}
|
||||||
|
if (magicHandoff.engineer_notes) {
|
||||||
|
lines.push(`**Engineer notes:** ${magicHandoff.engineer_notes}`)
|
||||||
|
}
|
||||||
|
lines.push(``, `Please analyze this and give me fresh diagnostic steps to try.`)
|
||||||
|
const briefing = lines.join('\n')
|
||||||
|
|
||||||
|
setMessages(prev => [...prev, { role: 'user', content: briefing }])
|
||||||
|
setLoading(true)
|
||||||
|
const response = await aiSessionsApi.sendChatMessage(urlSessionId, { message: briefing })
|
||||||
|
if (currentChatRef.current !== sentForChatId) return
|
||||||
|
setMessages(prev => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
content: response.content,
|
||||||
|
suggestedFlows: response.suggested_flows,
|
||||||
|
fork: response.fork,
|
||||||
|
actions: response.actions,
|
||||||
|
questions: response.questions,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
const hasQuestions = response.questions && response.questions.length > 0
|
||||||
|
const hasActions = response.actions && response.actions.length > 0
|
||||||
|
if (hasQuestions || hasActions) {
|
||||||
|
clearTaskState(urlSessionId)
|
||||||
|
setActiveQuestions(response.questions || [])
|
||||||
|
setActiveActions(response.actions || [])
|
||||||
|
setShowTaskLane(true)
|
||||||
|
setTaskLaneOwnerChatId(urlSessionId)
|
||||||
|
}
|
||||||
|
} catch (e: unknown) {
|
||||||
|
if (axios.isAxiosError(e) && e.response?.status === 409) {
|
||||||
|
const detail = e.response.data?.detail as
|
||||||
|
| { error?: string; claimed_by_name?: string; claimed_at?: string }
|
||||||
|
| undefined
|
||||||
|
if (detail?.error === 'already_claimed') {
|
||||||
|
const name = detail.claimed_by_name || 'another engineer'
|
||||||
|
const when = detail.claimed_at ? timeAgo(detail.claimed_at) : 'just now'
|
||||||
|
toast.info(`Already claimed by ${name} ${when}.`)
|
||||||
|
setSearchParams({})
|
||||||
|
setMagicState('dismissed')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const message = e instanceof Error ? e.message : 'Failed to start AI analysis'
|
||||||
|
toast.error(message)
|
||||||
|
} finally {
|
||||||
|
setActiveOptionKey(null)
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [urlSessionId, magicHandoff, setSearchParams, selectChat])
|
||||||
|
|
||||||
const handleNewChat = async () => {
|
const handleNewChat = async () => {
|
||||||
// Invalidate currentChatRef BEFORE the await so any in-flight handleSend/handleTaskSubmit
|
// Invalidate currentChatRef BEFORE the await so any in-flight handleSend/handleTaskSubmit
|
||||||
// for the previous session sees a mismatch and bails — prevents stale task lane appearing
|
// for the previous session sees a mismatch and bails — prevents stale task lane appearing
|
||||||
@@ -1546,8 +1645,12 @@ export default function AssistantChatPage() {
|
|||||||
<div className="h-[calc(100vh-3.5rem)] overflow-y-auto p-4 sm:p-8">
|
<div className="h-[calc(100vh-3.5rem)] overflow-y-auto p-4 sm:p-8">
|
||||||
<HandoffContextScreen
|
<HandoffContextScreen
|
||||||
handoff={magicHandoff}
|
handoff={magicHandoff}
|
||||||
onStartHere={handleStartHere}
|
onContinue={handleContinue}
|
||||||
isProcessing={claiming}
|
onAIAnalysis={handleAIAnalysis}
|
||||||
|
onOwnThing={handleOwnThing}
|
||||||
|
isProcessing={activeOptionKey !== null}
|
||||||
|
hasTaskLane={activeActions.length > 0 || activeQuestions.length > 0}
|
||||||
|
activeOptionKey={activeOptionKey}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -1888,46 +1991,142 @@ export default function AssistantChatPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Suggested-step chips (Codex correction, locked design):
|
{/* Task-lane shortcut chips: visible after the magic-moment
|
||||||
visible after the magic-moment dissolves (post-claim) so the
|
dissolves when the task lane has loaded items. Each card
|
||||||
senior can pull the AI's suggested next steps into the
|
links directly to the corresponding diagnostic card in the
|
||||||
composer with one click. Hides on first send or explicit X. */}
|
task lane — clicking opens the lane (if closed) and scrolls
|
||||||
|
to that card. Sourced from actual task lane items, not the
|
||||||
|
AI's free-text suggested_steps, so the card the user lands
|
||||||
|
on has full detail (description, command, etc.). */}
|
||||||
{!chipsHidden &&
|
{!chipsHidden &&
|
||||||
magicHandoff?.ai_assessment_data?.suggested_steps &&
|
(activeActions.length > 0 || activeQuestions.length > 0) &&
|
||||||
magicHandoff.ai_assessment_data.suggested_steps.length > 0 &&
|
magicState === 'dismissed' && (() => {
|
||||||
magicState === 'dismissed' && (
|
const chipItems = [
|
||||||
<div className="px-3 sm:px-6 pt-2 shrink-0">
|
...activeActions.slice(0, 4).map((a, ai) => ({
|
||||||
<div className="max-w-3xl mx-auto flex items-start gap-2">
|
label: a.label,
|
||||||
<p className="font-sans text-[0.625rem] uppercase tracking-wider text-muted-foreground pt-1.5 shrink-0">
|
cardIdx: activeQuestions.length + ai,
|
||||||
Suggested
|
description: a.description,
|
||||||
</p>
|
command: a.command ?? null,
|
||||||
<div className="flex flex-wrap gap-1.5 flex-1 min-w-0">
|
type: 'action' as const,
|
||||||
{magicHandoff.ai_assessment_data.suggested_steps.map((step, i) => (
|
})),
|
||||||
|
...activeQuestions.slice(0, Math.max(0, 4 - Math.min(activeActions.length, 4))).map((q, qi) => ({
|
||||||
|
label: q.text,
|
||||||
|
cardIdx: qi,
|
||||||
|
description: q.context ?? null,
|
||||||
|
command: null,
|
||||||
|
type: 'question' as const,
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
const selectedChip = chipItems.find(c => c.cardIdx === selectedChipCardIdx) ?? null
|
||||||
|
return (
|
||||||
|
<div className="px-3 sm:px-6 pt-2 pb-0.5 shrink-0">
|
||||||
|
<div className="max-w-3xl mx-auto">
|
||||||
|
<div className="flex items-center gap-2 mb-1.5">
|
||||||
|
<p className="font-sans text-[0.625rem] uppercase tracking-wider text-muted-foreground">
|
||||||
|
Suggested checks
|
||||||
|
</p>
|
||||||
<button
|
<button
|
||||||
key={i}
|
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => { setChipsHidden(true); setSelectedChipCardIdx(null) }}
|
||||||
setInput(step)
|
aria-label="Hide suggestions"
|
||||||
inputRef.current?.focus()
|
className="p-0.5 rounded text-muted-foreground hover:text-foreground hover:bg-elevated transition-colors"
|
||||||
}}
|
|
||||||
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}
|
<X size={11} />
|
||||||
</button>
|
</button>
|
||||||
))}
|
</div>
|
||||||
|
|
||||||
|
{/* Inline detail card — shown when a chip is selected */}
|
||||||
|
{selectedChip && (
|
||||||
|
<div className="mb-2 rounded-lg border border-default bg-card p-3 animate-fade-in">
|
||||||
|
<div className="flex items-start justify-between gap-2 mb-1.5">
|
||||||
|
<span className="text-[0.8125rem] font-medium text-heading leading-snug">{selectedChip.label}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedChipCardIdx(null)}
|
||||||
|
className="shrink-0 p-0.5 rounded text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
aria-label="Close detail"
|
||||||
|
>
|
||||||
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{selectedChip.description && (
|
||||||
|
<p className="text-[0.6875rem] text-muted-foreground mb-2 leading-relaxed">{selectedChip.description}</p>
|
||||||
|
)}
|
||||||
|
{selectedChip.command && (
|
||||||
|
<div className="rounded-md bg-code border border-default/50 px-3 py-2 flex items-start gap-2 mb-2.5">
|
||||||
|
<code className="flex-1 text-[0.75rem] font-mono text-heading whitespace-pre-wrap break-all leading-relaxed">{selectedChip.command}</code>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(selectedChip.command!)
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
const el = document.createElement('textarea')
|
||||||
|
el.value = selectedChip.command!
|
||||||
|
el.style.cssText = 'position:fixed;opacity:0;pointer-events:none'
|
||||||
|
document.body.appendChild(el)
|
||||||
|
el.select()
|
||||||
|
document.execCommand('copy')
|
||||||
|
document.body.removeChild(el)
|
||||||
|
} catch { return }
|
||||||
|
}
|
||||||
|
setCopiedChipCmd(true)
|
||||||
|
setTimeout(() => setCopiedChipCmd(false), 1500)
|
||||||
|
}}
|
||||||
|
className="shrink-0 p-1 rounded text-muted-foreground hover:text-heading hover:bg-elevated transition-colors mt-0.5"
|
||||||
|
title={copiedChipCmd ? 'Copied!' : 'Copy command'}
|
||||||
|
>
|
||||||
|
{copiedChipCmd
|
||||||
|
? <Check size={13} className="text-success" />
|
||||||
|
: <Copy size={13} />
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedChipCardIdx(null)
|
||||||
|
if (!showTaskLane) setShowTaskLane(true)
|
||||||
|
const el = document.getElementById(`task-lane-card-${selectedChip.cardIdx}`)
|
||||||
|
if (el) {
|
||||||
|
setTimeout(() => el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }), showTaskLane ? 0 : 200)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1 text-[0.75rem] font-medium text-accent-text hover:text-accent transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowRight size={11} />
|
||||||
|
Open in Tasks panel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2 overflow-x-auto pb-1" style={{ scrollbarWidth: 'none' }}>
|
||||||
|
{chipItems.map((item) => {
|
||||||
|
const isSelected = item.cardIdx === selectedChipCardIdx
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.cardIdx}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setCopiedChipCmd(false)
|
||||||
|
setSelectedChipCardIdx(isSelected ? null : item.cardIdx)
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'flex items-start gap-2 rounded-lg border px-3 py-2.5 text-left transition-colors shrink-0 w-[172px]',
|
||||||
|
isSelected
|
||||||
|
? 'border-accent/50 bg-accent-dim'
|
||||||
|
: 'border-default bg-card hover:bg-accent-dim hover:border-accent/30',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ArrowRight size={12} className="text-accent-text shrink-0 mt-0.5" />
|
||||||
|
<span className="text-xs text-foreground line-clamp-2 leading-snug">{item.label}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<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>
|
||||||
</div>
|
)
|
||||||
)}
|
})()}
|
||||||
|
|
||||||
{/* Rich Input */}
|
{/* Rich Input */}
|
||||||
<div className="px-3 sm:px-6 py-3 shrink-0">
|
<div className="px-3 sm:px-6 py-3 shrink-0">
|
||||||
@@ -2284,7 +2483,13 @@ export default function AssistantChatPage() {
|
|||||||
{/* Conclude Session Modal */}
|
{/* Conclude Session Modal */}
|
||||||
<ConcludeSessionModal
|
<ConcludeSessionModal
|
||||||
isOpen={showConclude}
|
isOpen={showConclude}
|
||||||
onClose={() => setShowConclude(false)}
|
onClose={() => {
|
||||||
|
setShowConclude(false)
|
||||||
|
if (activeSessionStatus === 'escalated') {
|
||||||
|
toast.info('Session escalated. Heading back to your dashboard.')
|
||||||
|
navigate('/')
|
||||||
|
}
|
||||||
|
}}
|
||||||
onConclude={handleConclude}
|
onConclude={handleConclude}
|
||||||
onResumeNew={handleResumeNew}
|
onResumeNew={handleResumeNew}
|
||||||
chatTitle={chats.find(c => c.id === activeChatId)?.title ?? 'Chat'}
|
chatTitle={chats.find(c => c.id === activeChatId)?.title ?? 'Chat'}
|
||||||
@@ -2347,7 +2552,6 @@ export default function AssistantChatPage() {
|
|||||||
>
|
>
|
||||||
<HandoffContextScreen
|
<HandoffContextScreen
|
||||||
handoff={overlayHandoff}
|
handoff={overlayHandoff}
|
||||||
onStartHere={() => {}}
|
|
||||||
onDismiss={() => setOverlayHandoff(null)}
|
onDismiss={() => setOverlayHandoff(null)}
|
||||||
dismissible
|
dismissible
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -86,14 +86,17 @@ export interface HandoffResponse {
|
|||||||
id: string
|
id: string
|
||||||
session_id: string
|
session_id: string
|
||||||
handed_off_by: string
|
handed_off_by: string
|
||||||
|
handed_off_by_name: string | null
|
||||||
intent: 'park' | 'escalate'
|
intent: 'park' | 'escalate'
|
||||||
source_branch_id: string | null
|
source_branch_id: string | null
|
||||||
snapshot: Record<string, unknown>
|
snapshot: Record<string, unknown>
|
||||||
ai_assessment: string | null
|
ai_assessment: string | null
|
||||||
ai_assessment_data: {
|
ai_assessment_data: {
|
||||||
|
summary_prose?: string
|
||||||
|
what_we_know?: string[]
|
||||||
likely_cause: string
|
likely_cause: string
|
||||||
suggested_steps: string[]
|
suggested_steps: string[]
|
||||||
confidence: number
|
confidence: string
|
||||||
} | null
|
} | null
|
||||||
artifacts: Array<{
|
artifacts: Array<{
|
||||||
name: string
|
name: string
|
||||||
|
|||||||
Reference in New Issue
Block a user