Phase 2 Task 33. Components can now ask "is this feature on?", "how many
sessions left?", and "what stage is the trial in?" without re-implementing
the read against useBillingStore.
- useFeature(flagKey): boolean — reads enabledFeatures from store
- useFeatureLimit(field): { used, limit, percentage, isAtLimit, isLoading }
with non-blocking 60s module-level cache and graceful 404 degradation
- useTrialBanner(): derives stage from subscription status + trial countdown,
returns null on initial load to prevent flicker
- usageApi.getCount(field) — calls /api/v1/usage/{field}; backend endpoint
is not yet implemented (planned), so the hook degrades to used=0
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
T32: Single frontend source of truth for subscription / plan / feature
state. New Zustand `useBillingStore` fetches `/billing/state` (auto-fetch
on login via authStore, reset on logout), exposes `refetch` for
post-Checkout refresh, and is supported by a `useBillingPoll` hook
that re-fetches every 60s while authenticated. The new `billingApi`
client transforms the snake_case backend payload to camelCase at a
single boundary so the rest of the frontend never sees `plan_billing`
or `enabled_features`.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
8612042 dropped the static "Account Management" heading in favor of the
account name (rendered as a dynamic h1). Switch the smoke test to the
"Settings" SectionLabel — a stable h2 that survives the redesign.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The /subscription endpoint returns usage as {tree_count, session_count_this_month}
without user_count, so the Seats UsageRow rendered as " / ∞" (blank current value).
The TS type declared user_count: number, hiding this API/type drift; the old
card-stack design hid it visually because each stat had its own border. The new
flat layout surfaced the gap.
Owners get a fallback to members.length (already fetched). Non-owners can't
fetch members and don't need seat-count info, so the row hides entirely for
them. Verified live: owner now sees Seats 2 / ∞.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The index page had ~12 distinct card surfaces with three places of
nested cards-inside-cards, against PRODUCT.md's "elevation = lighter
surface + border" + "nested cards are always wrong" rules. Branding
appeared twice, Display Code lived in Identity but does invite work,
and Preferences got a full card for one dropdown.
Single column, max-w-3xl, no card chrome. Sections separated by
border-t rules + mono-uppercase section labels (existing house style):
- Header: inline-editable name + plan/status/owner/member-count info
line. No card.
- Plan & usage: renewal date right-aligned in section header, three
thin progress rows replace the 4-card usage stat grid, upgrade
CTAs right-aligned at bottom.
- People (owner-only): invite form, unified members + pending invites
list, display code as a quiet "share to invite during signup" line.
Non-owners see a one-line "managed by your admin" instead of a card.
- Settings: dense route list (icon + title + summary + status pill +
chevron). Profile above a thin divider; team-admin rows below,
owner-gated. Branding row carries the Included/Plan-gated pill.
Support & Feedback as a dim link at the bottom.
- Account actions: plain rows. Owner: Transfer + Delete. Non-owner:
Leave. Destructive labels colored, no red box-of-doom.
Drops: Access & Security card (filler), Preferences card,
Settings Areas link grid, billing-card branding-status duplicate,
SettingsLinkCard helper. Default export format moves to Profile
Settings where it belongs (personal preference, not account).
856 -> 710 lines on the index. tsc, eslint, vite build clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Sidebar: kill the drifting railGroups + sections dual definition.
Single source of truth (workItems / libraryItems / footerItems)
rendered in both pinned and rail modes; pin/unpin is a width and
label affordance, not an IA switch. Hairline divider replaces
section labels. Guides moves to the footer alongside Account.
Renames: Home -> Dashboard, History -> Sessions, Insights -> Analytics.
- CURRENT-STATE.md: log PR #158 (session impeccable pass + tasklane
keyboard flow) under "Recently shipped".
- PRODUCT.md: design-context source of truth (users, brand, aesthetic);
sibling to DESIGN-SYSTEM.md.
- skills-lock.json: lock /impeccable + /documentation-writer skill
versions so other sessions reproduce the same tooling state.
- Drop stale .impeccable.md.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replace 15 feature-dump guides with 43 problem-oriented how-tos grouped
under 10 categories. Drop Maintenance Flows / AI Assistant / Flow Assist
Sparkles — those surfaces no longer exist post-FlowPilot pivot. Rename
Step Library → Solutions Library throughout. Correct every "click X in
the sidebar" reference to match live labels (Home, History, Tickets,
Flows, Scripts, Data, Acct).
Schema: add `category: CategoryId` and optional `relatedSlugs` to Guide;
new Category type and `categories` const drive hub ordering. GuidesHubPage
renders category sections (auto-hides empty); GuideDetailPage renders a
related-guides footer when set; GuideCard drops the misleading "N sections"
subtitle.
Fix step.tip markdown rendering — `**bold**` rendered literally because
tip used plain text instead of the same regex replacement used on
instruction.
14 net-new how-tos for FlowPilot-era surfaces with no prior coverage:
tasklane keyboard flow, view-what-we-know, ask-AI mid-session,
pause-and-leave, resolve, record-fix-outcome, escalate (Escalation
Mode), post-docs-to-ticket, send-client-update, build-script-from-scratch,
open-suggested-flow, pin-a-flow, invite-teammate.
Browser-verified against engineer + owner test users (sidebar labels,
account sub-pages, pilot-screen header buttons, Tasks panel, integration
form). tsc clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two small ergonomic fixes after the impeccable pass:
- TaskLane keyboard hints (⏎ submit · ⇧⏎ newline) under each open input
were rendered at text-muted-foreground/70, just shy of legible at a
glance. Drop the /70 opacity modifier so they read at full muted weight
on first look without becoming visually loud.
- 12 sites across the session screen had explicit font-sans utilities,
but the body default is already IBM Plex Sans (via --font-sans in
index.css and Tailwind v4's default-sans binding). None of the call
sites sit inside a font-heading or font-mono cascade, so every
font-sans there was a no-op. Drop them. ConcludeSessionModal also had
three "text-xs font-sans text-xs" triplets — drop both the redundant
font-sans and the doubled text-xs in one pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
ParameterizationPreview.tokenize() matched highlight values via raw
seg.text.startsWith(value, cursor) with no word-boundary check and no
minimum length. A param value like "D" (e.g. a drive letter) lit up every
capital D in the script body — Get-ADUser, Add-Type, Disable- all rendered
as proposed-parameter pills.
Add a word-boundary guard: a candidate match is only accepted if either
side of the match either falls at start/end of the segment, OR the
adjacent character is non-alphanumeric. The guard is conditional on
whether the value itself starts/ends with a word char, so values that
begin or end in punctuation (e.g. "D:\\Folder") still match cleanly when
they sit next to whitespace or punctuation.
Surfaced 2026-05-01 while testing the suggested-fix flow with a real
PowerShell script.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Multi-step UX refactor of the assistant chat session screen, run via the
$impeccable skill. Heuristic score moved 24/40 → 33/40 (+9), with the biggest
gains on Aesthetic & Minimalist (1→3), Consistency & Standards (1→3), and
Recognition Rather Than Recall (2→4).
Distill — chat region:
- Remove the "Suggested checks" chip strip + selected-chip detail card; the
TaskLane is the single canonical home for "what to do next"
- Add an inline Next steps · N pending cue above the latest action-bearing
AI bubble (anchors attention without duplicating the lane's items)
- Link banner ↔ script-panel lifecycle: collapsing or dismissing the
ProposalBanner now also hides the InlineNoTemplateDialog / TemplateMatchPanel
- Drop backdrop-blur on the handoff-context overlay (DESIGN-SYSTEM hard rule)
Quieter — drop decoration overshoot:
- Remove 3px side stripes on TaskLane done cards, all 6 ProposalBanner modes,
WhatWeKnowItem fact rows
- Drop bg-gradient surfaces on WhatWeKnow + every ProposalBanner mode
- Drop 2px accent borderTop on the TaskLane header
- Replace bordered avatar boxes in banners with inline state-colored icons
- Each surface now uses a single decoration channel (top border + inline icon)
Layout:
- Header consolidates to Resolve + Escalate + ⋯ kebab; Context, New Ticket,
Update Ticket, Pause now live behind the kebab on desktop, with feature
parity in the existing mobile overflow menu
- Messages column anchors to max-w-3xl mx-auto to match the composer
- Chat bubbles drop from rounded-2xl to rounded-xl for vocabulary alignment
Typeset:
- Unify text sizing from 14 distinct sizes (with sub-pixel oddities and
rem/px duplicates) to a 5-step scale: 10px / 11px / text-xs / 13px / text-sm
WhatWeKnow collapsible:
- Header is now a toggle; section body hides when collapsed
- Auto-collapses on first render when facts ≥ 5 so Questions / Diagnostic
Checks stay above the fold
- Engineer's choice persists in sessionStorage per session and beats the
auto-collapse heuristic on subsequent renders
- key=activeChatId on both render sites resets state cleanly across sessions
Polish:
- Split MessageCircleQuestion into Pencil (question Answer CTA, write
affordance) + HelpCircle (per-check Explain toggle, universal help icon) —
same icon for two different jobs was a discoverability bug
- Drop redundant text-xs from font-sans text-[0.625rem] / text-[0.6875rem]
double-class definitions; the more-specific size always wins
TaskLane keyboard flow:
- Enter submits and auto-advances to the next pending task; Shift+Enter
inserts a newline (consistent across question and action textareas — paste
events don't fire keydown, so paste-then-Enter still works as expected)
- Esc cancels (same as the Cancel button)
- After the last pending task is submitted, focus moves to the Send Responses
button so the engineer can fire the whole batch with one more keystroke
- Subtle hint row under each open input teaches the shortcut
Type-check, lint, and build all clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Page-level Resolve patches applied_pending → applied_success before
opening the resolution flow, so resolved sessions don't carry a
provisional pending fix.
- Page-level Escalate intercept now catches applied_pending in addition
to verifying/partial; intercept copy generalized from "Verifying state"
to "still needs an outcome."
- PendingBanner gains a Dismiss action, matching the PR body and the
backend's allowed pending → dismissed transition.
- resolution_note_generator and escalation_package_generator system
prompts no longer include real-looking pending examples (anti-parrot
guardrail compliance).
Verified via Docker: prompt anti-parrot 2/2, suggested-fix outcome suite
21/21, frontend tsc -b clean, npm run build clean.
Co-Authored-By: Codex <noreply@openai.com>
Engineer applies a fix but can't verify yet (waiting on client power-cycle,
AD replication, async sync). Today the verifying banner forces a synchronous
verdict (worked / didn't / partial) — anything else means leaving the banner
stale or guessing wrong. This adds a fourth outcome that parks the fix in a
non-terminal "Awaiting verification" state with a reason ("waiting on what?")
and exposes it on the chat-anchored banner so the engineer doesn't lose track.
Backend
- New non-terminal status `applied_pending` parallel to `applied_partial`.
- New `pending_reason` column (nullable Text) — the "what are you waiting on?"
prose, mirrors `partial_notes`. Required when outcome=applied_pending.
- Outcome endpoint allows pending in/out transitions; pending stamps
applied_at but NOT verified_at (it's parked, not verified).
- Resolution-note + escalation-package prompts handle the new status:
resolution note frames the fix as provisional; escalation package surfaces
pending verification as the leading hypothesis with reference to what's
being waited on.
- Migration: add column + extend status CHECK constraint.
Frontend
- New `BannerMode = 'pending'` + `PendingBanner` component (info-tone,
parallel to PartialBanner) with worked / didn't / update-reason actions.
- VerifyingBanner overflow menu adds "Waiting to verify…".
- Nudge banner's "Still checking" button now actually records pending with
a reason, instead of just silencing for the session.
- AssistantChatPage banner-mode derivation maps applied_pending → 'pending'.
Tests: 4 new integration tests covering pending notes requirement, reason
storage + applied_at/verified_at semantics, pending→success transition,
and pending_reason update on re-PATCH.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Codex review pass on the escalation wedge. Reworks claim_session from
read-then-write to a conditional UPDATE so two seniors racing can't both
win, blocks the original engineer from claiming their own handoff, and
filters self-escalated sessions out of the dashboard escalation queue.
Also preassigns the handoff UUID before flush so the compatibility
escalation_package payload carries it. Removes legacy frontend pickup
state (claiming, handleStartHere) that broke tsc --noEmit.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Bundles four fixes from the live debugging session:
1. AssistantChatPage: replace urlSessionId === activeChatId gate with a
loadedChatIdsRef. After 8914391 made activeChatId initialize from
urlSessionId, the gate short-circuited fresh mounts and selectChat
never fired. Symptom: senior picks up an escalation, lands on a blank
chat surface with no conversation history and no sidebar entry. Fix
also adds loadChats() in handleStartHere so the picked-up session
appears in the sidebar (its escalated_to_id is null pre-claim, so
listSessions doesn't return it until claim_session sets it).
2. config: bump ESCALATION_AI_ASSESSMENT_TIMEOUT_SECONDS 15s → 45s.
Sonnet was hitting tail latency at 15s in the field, leaving the
magic-moment placeholder permanent. Background-task architecture
(e8ba74e) means this no longer blocks the user; it's just the budget
before publishing has_assessment=false. NOTE: live test still shows
assessment not populating — see HANDOFF for the consolidation plan
that supersedes this.
3. Enter-to-submit: chat-input convention (Enter submits, Shift+Enter
inserts newline) on the escalate-flow forms. RichTextInput gains an
optional onSubmit prop; EscalateModal wires it to handleSubmit;
ConcludeSessionModal gets the same handler on its plain textarea.
4. PendingEscalations: each row is now expandable. Click row body to
reveal the engineer's escalation reason, step count on record,
confidence tier, and PSA ticket number. Pick Up still clicks through
directly. Single-expand-at-a-time keeps the dashboard compact.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The previous fix (8914391) only blocked the mount-time sessionStorage
restore when the page entered with prefill or ?pickup=true. It didn't
cover any path where the page was already mounted and activeChatId
flipped without the in-memory task-lane state going through reset+
repopulate cleanly — in-place URL navigation, mid-flight pickup,
HMR re-runs, the gap between setActiveChatId(B) and the AI response
that finally populates B's questions/actions.
Root cause: activeQuestions / activeActions / showTaskLane were never
intrinsically tied to a chatId. They were treated as "the active chat's
data" by convention, with no structural enforcement. Any window where
they survived past their owning chat leaked previous-session data into
the new view. The persistence effect made it worse: it stamped the
sessionStorage chatId field with activeChatId at write time, so a
mid-transition snapshot {chatId: B, questions: [A's]} would happily
restore A's data for B on the next mount.
Fix: introduce taskLaneOwnerChatId state that records the chatId those
in-memory questions/actions/show values BELONG to. Set at every site
that populates them (sendPrefill, selectChat, handleSend, handleTaskSubmit,
handleResumeNew, refreshFacts, handleApplyFix). Cleared in
resetSessionDerivedState. The persistence effect now writes ownerChatId
as the chatId tag, not activeChatId — so the snapshot is always
self-consistent.
Render gate: taskLaneIsForActiveChat = ownerChatId === activeChatId.
ANDed into all three render conditions (toolbar Tasks button, narrow-
viewport floating drawer, main side panel). The lane is structurally
unable to display data tagged with a different chat.
The mount-time skipTaskLaneRestore guard stays — it kills the flash
between component mount and the first sendPrefill effect run, which
the owner-gate alone doesn't cover.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Four items from the design-plan audit, all flagged as locked-design or
Codex corrections, shipped together so the GTM demo path covers them
end-to-end before bug bash.
1. Live AI assessment refresh on the magic-moment screen. Backend already
publishes handoff_assessment_ready when enrich_escalation_async commits;
wire the frontend listener so the senior sees the assessment populate
without a manual reopen. New event type + onAssessmentReady handler on
streamEscalations; AssistantChatPage opens a scoped SSE subscription
whenever it tracks a handoff missing its assessment, refetches on match,
and replaces magicHandoff / overlayHandoff in place. Closes the loop on
the async-assessment commit e8ba74e.
2. Suggested-step chips below the chat input. Locked design from the plan
(Codex correction). Chip strip renders above the composer post-claim
when ai_assessment_data.suggested_steps[] is non-empty. Click prefills
the input and focuses; first send or explicit X hides for the session.
3. Unread 6px dot on EscalationQueue cards. localStorage-persisted seen
set (rf-escalation-seen, capped 200). Dot top-right when not seen.
Cleared on open (card click) or claim (Pick Up) — NOT on hover, per
Codex correction. Pick Up stops propagation so it doesn't double-fire.
4. Race-condition toast on claim conflict. The /claim endpoint previously
silently overwrote claimed_by — both seniors thought they owned the
session. New HandoffAlreadyClaimedError carries the winner's id/name/
timestamp; claim_session rejects different-user re-claims (same-user is
idempotent for double-click safety); endpoint returns 409 with
structured detail. AssistantChatPage.handleStartHere extracts and
surfaces "Already claimed by {name} {time_ago}." via toast, drops
?pickup=true, dismisses magic-moment so the loser flows back to queue.
Tests: 2 new unit tests in test_handoff_manager.py (conflict raises,
same-user idempotent). Full handoff + escalation suite (34 tests) green.
Frontend tsc -b clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two compounding bugs caused the previous session's questions/actions
to render briefly when entering a new chat — visible as "the new
session instantly pops with old session task-lane data" the user
reported.
The race
- AssistantChatPage's activeQuestions / activeActions / showTaskLane
useState initializers synchronously read sessionStorage's
rf-tasklane-meta. They restore the persisted task-lane state if its
saved chatId matches the freshly-resolved activeChatId.
- On dashboard prefill flow, the page mounts on /pilot with
location.state.prefill set; activeChatId initializes from
sessionStorage's rf-active-chat-id (the previous session). The
previous session's task-lane meta matches that chatId — so the
initializer restores it. First paint shows old questions/actions.
sendPrefill's resetSessionDerivedState fires later from a useEffect,
but only after the flash.
- Same pattern hits the senior-pickup flow: ?pickup=true means we're
about to render the magic-moment screen and discard whatever chat
the senior was previously on, but the underlying chat surface still
initializes with their old task-lane meta.
The amplifier
- resetSessionDerivedState wiped the in-memory state but never
removed sessionStorage's rf-tasklane-meta. Any remount or reload
before the next persistence-effect write could re-hydrate the
cleared state from the still-stale sessionStorage entry.
Fixes
- Initializer guard: when location.state.prefill is set OR
?pickup=true is in the URL, skip the sessionStorage restore
entirely. Kills the first-paint flash for both entry paths.
- Eager wipe: resetSessionDerivedState now also calls
sessionStorage.removeItem('rf-tasklane-meta'). The persistence
effect re-saves on the next state change anyway, so the only
window where sessionStorage is empty is the exact window where
stale-tag leakage was happening.
tsc -b clean. No backend changes.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Three improvements driven by live wedge testing.
1) Notification title now includes a problem snippet and PSA ticket
suffix when present:
"Escalation from Jane · #12345: Outlook is failing to sync email…"
Replaces the prior "Session escalated by Jane" copy that made every
escalation from the same junior look identical in the bell panel.
Snippet is trimmed to 70 chars with ellipsis. handoff_manager now
passes psa_ticket_id through in the notify() payload so this works
for both /escalate and /handoff entry points.
2) AI enrichment (assessment + enhanced escalation_package) moved to
a FastAPI BackgroundTask. The escalating engineer no longer waits
on 15-25s of Sonnet latency — handoff creation returns as soon as
snapshot, status flip, dual-write, documentation, PSA push, and
notify() are committed. enrich_escalation_async opens its own DB
session, runs both AI calls, updates handoff.ai_assessment +
session.escalation_package, commits, and publishes a new
`handoff_assessment_ready` event on the escalation bus. Frontend
doesn't yet listen for that event — the magic-moment screen still
shows a placeholder ("AI assessment is still generating. Reopen
this view in a few seconds…") which is honest about the state.
Live polling / auto-refresh on the bus event is the natural next
step.
3) ChatSidebar entries now surface the problem summary as a secondary
line and tag PSA-linked sessions with a monospace #ticket badge plus
an "Escalated" pill on in-transit sessions. ChatListItem grew
problem_summary, psa_ticket_id, and status fields; loadChats
populates them from listSessions. The user couldn't tell their own
sessions apart in the sidebar because they all rendered as "New
Chat" with no distinguishing detail — this fixes that for any
session, escalated or not.
Test plan
- Backend full suite: 1103 passed in 255.85s with -n auto.
- Frontend tsc -b clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The /pilot/:id route renders AssistantChatPage, not FlowPilotSessionPage
(the latter is dead code with no active route). The earlier magic-moment
integration sat in the wrong file, so clicking Pick Up from the
dashboard navigated to /pilot/:id?pickup=true and AssistantChatPage
just loaded the chat surface with no claim — the senior never saw the
magic-moment screen and the handoff stayed unclaimed (status escalated,
permanently in the queue).
Adds full pickup awareness to AssistantChatPage:
- ?pickup=true on entry triggers a handoff fetch via
handoffsApi.listHandoffs (account-scoped, no claim required).
magicState transitions loading → visible (handoff found) or
loading → dismissed (no handoff or fetch failed). The dismiss path
also strips ?pickup=true from the URL so a refresh doesn't re-enter
loading state.
- The existing selectChat-from-URL effect is gated on magicState — it
skips while we're loading or showing the magic-moment so the chat
surface doesn't race the claim flow. After claim it re-fires and
populates messages from conversation_messages because the senior is
now escalated_to_id and GET succeeds.
- Magic-moment renders as full-page take-over (sidebar hidden) until
Start here. handleStartHere calls handoffsApi.claimHandoff, drops
?pickup=true, and dismisses — the regular chat then loads.
- Toolbar Context button (visible when magicHandoff is in memory)
re-opens the screen as a dismissible overlay. Lazy-fetches the
handoff when needed.
Verified tsc -b clean and Vite HMR picked the file up without errors.
The wire-level integration was already verified in earlier commits:
listHandoffs returns the unclaimed handoff for a senior pre-claim,
claimHandoff flips status escalated → active and sets escalated_to_id.
Note: the prior FlowPilotSessionPage magic-moment integration is now
in dead code (file is unreferenced from router). Left in place for
this commit; will come out in a follow-up cleanup once we're confident
the AssistantChatPage path is solid in production.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replaces the legacy flowpilot_engine.escalate_session orchestration with
a single canonical path through HandoffManager. Every escalation now
creates a SessionHandoff row, fans out via the SSE bus, persists
AppNotification rows for the bell icon, dispatches to external channels
(Slack/Teams) via notify(), and emails per-user — regardless of whether
the call entered through /escalate (legacy URL) or /handoff (new URL).
The senior-pickup magic-moment screen now works end-to-end from the
EscalateModal bell-icon path the user just tested.
Backend
- HandoffCreateRequest gains optional target_user_id (the equivalent of
the legacy escalated_to_id field). Self-targeting rejected.
- HandoffManager.create_handoff handles intent='escalate' end-to-end:
sets escalation_reason + escalated_to_id, builds the legacy enhanced
AI escalation_package (Sonnet, lazy-imported from flowpilot_engine,
graceful fallback on failure), and merges handoff metadata into it.
Eager-loads session.steps and session.user via selectinload — required
by both the enhanced-package builder and notify() to avoid
MissingGreenlet on async lazy access.
- HandoffManager.finalize_escalation generates SessionDocumentation,
pushes documentation to PSA, and runs notify() — pre-commit so the
AppNotification rows persist atomically with the handoff.
- HandoffManager.dispatch_escalation_notifications keeps only the
fire-and-forget IO (bus publish, per-user emails) — runs post-commit.
Pulls engineer name via a separate User query rather than relying on
session.user lazy access.
- /handoff endpoint passes target_user_id through and calls
finalize_escalation pre-commit.
- /escalate endpoint is now a thin shim: owner-only session lookup,
HandoffManager.create_handoff(intent='escalate'), finalize_escalation,
commit, dispatch_escalation_notifications, return SessionCloseResponse
built from documentation + psa_result. flowpilot_engine.escalate_session
is no longer called by any endpoint.
- pickup_session accepts both 'requesting_escalation' (legacy in-flight
sessions) and 'escalated' (new canonical) so the migration is seamless
for sessions already in the queue.
- Escalation queue list and sidebar count now match either status.
Frontend
- useFlowPilotSession optimistic update flips status to 'escalated'
instead of 'requesting_escalation' so the page state matches the
unified backend response.
Verified end-to-end live: a fresh /escalate call from the junior produces
status='escalated', a SessionHandoff row, a SessionDocumentation, PSA
push attempted (no_psa for this test session), AND a bell-icon
AppNotification for the team admin with link
/pilot/{session_id}?pickup=true. Backend test suite: 1103 passed.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds the dedicated 4-section handoff-context view that renders BEFORE
the FlowPilot session for senior techs picking up an escalated
session, then dissolves on "Start here". This is the wedge's
demonstrable magic moment — what the GTM Loom records.
- HandoffContextScreen.tsx: pure presentational, takes a HandoffResponse
plus onStartHere / onDismiss callbacks. Sections: header
(problem summary, domain, step count, escalated-time, priority badge),
"What's been tried" (engineer notes + step-count affordance), "AI
assessment" (likely_cause / suggested_steps / confidence badge), Start
here CTA. Confidence badge accepts both numeric (0..1) and string
("low"/"medium"/"high") shapes — backend currently emits the latter.
Renders an explicit "assessment unavailable" branch when
ai_assessment_data is null (the 5s timeout from 9bdd995 fired).
Honors prefers-reduced-motion (animate-fade-in vs animate-slide-up).
ARIA dialog + focus on the primary CTA. Esc dismisses when used as a
re-openable overlay; pre-claim, Start here is the only exit.
- FlowPilotSessionPage.tsx: on /pilot/:id?pickup=true, fetch the
handoff list via handoffsApi.listHandoffs (account-scoped via RLS,
no claim required) and find the latest unclaimed escalate handoff.
If found, render the magic-moment screen and skip the regular
loadSession (the senior isn't yet escalated_to_id, so GET would
404). Start here calls claimHandoff, drops the pickup query param,
dismisses the screen — the existing loadSession effect then fires
because the senior is now escalated_to_id. A "Context" toolbar
button on active sessions re-opens the screen as a dismissible
overlay (visible only when the senior arrived via the magic-moment
flow this session — handoff lookup on demand).
Verified end-to-end against the running dev stack: listHandoffs
returns the unclaimed handoff with full payload; claim flips session
status from escalated → active; subsequent GET succeeds. tsc -b clean.
Defers (TODO followups): suggested-step chips below the chat input
that prefill on click (requires threading through to
FlowPilotMessageBar); snapshot expansion to include the recent
diagnostic steps pre-claim; toolbar Context button on sessions where
the senior didn't arrive via magic-moment.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds the frontend live-arrival slice on top of the test-stabilized SSE
backend. Senior techs now see a junior's escalation slide into the
queue without refresh.
- streamEscalations(handlers, signal) in aiSessions.ts: fetch-based
ReadableStream parser (native EventSource cannot send auth headers).
Handles SSE frames, partial frames across chunks, : keepalive
heartbeats. Dispatches ready and handoff_created.
- HandoffCreatedEvent + EscalationStreamHandlers types mirror the bus
payload published by HandoffManager.dispatch_escalation_notifications.
- EscalationQueue.tsx: AbortController-managed subscription with
exponential-backoff reconnect (1s → 30s cap, attempt counter resets
on ready). On handoff_created, refetch and diff against previous IDs
via sessionsRef; new arrivals prepended (newest-first) above
established cards (oldest-first preserved). Slide-in tag held for
800ms so the locked 200ms animation completes. Tab-title flash
prefixes (N) while document.hidden, restores on focus / unmount.
prefers-reduced-motion swaps slide-in for fade-in. ARIA region +
aria-live=polite + aria-label on heading. Pick Up bumped to py-2.5
to clear the 44px touch floor.
Verified end-to-end against the running dev stack: subscriber received
the ready frame on connect; after posting a handoff via the API, the
subscriber received the handoff_created frame with the expected
payload — wire format matches the parser. Backend regression: focused
subset still 32 passed in 18.91s. Frontend tsc -b clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Surfaces the new GET /analytics/flowpilot/escalations endpoint as a card
above the EscalationQueue list. Closes the loop from yesterday's metric
endpoint commit — seniors and owners see the wedge stat the moment they
open the queue, which is the daily-reps version of the GTM ROI story.
Pieces:
- EscalationMetrics TS interface mirroring the backend Pydantic model
(incl. metric_definition disclaimer field)
- flowpilotAnalyticsApi.getEscalationMetrics(period) client method
- EscalationMetricCard component:
* loading skeleton, error state, zero-data empty state
* avg + median + n_with_action/n_claimed conversion rate
* humanized seconds → "Ns" / "N.N min" formatting
* inline disclaimer reminding callers this is in-product time-to-
first-action only, NOT the savings claim — pair with manual
baseline (per /codex review's two-metric correction)
- Wired into EscalationQueuePage above EscalationQueue
DS-aligned: card-flat, accent-dim usage held to interactive elements,
text-muted-foreground for secondary copy, font-heading on the headline
number, explicit transition properties (no `transition: all`). Respects
prefers-reduced-motion implicitly (only animation is the loading pulse,
which Tailwind's animate-pulse already gates).
tsc -b clean. No new tests in this commit — component is a thin
state-machine over an axios call; integration coverage comes from the
existing backend tests + the e2e Playwright work in the plan.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The dashboard prefill flow in AssistantChatPage set activeChatId after
creating a new session but never updated currentChatRef.current. Every
later handleSend / handleTaskSubmit then tripped the
`currentChatRef.current !== sentForChatId` guard that was supposed to
discard responses for stale chats — and silently dropped the AI's
follow-up. The user saw their submitted message but no assistant
reply, no toast, no task-lane update.
Mirrors what handleNewChat and handleResumeNew already do. Adds an
e2e regression test that drives the dashboard prefill, submits a
partial task-lane response, and asserts the second AI turn renders.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Mechanical drift between the e2e selectors and the current UI surfaced
on the first CI run after PR #149 unblocked the artifact upload step.
Five tests, three categories of drift:
1. **Page heading renames** (navigation.spec.ts)
- `Sessions` → `Session History` on /sessions
- `Account Settings` → `Account Management` on /account
2. **Route rename** (command-palette.spec.ts:74)
- The "Troubleshoot with FlowPilot" command palette option now lands
on /pilot (Phase 1 of the FlowPilot migration renamed /assistant).
/assistant still 301-redirects, so the assertion accepts either.
3. **Feature moved to /sessions** (history.spec.ts, resume.spec.ts)
- Default tab on /sessions is "AI Sessions"; flow-session filtering
and the Resume button moved behind the "Flow Sessions" tab. Both
tests now click that tab before asserting.
- resume.spec.ts no longer starts at /trees (Resume buttons aren't
rendered there anymore — the flow lives on /sessions). Destination
URL (/trees/:id/navigate) is unchanged.
No product-code changes — these are pure test updates against the
shipped UI. Run the suite locally with
`cd frontend && npm run test:e2e` once a fresh build is available.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The new react-hooks lint rule "Calling setState synchronously within an
effect can trigger cascading renders" flagged real anti-patterns in
four spots. Refactored each per the rule's intent (derive during render,
or use useSyncExternalStore for external subscriptions).
1. hooks/useMediaQuery.ts — replaced the useState + useEffect pair with
useSyncExternalStore. That's the canonical React hook for
subscribing to external stores (matchMedia in this case) without
mirroring into local state via an effect. Snapshot/getServerSnapshot
pair preserves the SSR-safe behaviour.
2. components/network/nodes/DeviceNode.tsx — the prop-sync useEffect
that copied nodeData.label into labelValue was redundant.
labelValue is the EDIT BUFFER; while not editing, the displayed
span now reads nodeData.label directly. The buffer is initialized
only when an edit session starts (onDoubleClick).
3. components/network/nodes/GroupNode.tsx — same pattern, same fix.
4. components/dashboard/TicketQueue.tsx — the
setTickets([]) + setLoading(true) + fetchTickets() chain in the
effect was the cascade. Pushed those writes inside fetchTickets
(after the function boundary, so they batch with the eventual
setTickets(result)). Added a request-id ref so a slow first
response can't overwrite a fast second one.
Frontend lint: 20 errors → 0 errors. tsc -b clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Five files, all stylistic:
- useFlowPilotSession.ts: typed the axios error shape with a narrow
inline type instead of \`as any\`.
- FlowPilotSessionPage.tsx: same — typed location.state once, then
destructured.
- ScriptBuilderTab.tsx: handleViewScript was a placeholder no-op;
declared the args properly with \`void script; void filename\` so the
signature matches ScriptBuilderChatProps without no-unused-vars
firing.
- TicketsPage.tsx: replaced 8 ternaries-as-statements (\`x ? f() : g()\`)
with proper if/else blocks. Same control flow, satisfies
no-unused-expressions, and reads better in the URL-param update paths.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
react-refresh/only-export-components fires when a file with the
\`router\` const export also defines a component (the redirect helper).
Moves the small helper to its own file under components/routing/ so
HMR can keep the route-component module hot-reload-eligible.
No behavior change.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Brings in PR #141 (PSA ticket management) so FlowPilot can ship on top
of a unified main. Two manual conflict resolutions:
1. CLAUDE.md — kept the FlowPilot ai-handoff rewrite (`.ai/`-driven
protocol). The pre-rewrite reference content (CW integration notes,
lessons archive, env vars table) lives in `docs/connectwise/`,
`docs/LESSONS-ARCHIVE.md`, and DEV-ENV.md by design.
2. frontend/src/pages/AssistantChatPage.tsx — both conflict regions
were purely additive. Concatenated FlowPilot's Phase 2-9 state hooks
(facts, activeFix, preview*, scriptPanelOpen, templatizeQueue) with
PSA's spin-off ticket state (linkedTicket, showNewTicket, spinOffHint).
Both modal mounts (TemplatizePrompt, ShortcutsHelpOverlay,
NewTicketModal) kept. All setters wired by either branch are intact.
Verification:
- `tsc -b` clean across the merged tree.
- Browser smoke-test (Session B fixture): Phase 9 ProposalBanner
("Run AI-drafted PowerShell to recover SSL VPN") renders alongside
PSA's new Tickets sidebar icon. Console clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1. EscalateInterceptDialog clipped off-screen.
The dialog was positioned with `absolute bottom-full mb-2 left-0`
under the assumption the Escalate button would have room above it.
In practice the button lives in the chat-page action bar near
y≈105, so the 302 px dialog overflows the top of the viewport
and only the last option is visible.
Switch to `top-full mt-2 right-0` — anchors the dialog below the
button and aligns its right edge with the button (avoids overflow
off the right when the button is in the right-side action cluster).
2. TemplateMatchPanel never renders on a fresh session.
`handleApplyFix` for the script_template_id branch only sets
`scriptPanelOpen=true`, but TemplateMatchPanel is mounted inside
`TaskLane.bottomSlot`. On sessions with no questions/facts the
lane defaults closed, so the panel exists in the React tree but
inside an unrendered TaskLane — the user clicks Apply fix and
nothing visibly changes.
Fix: also `setShowTaskLane(true)` in that branch so the lane
opens alongside the panel. The ai_drafted_script branch is fine
(InlineNoTemplateDialog renders in the chat region, not in the
lane), so it's left alone.
Both bugs were latent — they only surface on sessions that haven't
accumulated TaskLane state yet (questions/facts). Fresh sessions
created from the StartSessionInput hide them because the AI's first
turn populates questions and the lane auto-opens. Caught using the
new seed_phase9_qa_fixtures.py harness.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The ResolutionNotePreview popover renders inside TaskLane's
overflow-y-auto region at the bottom of the lane. On a 720px
viewport with the default question/check list expanded, the
popover lands below the visible scroll position — the engineer
clicks "Preview Resolve note", sees the button label flip to
"Showing", but no preview appears on screen.
Add a useEffect that calls scrollIntoView({block: 'nearest'}) on
the popover's outer div whenever `open` flips to true. block:
'nearest' scrolls just enough to make it visible without yanking
the lane to the top.
Discovered during Phase 9 QA. Reproduced at 1280x720; fix verified
visually in the same QA run (screenshots in
.gstack/qa-reports/phase9-*/).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Addresses docs/FlowAssist_Migration/Issues/phase-9-review-issues.md.
Issue #1 (High): "Applied partially" from the escalation intercept silently
dropped because the backend requires notes on applied_partial and the dialog
sent none. The catch was silent and the UI advanced into the conclude flow
as if the outcome were recorded.
- EscalateInterceptDialog now has a two-step flow: clicking the partial
choice reveals a notes textarea (autofocused, required non-empty) plus
Back / "Record partial & escalate" buttons.
- onChoose signature extended to (choice, notes?).
- handleInterceptChoice passes notes to patchOutcome; on failure it
surfaces a toast and does NOT advance to the conclude modal, so the
intercept stays open for retry.
Issue #2 (Medium/High): ScriptBuilderTab kept local state across active-fix
changes within the same pilot session, so a stale draft could PATCH against
a newer fix.id. Added key={activeFix.id} on the mount — forces a clean
remount per fix; backend get-or-create (keyed on user+ai_session_id) still
returns the same session row, which is the intended resume-on-refresh
semantic; but messages/editorBuffer/latestScript local state resets.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per Phase 9 §5. Before: banner Apply click stamped applied_at
regardless of whether the engineer had committed to running anything,
starting the Verifying timer prematurely. After:
- handleApplyFix no longer calls applyFix(). It just routes to the
right surface (TemplateMatchPanel / InlineNoTemplateDialog / Script
Builder tab).
- handleScriptDecision stamps applied_at for one_off + draft_template
(both labels are 'Run now, …' — the click is the declaration).
build_template does not stamp.
- TemplateMatchPanel's new 'I ran this' button calls applyFix via a
new onMarkRun prop.
- Script Builder tab Submit does not stamp (a draft is not a run).
No backend change — the /apply endpoint is unchanged. Only call sites
move.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires the three new components into AssistantChatPage:
- ChatTabStrip renders when the active fix needs a script drafted.
- ScriptBuilderTab sits alongside chat via display:none toggling so
chat scroll position + builder state both persist.
- InlineNoTemplateDialog replaces the task-lane bottomSlot render for
the drafted-script evaluation case; three cards finally fit.
- Banner Apply routing updated: no-draft/no-template → Script Builder
tab; drafted → InlineNoTemplateDialog; template → unchanged path.
applyFix() call site moves land in the next task.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the gap Phase 8 final review flagged. When a fix is in
applied_partial state and the engineer escalates, the intercept no
longer forces them to approximate with didn't-work/worked/never-applied.
AssistantChatPage's handleInterceptChoice (Task 13) already dispatches
to patchOutcome for any FixOutcome value, so no handler change is
needed — the type already supports applied_partial.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Generate and Copy alone don't declare a run — the engineer can walk
away after copying. Phase 9 §5 defines an explicit run-declaration
affordance so applied_at only stamps on the engineer's positive
commitment. Wiring from AssistantChatPage lands in Task 13.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Slide-up wrapper around the existing NoTemplateDialog for rendering
in the chat region above the composer (parallel to ProposalBanner).
The chat region's width lets grid-cols-3 finally work as intended.
No change to NoTemplateDialog itself; decision callbacks and card
copy stay identical.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Owns the inline Script Builder session lifecycle:
- Get-or-create (origin='pilot_inline', ai_session_id) on mount.
- Renders ScriptBuilderChat in AI mode and CodeModeEditor (Monaco) in
'Write it myself' mode. Mode toggles via display:none so buffer and
messages persist across switches.
- Submit → sessionSuggestedFixesApi.patchScript; emits onScriptDrafted
to parent, which refreshes the fix and hides the tab strip.
- Relays in-progress state to the parent via onProgressChange for the
ChatTabStrip's indicator dot.
ScriptBuilderChat is untouched (stays presentational). Persistence
semantics live on the controller, not the display component.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two-tab strip for the chat region. Parent controls mounting (strip only
appears when the fix needs a script drafted). Indicator dot signals
in-progress draft state. Tab switching via onChange callback; parent
handles display:none toggling so tab contents preserve state.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
sessionSuggestedFixesApi.patchScript(sessionId, fixId, script, params?)
hits the new PATCH /script endpoint.
scriptBuilder.createSession accepts an optional options bag with
origin + aiSessionId, defaulting to standalone when omitted so legacy
callers stay behavior-preserving.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Issue #3 from phase-8-review-issues.md. 'Not yet' on the AI-confirming
banner was a local-state hide; the proposal re-surfaced on the next
refreshSessionDerived call.
Two-part fix:
- PATCH /outcome now clears ai_outcome_proposal on any terminal action
(engineer has taken a decision; stale AI proposal is moot).
- New DELETE /ai-sessions/:sid/suggested-fixes/:fid/ai-outcome-proposal
endpoint for explicit 'Not yet' rejection. Does not touch status
or state_version — pure UI state.
Frontend handleRejectAIProposal now calls the DELETE and setActiveFix
with the server response.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Issue #2 from phase-8-review-issues.md. Apply was client-side-only via
a bannerApplied flag. Refresh / chat reselect / multi-tab would drop
Verifying state back to Proposed.
- New POST /ai-sessions/{sid}/suggested-fixes/{fid}/apply stamps
applied_at without changing status (still 'proposed'). Idempotent
if already stamped; 409 if fix is past proposed (a terminal outcome
was already recorded).
- Bumps state_version so resolve/escalate preview bundles reflect that
the fix has entered verifying.
- Frontend handleApplyFix calls the endpoint and uses the returned
applied_at directly. bannerApplied client flag is removed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Superseded by ProposalBanner (Phase 8). The import was already removed
from AssistantChatPage in the previous commit; this deletes the orphaned
file itself and strips the now-unused suggestedFixSlot prop from
TaskLane's interface and both call sites.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the task-lane SuggestedFix card with the ProposalBanner docked
above the chat composer. Wires:
- Resolve-while-verifying auto-marks applied_success (one-click resolve).
- Escalate-while-verifying opens EscalateInterceptDialog to capture the
real outcome (default: didn't work) before handoff.
- 3+ post-apply engineer messages trigger the passive Nudge banner.
- AI [FIX_OUTCOME] proposals surface in the AIConfirming state; one-click
confirm applies the outcome.
Banner state resets on session switch via resetSessionDerivedState.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Anchored above the Escalate button, captures fix outcome before the
engineer hands off the ticket. Defaults to 'didn't work' on Enter
(the common case). Alternatives: 'worked, escalating for another
reason' (preserves success) and 'never actually applied' (dismiss).
Task 11 will wire this to AssistantChatPage's Escalate handler.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Completes ProposalBanner's state machine. AIConfirming (accent-blue)
surfaces the AI's [FIX_OUTCOME] proposal with one-click accept; Nudge
is the compact passive-prompt variant for post-apply chats; Collapsed
is the 28px expand-hint strip.
Adds onSilenceNudge prop so the parent can silence the nudge without
collapsing it (Task 11 wires this). Removes the last three stale
eslint-disable-next-line comments — all sub-components now use props.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Verifying: amber pulse animation, confidence pill becomes 'Applied Xm ago',
three actions (overflow for Mark partial, Didn't work, It worked). window.prompt
used for the partial notes + failure reason inputs — good-enough v1 pending
an inline composer.
Partial: cyan-toned to signal 'parked, outcome unknown', shows saved notes
inline, Finish it / Didn't work / It worked actions.
Adds pulse-amber to @theme animations alongside slide-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>