41 Commits

Author SHA1 Message Date
d386d11af2 docs(pilot): correct Phase 9 migration description
All checks were successful
Mirror to GitHub / mirror (push) Successful in 4s
Handoff + migration spec incorrectly claimed Phase 9 added a new
parent_pilot_session_id FK. The implementation reuses the existing
ai_session_id column; the migration only adds the origin discriminator
+ partial unique index. Also: ScriptBuilderTab wraps ScriptBuilderChat
and ScriptBodyEditor (Monaco), not "ScriptBuilderChat in ephemeral
mode" — there is no ephemeral mode on the presentational component.

Applies applied_at call-site specifics: handleScriptDecision stamps
on one_off/draft_template, TemplateMatchPanel stamps on onMarkRun,
Script Builder tab Submit does not stamp.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 06:17:08 -04:00
65a831bf9a docs(pilot): Phase 9 handoff + migration spec update
Marks open items #1 (NoTemplateDialog narrow-lane) and #3 (Tabbed
Script Builder) as resolved. Records the applied_at semantics
correction as shipped. Final Phase 9 row added to the 'What shipped'
table.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 06:14:41 -04:00
faf1d8dd12 fix(pilot): applied_at stamps on run-declaring actions, not Apply click
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>
2026-04-24 04:11:56 -04:00
0386fa1fd5 feat(pilot): mount ChatTabStrip + ScriptBuilderTab + InlineNoTemplateDialog
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>
2026-04-24 04:02:20 -04:00
82db1c78e4 feat(pilot): EscalateInterceptDialog — fourth 'partial' choice
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>
2026-04-24 03:04:05 -04:00
f930787200 feat(pilot): TemplateMatchPanel — explicit 'I ran this' action
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>
2026-04-24 03:02:17 -04:00
5bcb7aa7c3 feat(pilot): InlineNoTemplateDialog — chat-region placement wrapper
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>
2026-04-24 02:56:35 -04:00
04fbfe3b8f feat(pilot): ScriptBuilderTab controller
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>
2026-04-24 02:55:12 -04:00
f92cbefed9 feat(pilot): ChatTabStrip component — [Chat] [Script Builder ●]
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>
2026-04-24 02:45:16 -04:00
c9306e40c9 feat(pilot): frontend API client — patchScript + inline createSession
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>
2026-04-24 02:38:07 -04:00
1c855563ee feat(pilot): PATCH /suggested-fixes/:id/script endpoint
Called by the inline Script Builder tab on Submit. Writes
ai_drafted_script + ai_drafted_parameters to the fix without stamping
applied_at (a draft is not an application — that's §5 of the Phase 9
spec). Bumps state_version so Resolve/Escalate preview bundles
regenerate.

409 on terminal fix status. 404 on wrong session. 422 on empty script.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 02:34:06 -04:00
d4fae87236 feat(pilot): inline Script Builder session — idempotent create + auth + filtered list
POST /script-builder/sessions now supports origin='pilot_inline':
- Requires ai_session_id; validates it against current user ownership.
- Get-or-create: returns existing row for (user, ai_session_id) pair.
- Partial unique index on the DB backs the invariant; races resolve to
  the single winner row.

list_sessions + count_user_sessions default-scope to origin='standalone'
so inline scratch sessions don't pollute the /script-builder dashboard
or count against the 5-session cap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 02:24:57 -04:00
f2fce27f0d feat(pilot): pydantic schemas for inline origin + script PATCH
- ScriptBuilderCreateRequest gains origin ('standalone' | 'pilot_inline')
  and optional ai_session_id. Handler-side validation (next task) enforces
  pilot_inline ⇒ ai_session_id required + owned by caller.
- SessionSuggestedFixScriptRequest added for the new PATCH /script
  endpoint (Phase 9 Task 6).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 01:53:28 -04:00
93c974466a feat(pilot): script_builder_sessions.origin on SQLAlchemy model
Mirrors the DB column added in the prior migration. App-level default
is 'standalone' so existing callers of ScriptBuilderSession(...) work
without code changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 01:48:22 -04:00
8012668975 feat(pilot): add origin + inline idempotency to script_builder_sessions
Phase 9 prep. Adds:
- origin VARCHAR(20) NOT NULL with CHECK ('standalone' | 'pilot_inline')
- invariant: pilot_inline rows must have ai_session_id
- partial unique index on (user_id, ai_session_id) WHERE origin='pilot_inline'
  — backs get-or-create idempotency for the inline Script Builder tab.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 00:22:53 -04:00
563bb1aa6f docs(pilot): Phase 9 implementation plan
14-task plan covering:
- DB migration for origin + partial unique index on script_builder_sessions
- Pydantic schemas for inline origin + PATCH /script
- POST /script-builder/sessions idempotent for pilot_inline + auth
- list_sessions + count_user_sessions filtered to standalone
- PATCH /suggested-fixes/:id/script (bumps state_version, no applied_at)
- Frontend API client additions
- ChatTabStrip, ScriptBuilderTab (controller), InlineNoTemplateDialog
- TemplateMatchPanel 'I ran this' action
- EscalateInterceptDialog fourth 'partial' choice
- AssistantChatPage integration + applyFix call-site relocation
- Docs + handoff updates

Paired with the spec at phase-9-script-builder-tab.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 00:03:57 -04:00
1d2d548fc8 docs(pilot): Phase 9 spec — final consistency polish
- Frontend scriptBuilder API client inventory now matches the backend
  schema: createSession accepts BOTH origin and ai_session_id (both
  required together for inline callers, both omitted for standalone).
- 'If template -> unchanged' sharpened: render location is unchanged,
  but run stamping moves into the panel's new 'I ran this' action per
  the §5 apply lifecycle correction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 23:54:04 -04:00
3ee0101c6d docs(pilot): Phase 9 spec — ownership + schema corrections
- scriptBuilderMode ownership: pinned to ScriptBuilderTab, removed from
  AssistantChatPage's state list. Parent never drives the AI/editor
  toggle; controller owns it and resets naturally on session switch via
  unmount/remount. scriptBuilderHasProgress stays on the page (needed
  for the tab strip indicator dot) and is driven by the controller via
  an onProgressChange callback.
- ScriptBuilderCreateRequest schema: explicitly calls for TWO new
  optional fields (origin + ai_session_id), not just origin. Handler
  enforces: when origin='pilot_inline', ai_session_id is required and
  must pass the current-user ownership check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 23:49:08 -04:00
861d082ff7 docs(pilot): Phase 9 spec — consistency pass on Apply stamp call sites
Three consistency fixes:
- File inventory (backend + frontend) now names all three apply-stamp
  call sites: handleScriptDecision('one_off' | 'draft_template') plus
  TemplateMatchPanel's 'I ran this' handler. Previously listed only
  'one_off' in two places, contradicting the §5 lifecycle table.
- NoTemplateDialog relocation section no longer claims the decision
  handler is 'unchanged' — it is unchanged EXCEPT for the moved
  apply stamp, which is the point of §5.
- Open deferrals entry on ScriptBuilderChat 'ephemeral mode' removed;
  replaced with the actual new surface (ScriptBuilderTab controller),
  which reuses the existing script-builder prompt unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 23:41:17 -04:00
75b59123e6 docs(pilot): Phase 9 spec — fix Apply semantics + session idempotency
Four review findings addressed:

- High: draft_template 'Run now, templatize after' DOES run the
  script; applied_at table now stamps for both one_off and
  draft_template. Only build_template (no run) skips the stamp.
- Medium: TemplateMatchPanel needs an explicit '✓ I ran this' button.
  Generate/Copy don't commit to running. The new button is the stamp
  moment for template-match fixes.
- Medium: get-or-create for inline script_builder_sessions —
  POST /script-builder/sessions is now idempotent for
  origin='pilot_inline' (returns the existing row for a
  (user, ai_session_id) pair). Backed by a partial unique index:
    UNIQUE (user_id, ai_session_id) WHERE origin = 'pilot_inline'
  so remount doesn't create duplicates and draft continuity is
  preserved.
- Medium: authorization — the create endpoint validates that any
  provided ai_session_id is owned by the current user (same guard
  other pilot endpoints use). Prevents cross-user attachment of
  scratch sessions to arbitrary pilot sessions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 23:34:53 -04:00
fcd224429c docs(pilot): revise Phase 9 spec per review findings
Four findings addressed:

- High: drop proposed parent_pilot_session_id column; reuse the
  existing ai_session_id FK on script_builder_sessions. Add an
  origin + ai_session_id coherence invariant.
- High: don't add a 'mode' prop to ScriptBuilderChat (it's
  presentational). Introduce a ScriptBuilderTab controller that owns
  session lifecycle + submit, renders ScriptBuilderChat unchanged.
- Medium: filter list_sessions / count_user_sessions to origin='standalone'
  so pilot_inline scratch sessions don't pollute the /script-builder
  dashboard or count against the 5-session cap.
- Medium: applied_at is stamped only when the engineer commits to a
  run-action (one_off, TemplateMatchPanel Run), not on banner Apply
  click. Corrects a Phase 8 over-eager stamp that would otherwise
  multiply across three surfaces.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 23:28:53 -04:00
196c003876 docs(pilot): Phase 9 spec — tabbed Script Builder + NoTemplateDialog relocation
Design doc for the FlowPilot migration's remaining open items:
- NoTemplateDialog narrow-lane bug (resolved by moving the dialog to
  the chat region alongside ProposalBanner — three cards fit naturally
  at that width; grid-cols fix no longer needed)
- Tabbed Script Builder inside the chat (new [Chat] [Script Builder ●]
  tab strip; AI chat default with 'Write it myself' Monaco escape hatch)

Plus a Phase 8 cleanup:
- EscalateInterceptDialog fourth 'I applied some of it — partial' choice

All six architecture decisions settled via brainstorming before writing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 23:03:57 -04:00
f2b9476edb docs(pilot): log Issues #1-4 findings for Phase 8 review
Tracks the three code-review issues that were fixed on this branch
(#1 outcome-aware previews, #2 persist Apply, #3 persist proposal
rejection) plus a newly-documented pre-existing test failure (#4 —
decision-endpoint test written in Phase 3 never updated when Phase 5
added the drafted-script validation guard).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 22:18:13 -04:00
70c5da0c75 fix(pilot): persist AI-proposal rejection + clear on outcome write
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>
2026-04-23 22:15:48 -04:00
de2bef3175 fix(pilot): persist Apply — stamp applied_at on click
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>
2026-04-23 22:10:52 -04:00
362c7b1d79 fix(pilot): outcome-aware Resolve/Escalate previews
Issue #1 from phase-8-review-issues.md. Cache invalidation alone isn't
enough — previews were also omitting outcome fields from the LLM bundle,
so a fresh regenerate still couldn't distinguish proposed / failed /
partial / success.

- PATCH /outcome now bumps ai_sessions.state_version (matches
  record_decision's existing pattern).
- Resolution-note + escalation-package bundles now include status,
  applied_at, verified_at, partial_notes, failure_reason on the active fix.
- Generator prompts prescribe outcome-aware phrasing (closure language
  for success; what-we've-tried + next-steps for failed/partial).
- New end-to-end test asserts the regenerated preview reflects the
  recorded outcome, not just that the cache key changed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 22:04:56 -04:00
ec104dc8de docs(pilot): sync Phase 8 handoff with actual implementation
Correct the stale ai_sessions.fix_outcome reference (no such column) —
the real schema adds six columns to session_suggested_fixes. Update
last_commit to reflect the docs-correction tip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 19:48:54 -04:00
a47ce07326 docs(pilot): fix Phase 8 column + commit-SHA references
Correct the FLOWPILOT-MIGRATION.md stale references to a non-existent
ai_sessions.fix_outcome column — the actual implementation added six
columns to session_suggested_fixes. Also fix a stale first-commit SHA
(6721b84 → cdd8bb0, the former was amended away).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 17:42:51 -04:00
2a54127a54 docs(pilot): Phase 8 fix outcome banner — handoff + migration spec
Marks open item #2 (task-lane crowding / Suggested Fix discoverability)
as resolved by Phase 8. Open items #1 (NoTemplateDialog narrow-lane)
and #3 (Tabbed Script Builder inside chat) remain deferred.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 16:52:07 -04:00
8582d24236 chore(pilot): remove deprecated SuggestedFix task-lane card
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>
2026-04-23 16:48:42 -04:00
bdb238a274 feat(pilot): mount ProposalBanner + wire implicit signals
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>
2026-04-23 16:42:01 -04:00
075b0fc1d8 feat(pilot): EscalateInterceptDialog popover
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>
2026-04-23 15:48:33 -04:00
217747f46e feat(pilot): banner AI-confirming, Nudge, Collapsed states
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>
2026-04-23 15:39:08 -04:00
7fa1d6a32f feat(pilot): banner Verifying + Partial states
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>
2026-04-23 15:32:02 -04:00
ac67e48500 feat(pilot): ProposalBanner scaffold + Proposed state
New component that will replace the task-lane SuggestedFix card. Docks
above the chat composer with a 320ms slide-up animation. This commit
implements only the Proposed state (Tasks 8 & 9 fill Verifying, Partial,
AI-confirming, Nudge, Collapsed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 15:25:41 -04:00
cdd29b460e feat(pilot): frontend fix-outcome types + patchOutcome API
Extends SessionSuggestedFix with outcome fields (status, applied_at,
verified_at, partial_notes, failure_reason, ai_outcome_proposal) and
adds a patchOutcome method hitting the new backend endpoint.

FixStatus (5 values) + FixOutcome (4 writable values) mirror the
backend Pydantic types and the DB check constraint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 15:20:16 -04:00
2cde6673b0 feat(pilot): [FIX_OUTCOME] system prompt instructions
Tells the AI when + how to emit the [FIX_OUTCOME] marker that Task 4's
parser consumes. Placeholder-only per the anti-parrot pattern — no
literal UUIDs, outcomes, or reasons that could leak into unrelated
sessions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 15:17:21 -04:00
c0112f8bee feat(pilot): [FIX_OUTCOME] marker parser + AI outcome proposal
The AI emits [FIX_OUTCOME] when the engineer indicates in chat that a
prior suggested fix worked, didn't work, or was partially applied. The
marker writes to session_suggested_fixes.ai_outcome_proposal (JSONB),
which the frontend surfaces as a "confirm outcome?" banner. The status
column is only updated when the engineer clicks confirm (via PATCH
/outcome endpoint from Task 3).

Placeholder-only system prompt wiring comes in Task 5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 15:08:43 -04:00
8988dbc885 feat(pilot): PATCH /suggested-fixes/:id/outcome endpoint + tests
Records engineer-reported outcome (applied_success|applied_failed|
applied_partial|dismissed). Enforces transition rules (partial → success/
failed allowed; terminal outcomes return 409) and notes requirements
(applied_partial requires notes).

Sets verified_at on success/failure, stamps applied_at if not already
set (handles the case where the AI [FIX_OUTCOME] marker fires before
the engineer clicks Apply).

Also fixes pre-existing test-infrastructure bug: network_diagram.py used
bare string server_default="'[]'" for JSONB columns, which asyncpg
rejects during test schema creation. Changed to text("'[]'::jsonb") to
match the pattern used by script_template.py.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 14:59:34 -04:00
4a8e3ae954 feat(pilot): pydantic schemas for fix outcome patch
Adds FixStatus literal (5 values matching the DB check constraint),
extends SessionSuggestedFixResponse with outcome fields, and introduces
SessionSuggestedFixOutcomeRequest for the PATCH /outcome endpoint coming
in Task 3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 14:44:39 -04:00
cdd8bb05cc feat(pilot): add outcome tracking columns to session_suggested_fixes
Phase 8 prep for the fix outcome banner. Adds:
- status (proposed|applied_success|applied_failed|applied_partial|dismissed)
- applied_at, verified_at (timestamps)
- partial_notes, failure_reason (engineer-provided context)
- ai_outcome_proposal (JSONB for AI [FIX_OUTCOME] marker payloads)

Backfills status='dismissed' from user_decision='dismissed'. status is
orthogonal to user_decision — outcome (did the fix work?) vs script-path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 14:40:17 -04:00
40 changed files with 9585 additions and 225 deletions

View File

@@ -0,0 +1,74 @@
"""add fix outcome tracking columns to session_suggested_fixes
Adds: status, applied_at, verified_at, partial_notes, failure_reason,
ai_outcome_proposal.
status is the outcome dimension (did the fix work?), orthogonal to the
existing user_decision column (which script-path the engineer took).
Revision ID: 6492ec8d2d5b
Revises: f07010f17b01
Create Date: 2026-04-23 18:32:38.609719
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '6492ec8d2d5b'
down_revision: Union[str, None] = 'f07010f17b01'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"session_suggested_fixes",
sa.Column("status", sa.String(length=20), nullable=False, server_default=sa.text("'proposed'")),
)
op.add_column(
"session_suggested_fixes",
sa.Column("applied_at", sa.DateTime(timezone=True), nullable=True),
)
op.add_column(
"session_suggested_fixes",
sa.Column("verified_at", sa.DateTime(timezone=True), nullable=True),
)
op.add_column(
"session_suggested_fixes",
sa.Column("partial_notes", sa.Text(), nullable=True),
)
op.add_column(
"session_suggested_fixes",
sa.Column("failure_reason", sa.Text(), nullable=True),
)
op.add_column(
"session_suggested_fixes",
sa.Column("ai_outcome_proposal", postgresql.JSONB(), nullable=True),
)
# Backfill before constraint creation so dismissed rows satisfy the new CHECK.
op.execute(
"UPDATE session_suggested_fixes "
"SET status = 'dismissed' "
"WHERE user_decision = 'dismissed'"
)
op.create_check_constraint(
"ck_session_suggested_fixes_status",
"session_suggested_fixes",
"status IN ('proposed', 'applied_success', 'applied_failed', 'applied_partial', 'dismissed')",
)
op.alter_column("session_suggested_fixes", "status", server_default=None)
def downgrade() -> None:
op.drop_constraint("ck_session_suggested_fixes_status", "session_suggested_fixes", type_="check")
op.drop_column("session_suggested_fixes", "ai_outcome_proposal")
op.drop_column("session_suggested_fixes", "failure_reason")
op.drop_column("session_suggested_fixes", "partial_notes")
op.drop_column("session_suggested_fixes", "verified_at")
op.drop_column("session_suggested_fixes", "applied_at")
op.drop_column("session_suggested_fixes", "status")

View File

@@ -0,0 +1,70 @@
"""add origin discriminator + inline idempotency to script_builder_sessions
Adds:
- origin VARCHAR(20) NOT NULL DEFAULT 'standalone' with CHECK enum
- invariant: pilot_inline rows must have ai_session_id
- partial unique index: one pilot_inline session per (user, pilot session)
Revision ID: 71efd2102f49
Revises: 6492ec8d2d5b
Create Date: 2026-04-24 04:22:10.819809
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '71efd2102f49'
down_revision = '6492ec8d2d5b'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"script_builder_sessions",
sa.Column(
"origin",
sa.String(length=20),
nullable=False,
server_default=sa.text("'standalone'"),
),
)
op.create_check_constraint(
"ck_script_builder_sessions_origin",
"script_builder_sessions",
"origin IN ('standalone', 'pilot_inline')",
)
op.create_check_constraint(
"ck_script_builder_sessions_origin_ai_session",
"script_builder_sessions",
"origin <> 'pilot_inline' OR ai_session_id IS NOT NULL",
)
op.create_index(
"ux_script_builder_sessions_pilot_inline",
"script_builder_sessions",
["user_id", "ai_session_id"],
unique=True,
postgresql_where=sa.text("origin = 'pilot_inline'"),
)
# Drop the server_default — app code owns the default via model default.
op.alter_column("script_builder_sessions", "origin", server_default=None)
def downgrade() -> None:
op.drop_index(
"ux_script_builder_sessions_pilot_inline",
table_name="script_builder_sessions",
)
op.drop_constraint(
"ck_script_builder_sessions_origin_ai_session",
"script_builder_sessions",
type_="check",
)
op.drop_constraint(
"ck_script_builder_sessions_origin",
"script_builder_sessions",
type_="check",
)
op.drop_column("script_builder_sessions", "origin")

View File

@@ -3,12 +3,14 @@ from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy import text
from sqlalchemy import select, text
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.rate_limit import limiter
from app.api.deps import get_current_active_user
from app.models.ai_session import AISession
from app.models.user import User
from app.models.script_builder_session import ScriptBuilderSession
from app.schemas.script_builder import (
@@ -67,15 +69,85 @@ async def create_session(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> ScriptBuilderSessionDetail:
"""Start a new Script Builder session."""
"""Start a new Script Builder session.
When origin='pilot_inline', behaves as get-or-create: the same row is
returned on repeated calls with the same (user, ai_session_id) pair.
Inline sessions are excluded from the session cap and the list endpoint.
"""
# Phase 9: inline origin validation + authorization
if data.origin == "pilot_inline":
if data.ai_session_id is None:
raise HTTPException(
status_code=400,
detail="ai_session_id is required when origin='pilot_inline'",
)
# Ownership check: the pilot session must belong to the current user.
ai_session = await db.scalar(
select(AISession).where(
AISession.id == data.ai_session_id,
AISession.user_id == current_user.id,
)
)
if ai_session is None:
raise HTTPException(
status_code=404,
detail="Session not found",
)
# Idempotent get-or-create: if a pilot_inline row already exists for
# this (user, ai_session_id) pair, return it without creating a duplicate.
existing = await db.scalar(
select(ScriptBuilderSession).where(
ScriptBuilderSession.user_id == current_user.id,
ScriptBuilderSession.ai_session_id == data.ai_session_id,
ScriptBuilderSession.origin == "pilot_inline",
)
)
if existing is not None:
# Re-fetch with message_records loaded
session = await script_builder_service.get_session(db, existing.id, current_user.id)
return _session_to_detail(session)
# Create the inline session — wrap in IntegrityError catch for races.
try:
session = await script_builder_service.create_session(
db=db,
user_id=current_user.id,
account_id=current_user.account_id,
team_id=current_user.team_id,
language=data.language,
origin=data.origin,
ai_session_id=data.ai_session_id,
)
await db.commit()
except IntegrityError:
await db.rollback()
# Race: another request won the unique index — re-read the winner row.
existing = await db.scalar(
select(ScriptBuilderSession).where(
ScriptBuilderSession.user_id == current_user.id,
ScriptBuilderSession.ai_session_id == data.ai_session_id,
ScriptBuilderSession.origin == "pilot_inline",
)
)
if existing is None:
raise
session = existing
# Re-fetch with message_records loaded
session = await script_builder_service.get_session(db, session.id, current_user.id)
return _session_to_detail(session)
# ── Standalone session ──────────────────────────────────────────────────
# Acquire per-user advisory lock so concurrent create requests are serialized.
# Without this, two simultaneous requests both read count < limit and both
# insert, exceeding MAX_SESSIONS_PER_USER.
user_lock_key = hash(str(current_user.id)) % (2**62)
await db.execute(text("SELECT pg_advisory_xact_lock(:key)"), {"key": user_lock_key})
# Enforce max concurrent sessions
count = await script_builder_service.count_user_sessions(db, current_user.id)
# Enforce max concurrent sessions (inline sessions excluded from cap)
count = await script_builder_service.count_user_sessions(db, current_user.id, include_inline=False)
if count >= MAX_SESSIONS_PER_USER:
raise HTTPException(
status_code=400,
@@ -88,6 +160,8 @@ async def create_session(
account_id=current_user.account_id,
team_id=current_user.team_id,
language=data.language,
origin=data.origin,
ai_session_id=data.ai_session_id,
)
await db.commit()
# Re-fetch with message_records loaded

View File

@@ -30,7 +30,9 @@ from app.schemas.session_suggested_fix import (
ResolutionPostResponse,
SessionSuggestedFixDecisionRequest,
SessionSuggestedFixDecisionResponse,
SessionSuggestedFixOutcomeRequest,
SessionSuggestedFixResponse,
SessionSuggestedFixScriptRequest,
)
from app.models.draft_template import DraftTemplate
from app.models.session_fact import SessionFact
@@ -216,6 +218,240 @@ async def record_decision(
)
# ── Suggested fix: apply (stamp applied_at) ──────────────────────────────
@router.post(
"/suggested-fixes/{fix_id}/apply",
response_model=SessionSuggestedFixResponse,
)
async def apply_suggested_fix(
session_id: UUID,
fix_id: UUID,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_engineer_or_admin),
) -> SessionSuggestedFixResponse:
"""Stamp applied_at when the engineer clicks Apply in the ProposalBanner.
This does NOT change status (fix remains 'proposed'). Status only flips
when the engineer records an outcome via PATCH /outcome.
Rules:
- Fix must be in 'proposed' status; any other status → 409.
- Idempotent: if applied_at is already set, returns 200 with the unchanged row.
- Bumps ai_sessions.state_version so resolve/escalate preview generators
know the fix has entered the verifying phase.
"""
await _load_session_or_404(db, session_id)
result = await db.execute(
select(SessionSuggestedFix).where(
SessionSuggestedFix.id == fix_id,
SessionSuggestedFix.session_id == session_id,
)
)
fix = result.scalar_one_or_none()
if fix is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Suggested fix not found"
)
if fix.status != "proposed":
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Apply is only valid from 'proposed'; fix is already '{fix.status}'",
)
# Idempotent: already stamped → return as-is without bumping state_version again.
if fix.applied_at is not None:
return SessionSuggestedFixResponse.model_validate(fix)
fix.applied_at = datetime.now(timezone.utc)
# Bump state_version so preview generators see the verifying-phase signal.
await db.execute(
update(AISession)
.where(AISession.id == session_id)
.values(state_version=AISession.state_version + 1)
)
await db.commit()
await db.refresh(fix)
return SessionSuggestedFixResponse.model_validate(fix)
# ── Suggested fix: outcome ────────────────────────────────────────────────
@router.patch(
"/suggested-fixes/{fix_id}/outcome",
response_model=SessionSuggestedFixResponse,
)
async def patch_suggested_fix_outcome(
session_id: UUID,
fix_id: UUID,
body: SessionSuggestedFixOutcomeRequest,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_engineer_or_admin),
) -> SessionSuggestedFixResponse:
"""Record the engineer's outcome for an applied fix.
See `SessionSuggestedFixOutcomeRequest` for transition rules.
"""
await _load_session_or_404(db, session_id)
now = datetime.now(timezone.utc)
result = await db.execute(
select(SessionSuggestedFix).where(
SessionSuggestedFix.id == fix_id,
SessionSuggestedFix.session_id == session_id,
)
)
fix = result.scalar_one_or_none()
if fix is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Suggested fix not found"
)
if body.outcome == "applied_partial" and not (body.notes and body.notes.strip()):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="notes are required when outcome is applied_partial",
)
TERMINAL = {"applied_success", "applied_failed", "dismissed"}
if fix.status in TERMINAL:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Fix is already in terminal status {fix.status!r}",
)
fix.status = body.outcome
if body.outcome == "applied_partial":
fix.partial_notes = (body.notes or "").strip() or None
elif body.outcome == "applied_failed":
fix.failure_reason = (body.notes or "").strip() or None
fix.verified_at = now
elif body.outcome == "applied_success":
fix.verified_at = now
# dismissed: no timestamp/notes stamping
if fix.applied_at is None and body.outcome != "dismissed":
fix.applied_at = now
# Clear any pending AI outcome proposal — engineer has taken a terminal action.
fix.ai_outcome_proposal = None
# Outcome changes the bundle that resolution-note/escalation-package
# previews see, so bump state_version inside the same transaction —
# mirrors the pattern in record_decision above.
await db.execute(
update(AISession)
.where(AISession.id == session_id)
.values(state_version=AISession.state_version + 1)
)
await db.commit()
await db.refresh(fix)
return SessionSuggestedFixResponse.model_validate(fix)
# ── Suggested fix: attach drafted script ─────────────────────────────────────
@router.patch(
"/suggested-fixes/{fix_id}/script",
response_model=SessionSuggestedFixResponse,
)
async def patch_suggested_fix_script(
session_id: UUID,
fix_id: UUID,
body: SessionSuggestedFixScriptRequest,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_engineer_or_admin),
) -> SessionSuggestedFixResponse:
"""Attach an engineer-drafted script to a suggested fix.
Called by the inline Script Builder tab on Submit. Does NOT stamp
applied_at — a draft is not an application. Bumps state_version so
the Resolve/Escalate preview bundles regenerate.
"""
await _load_session_or_404(db, session_id)
fix = await db.scalar(
select(SessionSuggestedFix).where(
SessionSuggestedFix.id == fix_id,
SessionSuggestedFix.session_id == session_id,
)
)
if fix is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Suggested fix not found")
TERMINAL = {"applied_success", "applied_failed", "dismissed"}
if fix.status in TERMINAL:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Fix is already in terminal status {fix.status!r}",
)
fix.ai_drafted_script = body.ai_drafted_script
fix.ai_drafted_parameters = body.ai_drafted_parameters
# Bump state_version on the parent session — previews cached by
# (session_id, state_version) must regenerate to reflect the new draft.
await db.execute(
update(AISession)
.where(AISession.id == session_id)
.values(state_version=AISession.state_version + 1)
)
await db.commit()
await db.refresh(fix)
return SessionSuggestedFixResponse.model_validate(fix)
# ── Suggested fix: clear AI outcome proposal ("Not yet") ─────────────────────
@router.delete(
"/suggested-fixes/{fix_id}/ai-outcome-proposal",
response_model=SessionSuggestedFixResponse,
)
async def clear_ai_outcome_proposal(
session_id: UUID,
fix_id: UUID,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_engineer_or_admin),
) -> SessionSuggestedFixResponse:
"""Explicitly dismiss the AI-proposed outcome banner ("Not yet").
Clears `ai_outcome_proposal` without touching status or state_version
(this is pure UI state, not outcome data). Idempotent: returns 200 even
when the field is already null. After this call the banner will not
re-surface on the next refreshSessionDerived unless the AI emits a new
proposal.
"""
await _load_session_or_404(db, session_id)
result = await db.execute(
select(SessionSuggestedFix).where(
SessionSuggestedFix.id == fix_id,
SessionSuggestedFix.session_id == session_id,
)
)
fix = result.scalar_one_or_none()
if fix is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Suggested fix not found"
)
fix.ai_outcome_proposal = None
await db.commit()
await db.refresh(fix)
return SessionSuggestedFixResponse.model_validate(fix)
async def _summarize_session_for_extraction(
db: AsyncSession, session_id: UUID,
) -> str:

View File

@@ -3,7 +3,7 @@ import uuid
from datetime import datetime, timezone
from typing import Any, TYPE_CHECKING
from sqlalchemy import String, Text, Boolean, DateTime, ForeignKey
from sqlalchemy import String, Text, Boolean, DateTime, ForeignKey, text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, JSONB
@@ -30,8 +30,8 @@ class NetworkDiagram(Base):
client_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
asset_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
nodes: Mapped[list[dict[str, Any]]] = mapped_column(JSONB, nullable=False, server_default="'[]'")
edges: Mapped[list[dict[str, Any]]] = mapped_column(JSONB, nullable=False, server_default="'[]'")
nodes: Mapped[list[dict[str, Any]]] = mapped_column(JSONB, nullable=False, server_default=text("'[]'::jsonb"))
edges: Mapped[list[dict[str, Any]]] = mapped_column(JSONB, nullable=False, server_default=text("'[]'::jsonb"))
thumbnail_url: Mapped[str | None] = mapped_column(Text, nullable=True)
is_archived: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False,

View File

@@ -62,6 +62,16 @@ class ScriptBuilderSession(Base):
nullable=True,
comment="Link to FlowPilot session if launched from there",
)
origin: Mapped[str] = mapped_column(
String(20),
nullable=False,
default="standalone",
comment=(
"Session origin — 'standalone' (from /script-builder) or "
"'pilot_inline' (from FlowPilot Script Builder tab). "
"Invariant: pilot_inline rows must have ai_session_id set."
),
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)

View File

@@ -35,6 +35,11 @@ class SessionSuggestedFix(Base):
"'one_off', 'draft_template', 'build_template', 'dismissed')",
name="ck_session_suggested_fixes_user_decision",
),
CheckConstraint(
"status IN ('proposed', 'applied_success', 'applied_failed', "
"'applied_partial', 'dismissed')",
name="ck_session_suggested_fixes_status",
),
)
id: Mapped[uuid.UUID] = mapped_column(
@@ -65,6 +70,21 @@ class SessionSuggestedFix(Base):
JSONB, nullable=True
)
user_decision: Mapped[str | None] = mapped_column(String(32), nullable=True)
# Outcome dimension — did the fix work? Orthogonal to user_decision.
status: Mapped[str] = mapped_column(
String(20), nullable=False, default="proposed"
)
applied_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
verified_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
partial_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
failure_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
ai_outcome_proposal: Mapped[dict[str, Any] | None] = mapped_column(
JSONB, nullable=True
)
# Set when a newer suggested fix supersedes this one.
superseded_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True

View File

@@ -1,18 +1,27 @@
"""Pydantic schemas for the AI Script Builder."""
from datetime import datetime
from typing import Optional
from typing import Literal, Optional
from uuid import UUID
from pydantic import BaseModel, Field
class ScriptBuilderCreateRequest(BaseModel):
"""Request to start a new builder session."""
"""Request to start (or get-or-create, for inline origin) a builder session.
When `origin='pilot_inline'`, `ai_session_id` is REQUIRED and must
reference a pilot session owned by the current user. The endpoint's
get-or-create semantics kick in: if a pilot_inline session already
exists for (user_id, ai_session_id), that row is returned instead of
creating a duplicate.
"""
language: str = Field(
default="powershell",
pattern=r"^(powershell|bash|python)$",
description="Script language",
)
origin: Literal["standalone", "pilot_inline"] = "standalone"
ai_session_id: UUID | None = None
class ScriptBuilderMessageRequest(BaseModel):

View File

@@ -12,6 +12,17 @@ from pydantic import BaseModel, Field
UserDecision = Literal["one_off", "draft_template", "build_template", "dismissed"]
# "dismissed" here is the outcome dimension — orthogonal to UserDecision's
# "dismissed" (script-path choice), though the migration backfill aligns
# them for pre-existing rows.
FixStatus = Literal[
"proposed",
"applied_success",
"applied_failed",
"applied_partial",
"dismissed",
]
class SessionSuggestedFixResponse(BaseModel):
id: UUID
@@ -25,6 +36,12 @@ class SessionSuggestedFixResponse(BaseModel):
user_decision: UserDecision | None
superseded_at: datetime | None
created_at: datetime
status: FixStatus
applied_at: datetime | None
verified_at: datetime | None
partial_notes: str | None
failure_reason: str | None
ai_outcome_proposal: dict[str, Any] | None
model_config = {"from_attributes": True}
@@ -71,6 +88,43 @@ class SessionSuggestedFixDecisionResponse(BaseModel):
)
# Subset of FixStatus that the engineer can set via the outcome endpoint —
# `proposed` is excluded because you can't un-decide a fix back to "proposed".
FixOutcome = Literal[
"applied_success", "applied_failed", "applied_partial", "dismissed"
]
class SessionSuggestedFixOutcomeRequest(BaseModel):
"""Engineer-reported outcome of applying a suggested fix.
Writes to session_suggested_fixes.status and companion columns. This is
orthogonal to `user_decision` (which records which script-path the
engineer took); outcome captures whether the fix actually worked.
Allowed transitions:
- from `proposed` or `applied_partial`: any outcome is valid
(partial is parked, not terminal — the engineer may update notes,
abandon via dismiss, or advance to success/failed)
- from any terminal outcome (`applied_success`, `applied_failed`,
`dismissed`): server returns 409
"""
outcome: FixOutcome
# Required for applied_partial, optional for applied_failed, ignored otherwise.
notes: str | None = Field(None, max_length=500)
class SessionSuggestedFixScriptRequest(BaseModel):
"""Engineer-submitted drafted script for a suggested fix.
Called when the inline Script Builder tab's Submit action fires. The
fix must be non-terminal (still proposed/applied_partial). Setting
the script does NOT stamp applied_at — a draft is not an application.
"""
ai_drafted_script: str = Field(..., min_length=1, max_length=50_000)
ai_drafted_parameters: dict[str, Any] | None = None
# ── Resolution note preview ────────────────────────────────────────────────
class ResolutionNotePreviewResponse(BaseModel):

View File

@@ -198,6 +198,44 @@ for the drafted script
The marker is stripped from display — the engineer sees the suggested fix as \
an interactive card with confidence badge, not raw JSON.
## Reporting fix outcome with [FIX_OUTCOME]
When the engineer clearly indicates in chat that a previously proposed fix
worked, didn't work, or was partially applied, emit a [FIX_OUTCOME] marker
on its own lines. This surfaces a "confirm outcome?" banner in the UI — it
does NOT mark the fix resolved on its own; the engineer confirms via the UI.
**When to emit [FIX_OUTCOME]:**
- The engineer states the user's problem is resolved after applying the fix
(affirmative resolution language → outcome="success")
- The engineer states the issue persists after applying the fix
(→ outcome="failure")
- The engineer describes applying only part of the fix
(→ outcome="partial")
**When NOT to emit [FIX_OUTCOME]:**
- The engineer is still verifying (user rebooting, testing, etc.)
- The outcome is ambiguous or inferred rather than stated
- No [SUGGEST_FIX] has been emitted this session
**[FIX_OUTCOME] marker format (one block per response, on its own lines).**
Schema below — DO NOT copy these placeholders into your real response, fill \
each field with content specific to the actual ticket:
[FIX_OUTCOME]
{"fix_id": "<uuid-of-the-active-suggested-fix>",
"outcome": "<success|failure|partial>",
"reason": "<one-line-quote-or-paraphrase-of-what-the-engineer-said>"}
[/FIX_OUTCOME]
- `fix_id`: the UUID of the active suggested fix (provided in session context)
- `outcome`: one of `"success"`, `"failure"`, or `"partial"`
- `reason`: one-line paraphrase of what the engineer said — derived from \
their CURRENT message, not invented
The marker is stripped from display — the engineer sees a "confirm outcome?" \
banner in the UI, not raw JSON.
## Using the Team's Flow Library
Your team has built troubleshooting flows in ResolutionFlow. When relevant flows \
appear in the context below, reference them by name so the engineer can launch them \
@@ -269,6 +307,8 @@ the originating item's `id` into `source_ref` verbatim.
[SUGGEST_FIX] is OPTIONAL — emit one at most per response, only when you have a \
concrete proposed resolution at ~50%+ confidence. A new [SUGGEST_FIX] supersedes \
any prior suggested fix.
[FIX_OUTCOME] is OPTIONAL — emit one at most per response, only when the engineer \
has clearly stated the outcome in their current message.
ANTI-PARROT RULE: The schemas above use placeholders in `<angle brackets>` to show \
the SHAPE of valid output. Your real questions, actions, facts, and suggested fixes \

View File

@@ -55,22 +55,45 @@ header.>
If there are no facts, write "Nothing confirmed yet." and continue.>
## What we've tried
<bulleted list of diagnostic checks run (from the [diagnostic_check] facts) \
and scripts generated during the session. State what each revealed or did, \
not what was attempted without an outcome. If nothing has been tried, write \
"No diagnostic actions run yet." and continue.>
<Bulleted list of diagnostic checks run and scripts generated during the \
session. The content of this section also depends on the outcome recorded for \
the active suggested fix, as given in the input bundle under "Outcome status":>
- applied_failed: List the fix as a tried path. Include the failure reason if \
provided. State that it did not resolve the issue.
- applied_partial: Include the fix as a partially tried path. Include partial \
notes if provided. Indicate it was not fully completed or not verified.
- applied_success: Note that the fix was applied and verified but escalation \
is still needed for another reason (unusual — reflect this accurately).
- dismissed: Do not mention the fix as a tried path; it was only considered.
- proposed (no outcome yet): Do not list it here; it goes in Current hypothesis.
If nothing has been tried at all (no checks, no scripts, no applied/partial \
fix), write "No diagnostic actions run yet." and continue.
## Current hypothesis
<one short paragraph naming the active suggested fix and its confidence. If \
confidence is below 60% or there is no active fix, say so plainly: "No leading \
hypothesis yet — symptoms are still being narrowed.">
<The content depends on the outcome recorded for the active suggested fix:>
- proposed (no outcome yet): State the fix title and confidence. If confidence \
is below 60% or there is no active fix, say "No leading hypothesis yet — \
symptoms are still being narrowed."
- applied_failed or dismissed: Say the proposed fix did not hold or was set \
aside. State any remaining uncertainty.
- applied_partial: Note the partial application and what remains open.
- applied_success: Unusual in an escalate path — state the fix resolved the \
original symptom but a new or related issue requires escalation.
## Suggested next steps
<bulleted list of 2-4 concrete next actions the receiving engineer should \
take. Prefer specifics: commands to run, tickets to check, people to contact. \
Derive from the gap between confirmed facts and a complete resolution. If the \
active suggested fix is high confidence (>80%), the first bullet is "Try the \
suggested fix: <title>.">
Derive from the gap between confirmed facts and a complete resolution. \
If the active suggested fix failed (applied_failed), inform the next steps \
accordingly — e.g. suggest alternatives or deeper investigation paths, \
drawing on the failure reason if provided. \
If the fix is partially applied (applied_partial), the first step is typically \
to complete or verify it. \
If the fix is still proposed (no outcome), the first step is to try it if \
confidence is high (>80%).>
Strict rules:
- Use ONLY the input I provide. Never invent command names, KB articles, or \
@@ -269,6 +292,15 @@ class EscalationPackageGeneratorService:
lines.append(f"Title: {active_fix.title}")
lines.append(f"Confidence: {active_fix.confidence_pct}%")
lines.append(f"Description: {active_fix.description}")
lines.append(f"Outcome status: {active_fix.status}")
if active_fix.applied_at:
lines.append(f"Applied at: {active_fix.applied_at.isoformat()}")
if active_fix.verified_at:
lines.append(f"Verified at: {active_fix.verified_at.isoformat()}")
if active_fix.partial_notes:
lines.append(f"Partial notes: {active_fix.partial_notes}")
if active_fix.failure_reason:
lines.append(f"Failure reason: {active_fix.failure_reason}")
lines.append("")
lines.append(

View File

@@ -69,11 +69,24 @@ say "Root cause not definitively isolated." and explain what is suspected based
on facts.>
## Resolution
<one short paragraph describing the resolution applied. If a script ran during \
the session, mention it (e.g. "Cleared cached credentials via the \
clear-outlook-credentials script."). If no resolution has been performed yet, \
write "Resolution not yet applied — fix proposed: <fix title>." Pull verbatim \
script names and template references when available.>
<The content of this section depends on the outcome recorded for the active \
suggested fix, as given in the input bundle under "fix.status":>
- applied_success: Write in past tense using closure language. State that the \
fix was applied and verified as working. If verified_at is provided, you may \
reference it as the time resolution was confirmed. Example phrasing: \
"Applied <fix title>; confirmed working."
- applied_failed: Acknowledge that the proposed fix did not resolve the issue \
and was discarded. If failure_reason is provided, include it. Then describe \
the actual resolution path taken (derived from facts and scripts run). This \
state means the engineer resolved the issue another way; the note should cover \
that actual resolution, not just the failed attempt.
- applied_partial: Note that the fix was partially applied. If partial_notes \
are provided, include them. Then describe the final resolution path taken.
- dismissed: Treat the fix as considered and set aside. Do not center the note \
on it. Describe the resolution based on what was actually confirmed and done.
- proposed (no outcome yet): Write "Resolution not yet applied — fix proposed: \
<fix title>." Pull verbatim script names and template references when available.
Strict rules:
- Use ONLY the facts and state I provide. Never invent specifics that are not \
@@ -302,6 +315,15 @@ class ResolutionNoteGeneratorService:
lines.append(f"Description: {active_fix.description}")
if active_fix.user_decision:
lines.append(f"Engineer decision: {active_fix.user_decision}")
lines.append(f"Outcome status: {active_fix.status}")
if active_fix.applied_at:
lines.append(f"Applied at: {active_fix.applied_at.isoformat()}")
if active_fix.verified_at:
lines.append(f"Verified at: {active_fix.verified_at.isoformat()}")
if active_fix.partial_notes:
lines.append(f"Partial notes: {active_fix.partial_notes}")
if active_fix.failure_reason:
lines.append(f"Failure reason: {active_fix.failure_reason}")
lines.append("")
lines.append("# Scripts run during the session (passwords redacted)")

View File

@@ -148,6 +148,8 @@ async def create_session(
team_id: UUID | None,
language: str,
initial_prompt: str | None = None,
origin: str = "standalone",
ai_session_id: UUID | None = None,
) -> ScriptBuilderSession:
"""Create a new Script Builder session."""
session = ScriptBuilderSession(
@@ -155,6 +157,8 @@ async def create_session(
account_id=account_id,
team_id=team_id,
language=language,
origin=origin,
ai_session_id=ai_session_id,
)
db.add(session)
await db.flush()
@@ -295,15 +299,22 @@ async def list_sessions(
user_id: UUID,
limit: int = 20,
offset: int = 0,
*,
include_inline: bool = False,
) -> list[ScriptBuilderSession]:
"""List user's builder sessions ordered by updated_at desc."""
result = await db.execute(
"""List user's builder sessions ordered by updated_at desc.
By default (include_inline=False) excludes pilot_inline sessions so the
/script-builder dashboard only shows standalone sessions.
"""
stmt = (
select(ScriptBuilderSession)
.where(ScriptBuilderSession.user_id == user_id)
.order_by(ScriptBuilderSession.updated_at.desc())
.limit(limit)
.offset(offset)
)
if not include_inline:
stmt = stmt.where(ScriptBuilderSession.origin == "standalone")
stmt = stmt.order_by(ScriptBuilderSession.updated_at.desc()).limit(limit).offset(offset)
result = await db.execute(stmt)
return list(result.scalars().all())
@@ -321,13 +332,23 @@ async def delete_session(
return True
async def count_user_sessions(db: AsyncSession, user_id: UUID) -> int:
"""Count active builder sessions for a user."""
result = await db.execute(
select(func.count(ScriptBuilderSession.id)).where(
ScriptBuilderSession.user_id == user_id
)
async def count_user_sessions(
db: AsyncSession,
user_id: UUID,
*,
include_inline: bool = False,
) -> int:
"""Count active builder sessions for a user.
By default (include_inline=False) excludes pilot_inline sessions so they
don't consume slots against the MAX_SESSIONS_PER_USER cap.
"""
stmt = select(func.count(ScriptBuilderSession.id)).where(
ScriptBuilderSession.user_id == user_id
)
if not include_inline:
stmt = stmt.where(ScriptBuilderSession.origin == "standalone")
result = await db.execute(stmt)
return result.scalar_one()

View File

@@ -354,6 +354,56 @@ def _parse_suggest_fix_marker(
return cleaned, parsed
def _parse_fix_outcome_marker(
ai_content: str,
) -> tuple[str, dict[str, Any] | None]:
"""Extract a single [FIX_OUTCOME]...[/FIX_OUTCOME] JSON block.
Block shape:
{"fix_id": "<uuid>", "outcome": "success"|"failure"|"partial",
"reason": "<one-line>"}
Emitted by the AI when the engineer clearly indicates in chat that a
prior suggested fix worked, didn't work, or was partially applied.
The marker PROPOSES an outcome — the engineer confirms via the UI.
Only the last block in a response is honored.
"""
blocks = list(re.finditer(
r"\[FIX_OUTCOME\]\s*([\s\S]*?)\s*\[/FIX_OUTCOME\]", ai_content,
))
if not blocks:
return ai_content, None
last = blocks[-1]
raw = last.group(1).strip()
if raw.startswith("```"):
raw = re.sub(r"^```(?:json)?\s*", "", raw)
raw = re.sub(r"\s*```$", "", raw)
cleaned = re.sub(
r"\[FIX_OUTCOME\]\s*[\s\S]*?\s*\[/FIX_OUTCOME\]", "", ai_content,
).strip()
try:
data = json.loads(raw)
except (json.JSONDecodeError, ValueError) as e:
logger.warning("Failed to parse [FIX_OUTCOME] block: %s", e)
return cleaned, None
if not isinstance(data, dict):
return cleaned, None
fix_id = str(data.get("fix_id") or "").strip()
outcome = str(data.get("outcome") or "").strip().lower()
reason = str(data.get("reason") or "").strip()
if not fix_id or outcome not in {"success", "failure", "partial"}:
logger.warning("[FIX_OUTCOME] missing/invalid fields, dropping")
return cleaned, None
return cleaned, {"fix_id": fix_id, "outcome": outcome, "reason": reason}
async def _persist_suggested_fix(
*,
db: AsyncSession,
@@ -415,6 +465,39 @@ async def _persist_suggested_fix(
await db.flush()
async def _record_ai_outcome_proposal(
*,
db: AsyncSession,
session: AISession,
proposal: dict[str, Any],
) -> None:
"""Persist the AI's proposed outcome on the active fix.
Writes to session_suggested_fixes.ai_outcome_proposal. Frontend polls
the active fix and renders the AI-confirming banner state when this is
non-null. Does NOT mutate the fix's status — the engineer's confirmation
click via PATCH /outcome is what changes the status.
Drops silently when the fix_id isn't a valid UUID or doesn't belong to
this session.
"""
try:
fix_uuid = UUID(proposal["fix_id"])
except (ValueError, KeyError, TypeError):
logger.warning("[FIX_OUTCOME] invalid fix_id, dropping")
return
await db.execute(
update(SessionSuggestedFix)
.where(
SessionSuggestedFix.id == fix_uuid,
SessionSuggestedFix.session_id == session.id,
)
.values(ai_outcome_proposal=proposal)
)
await db.flush()
async def _persist_promote_items(
*,
db: AsyncSession,
@@ -566,6 +649,7 @@ async def send_chat_message(
branch_display, branch_questions_data = _parse_questions_marker(branch_display)
branch_display, branch_promote_items = _parse_promote_marker(branch_display)
branch_display, branch_suggest_fix = _parse_suggest_fix_marker(branch_display)
branch_display, branch_outcome_proposal = _parse_fix_outcome_marker(branch_display)
if branch_display != ai_content:
# Store stripped content in branch history
msgs[-1] = {"role": "assistant", "content": branch_display}
@@ -629,6 +713,12 @@ async def send_chat_message(
db=db, session=session, fix=branch_suggest_fix,
)
# Persist a [FIX_OUTCOME] proposal if the branch turn included one.
if branch_outcome_proposal is not None:
await _record_ai_outcome_proposal(
db=db, session=session, proposal=branch_outcome_proposal,
)
suggested_flows = extract_suggested_flows(
await rag_search(query=message, account_id=account_id, db=db, limit=8)
)
@@ -681,11 +771,16 @@ async def send_chat_message(
# Check for a [SUGGEST_FIX] marker — supersedes the prior active fix.
display_content, suggest_fix_data = _parse_suggest_fix_marker(display_content)
# Check for a [FIX_OUTCOME] proposal — AI confirms a prior fix's outcome.
display_content, outcome_proposal = _parse_fix_outcome_marker(display_content)
logger.info(
"Marker parsing results — actions: %s, questions: %s, fork: %s, "
"promote: %d, suggest_fix: %s, raw_length: %d, display_length: %d",
"promote: %d, suggest_fix: %s, outcome_proposal: %s, "
"raw_length: %d, display_length: %d",
bool(actions_data), bool(questions_data), bool(fork_data),
len(promote_items or []), bool(suggest_fix_data),
bool(outcome_proposal),
len(ai_content), len(display_content),
)
@@ -774,6 +869,12 @@ async def send_chat_message(
if suggest_fix_data:
await _persist_suggested_fix(db=db, session=session, fix=suggest_fix_data)
# Persist a [FIX_OUTCOME] proposal if this turn included one.
if outcome_proposal is not None:
await _record_ai_outcome_proposal(
db=db, session=session, proposal=outcome_proposal,
)
suggested_flows = extract_suggested_flows(rag_results)
return display_content, suggested_flows, session, fork_metadata, actions_data, questions_data

View File

@@ -0,0 +1,536 @@
"""Integration tests for PATCH /ai-sessions/{sid}/suggested-fixes/{fid}/outcome.
Fixture style follows test_session_suggested_fixes_api.py:
client, test_user, auth_headers, test_db
"""
from __future__ import annotations
from unittest.mock import AsyncMock, call, patch
import pytest
from httpx import AsyncClient
from sqlalchemy import select
from app.api.endpoints.session_suggested_fixes import _clear_preview_cache_for_tests
from app.models.ai_session import AISession
from app.models.session_suggested_fix import SessionSuggestedFix
@pytest.fixture(autouse=True)
def _isolate_preview_cache():
_clear_preview_cache_for_tests()
yield
_clear_preview_cache_for_tests()
# ── shared helper ────────────────────────────────────────────────────────────
async def _make_session_with_fix(test_db, user) -> tuple[str, str]:
"""Create an AISession + active proposed SessionSuggestedFix.
Returns (session_id_str, fix_id_str).
"""
session = AISession(
user_id=user["user_data"]["id"],
account_id=user["user_data"]["account_id"],
session_type="chat",
intake_type="free_text",
intake_content={"text": "outcome test"},
status="active",
confidence_tier="discovery",
conversation_messages=[],
)
test_db.add(session)
await test_db.flush()
fix = SessionSuggestedFix(
session_id=session.id,
account_id=session.account_id,
title="Reset credential cache",
description="Clear stale credentials from the domain cache.",
confidence_pct=82,
)
test_db.add(fix)
await test_db.commit()
await test_db.refresh(fix)
return str(session.id), str(fix.id)
# ── tests ────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_patch_outcome_marks_success(
client: AsyncClient, test_user, auth_headers, test_db
):
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
r = await client.patch(
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
headers=auth_headers,
json={"outcome": "applied_success"},
)
assert r.status_code == 200, r.text
body = r.json()
assert body["status"] == "applied_success"
assert body["verified_at"] is not None
@pytest.mark.asyncio
async def test_patch_outcome_partial_requires_notes(
client: AsyncClient, test_user, auth_headers, test_db
):
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
r = await client.patch(
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
headers=auth_headers,
json={"outcome": "applied_partial"},
)
assert r.status_code == 400
assert "notes" in r.text.lower()
@pytest.mark.asyncio
async def test_partial_to_success_allowed(
client: AsyncClient, test_user, auth_headers, test_db
):
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
r1 = await client.patch(
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
headers=auth_headers,
json={"outcome": "applied_partial", "notes": "ran cred clear only"},
)
assert r1.status_code == 200, r1.text
r2 = await client.patch(
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
headers=auth_headers,
json={"outcome": "applied_success"},
)
assert r2.status_code == 200
assert r2.json()["status"] == "applied_success"
@pytest.mark.asyncio
async def test_terminal_outcome_is_locked(
client: AsyncClient, test_user, auth_headers, test_db
):
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
r1 = await client.patch(
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
headers=auth_headers,
json={"outcome": "applied_failed", "notes": "no change"},
)
assert r1.status_code == 200
r2 = await client.patch(
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
headers=auth_headers,
json={"outcome": "applied_success"},
)
assert r2.status_code == 409
@pytest.mark.asyncio
async def test_partial_notes_can_be_updated(
client: AsyncClient, test_user, auth_headers, test_db
):
"""partial→partial with new notes updates the stored notes."""
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
r1 = await client.patch(
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
json={"outcome": "applied_partial", "notes": "ran cred clear only"},
headers=auth_headers,
)
assert r1.status_code == 200
assert r1.json()["partial_notes"] == "ran cred clear only"
r2 = await client.patch(
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
json={"outcome": "applied_partial", "notes": "also finished the rebuild; not verified yet"},
headers=auth_headers,
)
assert r2.status_code == 200
assert r2.json()["partial_notes"] == "also finished the rebuild; not verified yet"
@pytest.mark.asyncio
async def test_dismissed_sets_no_timestamps(
client: AsyncClient, test_user, auth_headers, test_db
):
"""dismissed outcome does not stamp applied_at or verified_at."""
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
r = await client.patch(
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
json={"outcome": "dismissed"},
headers=auth_headers,
)
assert r.status_code == 200
body = r.json()
assert body["status"] == "dismissed"
assert body["applied_at"] is None
assert body["verified_at"] is None
@pytest.mark.asyncio
async def test_applied_at_auto_stamped_on_first_outcome(
client: AsyncClient, test_user, auth_headers, test_db
):
"""If applied_at is null when the engineer sets outcome, server stamps it."""
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
r = await client.patch(
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
json={"outcome": "applied_success"},
headers=auth_headers,
)
assert r.status_code == 200
body = r.json()
assert body["applied_at"] is not None
assert body["verified_at"] is not None
@pytest.mark.asyncio
async def test_failed_outcome_stores_notes_as_failure_reason(
client: AsyncClient, test_user, auth_headers, test_db
):
"""applied_failed stores notes under failure_reason (not partial_notes)."""
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
r = await client.patch(
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
json={"outcome": "applied_failed", "notes": "user reports no change"},
headers=auth_headers,
)
assert r.status_code == 200
body = r.json()
assert body["failure_reason"] == "user reports no change"
assert body["partial_notes"] is None
# ── state_version bump ────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_outcome_patch_bumps_state_version(
client: AsyncClient, test_user, auth_headers, test_db
):
"""PATCH /outcome must increment ai_sessions.state_version (like record_decision)."""
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
# Capture the initial state_version from DB.
from uuid import UUID
result = await test_db.execute(
select(AISession).where(AISession.id == UUID(session_id))
)
session_obj = result.scalar_one()
initial_version = session_obj.state_version
r = await client.patch(
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
json={"outcome": "applied_success"},
headers=auth_headers,
)
assert r.status_code == 200
await test_db.refresh(session_obj)
assert session_obj.state_version == initial_version + 1, (
"Outcome patch must bump state_version so preview cache is invalidated"
)
# ── outcome propagation into preview bundle ───────────────────────────────────
@pytest.mark.asyncio
async def test_resolution_note_preview_reflects_outcome_after_patch(
client: AsyncClient, test_user, auth_headers, test_db
):
"""End-to-end: preview before outcome != preview after outcome; new preview
bundle includes failure_reason; state_version was bumped between the two.
The LLM is stubbed so the test is deterministic. The stub returns whatever
the user-message content is, which means the captured call args reflect
what the bundle actually contained.
"""
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
distinct_failure_reason = "DISTINCT-FAILURE-REASON-XYZZY-42"
calls_made: list[str] = []
async def fake_generate_text(system_prompt, messages, max_tokens):
user_content = messages[0]["content"]
calls_made.append(user_content)
# Return markdown that includes the user-message bundle verbatim so we
# can assert the bundle shape without inspecting mock internals.
return (
f"## Problem\ntest\n\n## What we confirmed\n(none)\n\n"
f"## Root cause\ntest\n\n## Resolution\nBUNDLE_CONTENT={user_content}",
100,
50,
)
fake_provider = AsyncMock()
fake_provider.generate_text = AsyncMock(side_effect=fake_generate_text)
with patch(
"app.services.resolution_note_generator.get_ai_provider",
return_value=fake_provider,
):
# Preview A — before any outcome recorded (status = "proposed").
r_a = await client.post(
f"/api/v1/ai-sessions/{session_id}/resolution-note/preview",
headers=auth_headers,
)
assert r_a.status_code == 200
markdown_a = r_a.json()["markdown"]
version_a = r_a.json()["state_version"]
assert r_a.json()["from_cache"] is False
# Record an applied_failed outcome with a distinctive reason.
r_patch = await client.patch(
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
json={"outcome": "applied_failed", "notes": distinct_failure_reason},
headers=auth_headers,
)
assert r_patch.status_code == 200
# Preview B — must be a cache miss because state_version changed.
r_b = await client.post(
f"/api/v1/ai-sessions/{session_id}/resolution-note/preview",
headers=auth_headers,
)
assert r_b.status_code == 200
markdown_b = r_b.json()["markdown"]
version_b = r_b.json()["state_version"]
assert r_b.json()["from_cache"] is False, (
"Preview after outcome patch must be a cache miss (state_version changed)"
)
# State version increased between the two previews.
assert version_b > version_a, (
f"state_version should have increased; got {version_a}{version_b}"
)
# Markdown differs between the two previews.
assert markdown_a != markdown_b, (
"Regenerated preview after outcome patch should differ from pre-outcome preview"
)
# The bundle passed to the LLM for preview B includes the outcome fields.
assert len(calls_made) == 2, f"Expected 2 LLM calls (one per preview); got {len(calls_made)}"
bundle_b = calls_made[1]
assert "applied_failed" in bundle_b, (
"Bundle for second preview should include 'Outcome status: applied_failed'"
)
assert distinct_failure_reason in bundle_b, (
"Bundle for second preview should include the failure_reason text"
)
# ── Apply endpoint ─────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_apply_stamps_applied_at(
client: AsyncClient, test_user, auth_headers, test_db
):
"""POST /apply stamps applied_at and bumps state_version."""
from uuid import UUID
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
result = await test_db.execute(
select(AISession).where(AISession.id == UUID(session_id))
)
session_obj = result.scalar_one()
initial_version = session_obj.state_version
r = await client.post(
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/apply",
headers=auth_headers,
)
assert r.status_code == 200, r.text
body = r.json()
assert body["applied_at"] is not None, "applied_at must be set after /apply"
assert body["status"] == "proposed", "status must remain 'proposed' after /apply"
await test_db.refresh(session_obj)
assert session_obj.state_version == initial_version + 1, (
"/apply must bump state_version so preview cache is invalidated"
)
@pytest.mark.asyncio
async def test_apply_is_idempotent(
client: AsyncClient, test_user, auth_headers, test_db
):
"""Second POST /apply returns 200 with applied_at unchanged (no double-bump)."""
from uuid import UUID
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
r1 = await client.post(
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/apply",
headers=auth_headers,
)
assert r1.status_code == 200, r1.text
applied_at_first = r1.json()["applied_at"]
result = await test_db.execute(
select(AISession).where(AISession.id == UUID(session_id))
)
session_obj = result.scalar_one()
version_after_first = session_obj.state_version
r2 = await client.post(
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/apply",
headers=auth_headers,
)
assert r2.status_code == 200, r2.text
assert r2.json()["applied_at"] == applied_at_first, (
"applied_at must not change on second /apply call"
)
await test_db.refresh(session_obj)
assert session_obj.state_version == version_after_first, (
"state_version must not be bumped a second time on idempotent /apply"
)
@pytest.mark.asyncio
async def test_apply_rejects_non_proposed(
client: AsyncClient, test_user, auth_headers, test_db
):
"""POST /apply returns 409 when fix status is 'applied_success'."""
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
# Advance the fix to a terminal status via the outcome endpoint.
r_outcome = await client.patch(
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
headers=auth_headers,
json={"outcome": "applied_success"},
)
assert r_outcome.status_code == 200
r = await client.post(
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/apply",
headers=auth_headers,
)
assert r.status_code == 409, r.text
@pytest.mark.asyncio
async def test_apply_rejects_dismissed(
client: AsyncClient, test_user, auth_headers, test_db
):
"""POST /apply returns 409 when fix status is 'dismissed'."""
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
r_outcome = await client.patch(
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
headers=auth_headers,
json={"outcome": "dismissed"},
)
assert r_outcome.status_code == 200
r = await client.post(
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/apply",
headers=auth_headers,
)
assert r.status_code == 409, r.text
# ── AI outcome proposal: clear / reject ───────────────────────────────────────
async def _make_session_with_fix_and_proposal(test_db, user) -> tuple[str, str]:
"""Create an AISession + fix with a populated ai_outcome_proposal."""
from uuid import UUID as _UUID
session = AISession(
user_id=user["user_data"]["id"],
account_id=user["user_data"]["account_id"],
session_type="chat",
intake_type="free_text",
intake_content={"text": "proposal clear test"},
status="active",
confidence_tier="discovery",
conversation_messages=[],
)
test_db.add(session)
await test_db.flush()
fix = SessionSuggestedFix(
session_id=session.id,
account_id=session.account_id,
title="Flush DNS cache",
description="Run ipconfig /flushdns on the affected host.",
confidence_pct=74,
ai_outcome_proposal={"fix_id": str(session.id), "outcome": "success", "reason": "User confirmed resolved"},
)
test_db.add(fix)
await test_db.commit()
await test_db.refresh(fix)
return str(session.id), str(fix.id)
@pytest.mark.asyncio
async def test_outcome_patch_clears_ai_proposal(
client: AsyncClient, test_user, auth_headers, test_db
):
"""PATCH /outcome clears ai_outcome_proposal regardless of which outcome is written."""
session_id, fix_id = await _make_session_with_fix_and_proposal(test_db, test_user)
# Verify the proposal is set before the patch.
from uuid import UUID
result = await test_db.execute(
select(SessionSuggestedFix).where(SessionSuggestedFix.id == UUID(fix_id))
)
fix_before = result.scalar_one()
assert fix_before.ai_outcome_proposal is not None
r = await client.patch(
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
headers=auth_headers,
json={"outcome": "applied_success"},
)
assert r.status_code == 200, r.text
body = r.json()
assert body["ai_outcome_proposal"] is None, (
"PATCH /outcome must clear ai_outcome_proposal on any terminal action"
)
@pytest.mark.asyncio
async def test_delete_ai_proposal_clears_field(
client: AsyncClient, test_user, auth_headers, test_db
):
"""DELETE /ai-outcome-proposal clears the field without changing status."""
session_id, fix_id = await _make_session_with_fix_and_proposal(test_db, test_user)
r = await client.delete(
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/ai-outcome-proposal",
headers=auth_headers,
)
assert r.status_code == 200, r.text
body = r.json()
assert body["ai_outcome_proposal"] is None, (
"DELETE /ai-outcome-proposal must clear the field"
)
assert body["status"] == "proposed", (
"DELETE /ai-outcome-proposal must not change fix status"
)
@pytest.mark.asyncio
async def test_delete_ai_proposal_when_none_is_idempotent(
client: AsyncClient, test_user, auth_headers, test_db
):
"""DELETE /ai-outcome-proposal returns 200 even when the field is already null."""
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
# Fix created by _make_session_with_fix has ai_outcome_proposal=None.
r = await client.delete(
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/ai-outcome-proposal",
headers=auth_headers,
)
assert r.status_code == 200, r.text
assert r.json()["ai_outcome_proposal"] is None

View File

@@ -0,0 +1,91 @@
"""Unit tests for the [FIX_OUTCOME] marker parser."""
from __future__ import annotations
from app.services.unified_chat_service import _parse_fix_outcome_marker
def test_parses_success_outcome():
ai = (
"Great news — that confirms the root cause.\n\n"
"[FIX_OUTCOME]\n"
'{"fix_id":"11111111-1111-1111-1111-111111111111",'
'"outcome":"success","reason":"user said the fix worked"}\n'
"[/FIX_OUTCOME]\n"
)
cleaned, parsed = _parse_fix_outcome_marker(ai)
assert "[FIX_OUTCOME]" not in cleaned
assert "confirms the root cause" in cleaned
assert parsed == {
"fix_id": "11111111-1111-1111-1111-111111111111",
"outcome": "success",
"reason": "user said the fix worked",
}
def test_parses_failure_outcome():
ai = (
"[FIX_OUTCOME]\n"
'{"fix_id":"22222222-2222-2222-2222-222222222222",'
'"outcome":"failure","reason":"user reports still broken"}\n'
"[/FIX_OUTCOME]"
)
cleaned, parsed = _parse_fix_outcome_marker(ai)
assert "[FIX_OUTCOME]" not in cleaned
assert parsed["outcome"] == "failure"
def test_missing_marker_returns_none():
ai = "no marker here"
cleaned, parsed = _parse_fix_outcome_marker(ai)
assert cleaned == ai
assert parsed is None
def test_invalid_json_is_dropped():
ai = "[FIX_OUTCOME]\nnot-json\n[/FIX_OUTCOME]"
cleaned, parsed = _parse_fix_outcome_marker(ai)
assert "[FIX_OUTCOME]" not in cleaned
assert parsed is None
def test_unknown_outcome_rejected():
ai = (
"[FIX_OUTCOME]\n"
'{"fix_id":"33333333-3333-3333-3333-333333333333",'
'"outcome":"maybe","reason":"x"}\n'
"[/FIX_OUTCOME]"
)
_, parsed = _parse_fix_outcome_marker(ai)
assert parsed is None
def test_last_block_wins_when_multiple():
ai = (
"[FIX_OUTCOME]\n"
'{"fix_id":"44444444-4444-4444-4444-444444444444",'
'"outcome":"failure","reason":"first"}\n'
"[/FIX_OUTCOME]\n"
"[FIX_OUTCOME]\n"
'{"fix_id":"55555555-5555-5555-5555-555555555555",'
'"outcome":"success","reason":"second"}\n'
"[/FIX_OUTCOME]"
)
cleaned, parsed = _parse_fix_outcome_marker(ai)
assert "[FIX_OUTCOME]" not in cleaned
assert parsed["fix_id"] == "55555555-5555-5555-5555-555555555555"
assert parsed["outcome"] == "success"
def test_parses_partial_outcome():
ai = (
"[FIX_OUTCOME]\n"
'{"fix_id":"66666666-6666-6666-6666-666666666666",'
'"outcome":"partial","reason":"user ran cred clear only"}\n'
"[/FIX_OUTCOME]"
)
_, parsed = _parse_fix_outcome_marker(ai)
assert parsed == {
"fix_id": "66666666-6666-6666-6666-666666666666",
"outcome": "partial",
"reason": "user ran cred clear only",
}

View File

@@ -0,0 +1,120 @@
"""Integration tests for PATCH /ai-sessions/{sid}/suggested-fixes/{fid}/script."""
from __future__ import annotations
import pytest
from httpx import AsyncClient
from sqlalchemy import select
from uuid import UUID, uuid4
from app.models.ai_session import AISession
from app.models.session_suggested_fix import SessionSuggestedFix
async def _make_session_with_fix(
test_db, user, *, status: str = "proposed", with_script: bool = False,
) -> tuple[str, str]:
"""Create a pilot session + suggested fix for tests. Returns (sid, fid)."""
session = AISession(
id=uuid4(),
user_id=user["user_data"]["id"],
account_id=user["user_data"]["account_id"],
session_type="tshoot",
intake_type="psa_ticket",
intake_content={},
title="QA",
status="active",
confidence_tier="exploring",
confidence_score=0.0,
)
test_db.add(session)
await test_db.flush()
fix = SessionSuggestedFix(
id=uuid4(),
session_id=session.id,
account_id=user["user_data"]["account_id"],
title="QA: test fix",
description="desc",
confidence_pct=80,
status=status,
ai_drafted_script="pre-existing" if with_script else None,
)
test_db.add(fix)
await test_db.commit()
return str(session.id), str(fix.id)
@pytest.mark.asyncio
async def test_patch_script_happy_path(
client: AsyncClient, test_user, auth_headers, test_db
):
sid, fid = await _make_session_with_fix(test_db, test_user)
r = await client.patch(
f"/api/v1/ai-sessions/{sid}/suggested-fixes/{fid}/script",
json={"ai_drafted_script": "Write-Host 'hello'"},
headers=auth_headers,
)
assert r.status_code == 200, r.text
body = r.json()
assert body["ai_drafted_script"] == "Write-Host 'hello'"
assert body["applied_at"] is None # draft != apply
assert body["status"] == "proposed"
@pytest.mark.asyncio
async def test_patch_script_bumps_state_version(
client: AsyncClient, test_user, auth_headers, test_db
):
sid, fid = await _make_session_with_fix(test_db, test_user)
before = await test_db.scalar(
select(AISession.state_version).where(AISession.id == UUID(sid))
)
r = await client.patch(
f"/api/v1/ai-sessions/{sid}/suggested-fixes/{fid}/script",
json={"ai_drafted_script": "echo hi"},
headers=auth_headers,
)
assert r.status_code == 200
after = await test_db.scalar(
select(AISession.state_version).where(AISession.id == UUID(sid))
)
assert after == (before or 0) + 1
@pytest.mark.asyncio
async def test_patch_script_rejects_terminal_fix(
client: AsyncClient, test_user, auth_headers, test_db
):
sid, fid = await _make_session_with_fix(test_db, test_user, status="applied_success")
r = await client.patch(
f"/api/v1/ai-sessions/{sid}/suggested-fixes/{fid}/script",
json={"ai_drafted_script": "echo hi"},
headers=auth_headers,
)
assert r.status_code == 409
@pytest.mark.asyncio
async def test_patch_script_rejects_empty_body(
client: AsyncClient, test_user, auth_headers, test_db
):
sid, fid = await _make_session_with_fix(test_db, test_user)
r = await client.patch(
f"/api/v1/ai-sessions/{sid}/suggested-fixes/{fid}/script",
json={"ai_drafted_script": ""},
headers=auth_headers,
)
assert r.status_code == 422 # pydantic min_length=1
@pytest.mark.asyncio
async def test_patch_script_404_on_wrong_session(
client: AsyncClient, test_user, auth_headers, test_db
):
_, fid = await _make_session_with_fix(test_db, test_user)
wrong_sid = str(uuid4())
r = await client.patch(
f"/api/v1/ai-sessions/{wrong_sid}/suggested-fixes/{fid}/script",
json={"ai_drafted_script": "echo hi"},
headers=auth_headers,
)
assert r.status_code == 404

View File

@@ -87,7 +87,7 @@ _FORBIDDEN_LITERAL_TOKENS: tuple[str, ...] = (
# so prose blocks (like the closing-tag-distance regex match across
# markdown headings) are excluded
_MARKER_BLOCK_RE = re.compile(
r"(?:^|\n)\[(QUESTIONS|ACTIONS|SUGGEST_FIX|PROMOTE|FORK|TREE_UPDATE|STEPS_UPDATE|INTAKE_FORM|METADATA|DELTA)\]"
r"(?:^|\n)\[(QUESTIONS|ACTIONS|SUGGEST_FIX|FIX_OUTCOME|PROMOTE|FORK|TREE_UPDATE|STEPS_UPDATE|INTAKE_FORM|METADATA|DELTA)\]"
r"\s*\n" # forced newline before content
r"(\s*[\[{][\s\S]*?)" # content must start with [ or {
r"\s*\n\[/\1\]"

View File

@@ -0,0 +1,176 @@
"""Integration tests for inline pilot_inline script_builder_session behavior.
Covers:
- Idempotent get-or-create for (user, ai_session_id) on origin='pilot_inline'
- Authorization: ai_session_id must belong to current user
- list_sessions + count_user_sessions default-scope to 'standalone'
"""
from __future__ import annotations
import pytest
from httpx import AsyncClient
from sqlalchemy import select, func
from uuid import uuid4
from app.models.ai_session import AISession
from app.models.script_builder_session import ScriptBuilderSession
async def _make_pilot_session(test_db, user) -> str:
"""Helper: create a minimal pilot session owned by `user`.
Matches the existing pattern used by test_fix_outcome_endpoint.py.
`user` is the dict returned by the test_user fixture:
{"email": ..., "password": ..., "user_data": {"id": ..., "account_id": ..., ...}}
"""
user_id = user["user_data"]["id"]
account_id = user["user_data"]["account_id"]
session = AISession(
id=uuid4(), user_id=user_id, account_id=account_id,
session_type="tshoot", intake_type="psa_ticket",
intake_content={}, title="QA",
status="active", confidence_tier="exploring", confidence_score=0.0,
)
test_db.add(session)
await test_db.commit()
return str(session.id)
@pytest.mark.asyncio
async def test_inline_create_is_idempotent(
client: AsyncClient, test_user, auth_headers, test_db
):
"""Second create with same (user, ai_session_id) returns the existing row."""
ai_session_id = await _make_pilot_session(test_db, test_user)
r1 = await client.post(
"/api/v1/scripts/builder/sessions",
json={"language": "powershell", "origin": "pilot_inline",
"ai_session_id": ai_session_id},
headers=auth_headers,
)
assert r1.status_code in (200, 201), r1.text
first_id = r1.json()["id"]
r2 = await client.post(
"/api/v1/scripts/builder/sessions",
json={"language": "powershell", "origin": "pilot_inline",
"ai_session_id": ai_session_id},
headers=auth_headers,
)
assert r2.status_code in (200, 201)
assert r2.json()["id"] == first_id
# DB confirms only one row
row_count = await test_db.scalar(
select(func.count()).select_from(ScriptBuilderSession).where(
ScriptBuilderSession.user_id == test_user["user_data"]["id"],
ScriptBuilderSession.origin == "pilot_inline",
)
)
assert row_count == 1
@pytest.mark.asyncio
async def test_inline_requires_ai_session_id(
client: AsyncClient, auth_headers
):
"""origin='pilot_inline' without ai_session_id is rejected."""
r = await client.post(
"/api/v1/scripts/builder/sessions",
json={"language": "powershell", "origin": "pilot_inline"},
headers=auth_headers,
)
assert r.status_code == 400
assert "ai_session_id" in r.text.lower()
@pytest.mark.asyncio
async def test_inline_ai_session_must_belong_to_caller(
client: AsyncClient, test_user, auth_headers, test_db
):
"""ai_session_id pointing at another user's session is rejected."""
# Create pilot session owned by a DIFFERENT user
from app.models.user import User
from app.models.account import Account
other_account = Account(id=uuid4(), name="other", display_code="OTH-0001")
test_db.add(other_account)
await test_db.flush()
other_user = User(
id=uuid4(), email="other@example.com",
password_hash="x", name="Other", role="engineer",
is_super_admin=False, is_team_admin=False, is_active=True,
is_service_account=False, must_change_password=False,
account_id=other_account.id, account_role="engineer",
)
test_db.add(other_user)
await test_db.flush()
# Build user dict in the same shape as the test_user fixture
other_user_dict = {
"user_data": {"id": str(other_user.id), "account_id": str(other_account.id)}
}
other_session_id = await _make_pilot_session(test_db, other_user_dict)
r = await client.post(
"/api/v1/scripts/builder/sessions",
json={"language": "powershell", "origin": "pilot_inline",
"ai_session_id": other_session_id},
headers=auth_headers,
)
assert r.status_code in (403, 404), r.text
@pytest.mark.asyncio
async def test_list_sessions_excludes_inline(
client: AsyncClient, test_user, auth_headers, test_db
):
"""GET /scripts/builder/sessions returns only standalone rows."""
ai_session_id = await _make_pilot_session(test_db, test_user)
# Create one inline session
await client.post(
"/api/v1/scripts/builder/sessions",
json={"language": "powershell", "origin": "pilot_inline",
"ai_session_id": ai_session_id},
headers=auth_headers,
)
# Create one standalone session
await client.post(
"/api/v1/scripts/builder/sessions",
json={"language": "powershell"},
headers=auth_headers,
)
r = await client.get("/api/v1/scripts/builder/sessions", headers=auth_headers)
assert r.status_code == 200
body = r.json()
# Depending on response shape, this may be a list or {"sessions": [...]}.
items = body if isinstance(body, list) else body.get("sessions", body.get("items", []))
# Response schema does not surface `origin`; len==1 is the only meaningful guard:
# inline row would push this to 2.
assert len(items) == 1
@pytest.mark.asyncio
async def test_inline_sessions_do_not_count_against_cap(
client: AsyncClient, test_user, auth_headers, test_db
):
"""Creating 5 pilot_inline sessions does not block a subsequent standalone."""
# Create 5 distinct pilot sessions and attach inline builder sessions to each
for _ in range(5):
ai_session_id = await _make_pilot_session(test_db, test_user)
r = await client.post(
"/api/v1/scripts/builder/sessions",
json={"language": "powershell", "origin": "pilot_inline",
"ai_session_id": ai_session_id},
headers=auth_headers,
)
assert r.status_code in (200, 201), r.text
# A standalone create should still succeed — inline sessions don't count
r = await client.post(
"/api/v1/scripts/builder/sessions",
json={"language": "powershell"},
headers=auth_headers,
)
assert r.status_code in (200, 201), r.text

View File

@@ -2,8 +2,8 @@
> **Target:** Transform `/assistant` (ResolutionAssist) into the new unified `/pilot` (FlowPilot) surface.
> **Audience:** Claude Code (implementation) and Codex (review) reviewed by Michael (owner).
> **Status:** Phases 07 implemented. Phase 7 delivered polish: fact-synthesis loading indicator in `WhatWeKnow`, "thinking" pip in the task-lane header, quiet-state hint when questions/checks/fix are all absent, keyboard shortcuts (`⌘K` palette already present, `⌘↵` send, `⌘G` toggle script panel, `?` help overlay), and responsive bottom-drawer lane on viewports <1200px with a floating "Tasks" toggle. `tsc -b` and `npm run build` both clean.
> **Last updated:** April 22, 2026 (Phase 6post-resolve TemplatizePrompt — committed; draft accept → script_templates promotion with provenance verified live)
> **Status:** Phases 09 implemented. Phase 9 shipped the tabbed Script Builder integration (chat-region tab strip, `ScriptBuilderTab` controller with AI + Monaco editor modes, `InlineNoTemplateDialog` chat-region relocation, `PATCH /script` endpoint, `origin` discriminator migration reusing the existing `ai_session_id` FK, `applied_at` semantics correction, and `EscalateInterceptDialog` fourth "partial" choice). `tsc -b` and `npm run build` both clean.
> **Last updated:** April 24, 2026 (Phase 9Tabbed Script Builder — committed; handoff and migration spec updated)
---
@@ -891,6 +891,56 @@ git commit -m "feat(pilot): add post-resolve templatize prompt for draft templat
git commit -m "feat(pilot): visual polish, empty/loading states, keyboard shortcuts"
```
### Phase 8 — Fix Outcome Banner
**Plan and rationale:** [phase-8-fix-outcome-banner.md](phase-8-fix-outcome-banner.md)
**Mockups:** [mockups/06-slide-up-banner.html](mockups/06-slide-up-banner.html), [mockups/07-verify-states.html](mockups/07-verify-states.html)
**What this phase does:** Removes the `SuggestedFix` card as the primary interaction point for fix application. Replaces it with a chat-composer-anchored slide-up banner (`ProposalBanner`) that stays visible at the bottom of the conversation column regardless of task-lane scroll depth. Addresses the user-reported discoverability problem: *"the task lane fills up pretty quick … the suggested fix … is easily missed."*
**Key backend additions:**
- Six new columns on `session_suggested_fixes`: `status`, `applied_at`, `verified_at`, `partial_notes`, `failure_reason`, `ai_outcome_proposal`
- `PATCH /api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome` endpoint to record the engineer's decision
- `[FIX_OUTCOME]` marker in the FlowPilot system prompt, parsed by `unified_chat_service.py` to trigger the banner
**Key frontend additions:**
- `ProposalBanner` component (`frontend/src/components/pilot/ProposalBanner.tsx`) — slide-up banner anchored above the chat composer; shows fix title, confidence, and Accept / Dismiss / Escalate actions; auto-collapses after session resolves
- `EscalateInterceptDialog` — intercepts the Escalate action when a fix proposal is active, asking whether the engineer wants to note that the fix was attempted before escalating
**Commit range:** `cdd8bb0` (Phase 8 Task 1 start) through `8582d24`
```
git commit -m "feat(pilot): Phase 8 — fix outcome banner replaces task-lane SuggestedFix CTA"
```
### Phase 9 — Tabbed Script Builder
**Spec:** [phase-9-script-builder-tab.md](phase-9-script-builder-tab.md)
**Implementation plan:** [phase-9-implementation-plan.md](phase-9-implementation-plan.md)
**What this phase does:** Resolves open items #1 (NoTemplateDialog narrow-lane bug) and #3 (Tabbed Script Builder) from the Phase 6/7 backlog. The chat region gains a `[Chat] [Script Builder ●]` tab strip (`ChatTabStrip` + a new `ScriptBuilderTab` controller) that hosts two modes: an AI path reusing the existing (untouched) `ScriptBuilderChat`, and a "Write it myself" path using `ScriptBodyEditor` (Monaco). Engineer submit writes the drafted script back to `session_suggested_fixes.ai_drafted_script` via a new PATCH endpoint — `applied_at` is NOT stamped (a draft is not an application). Tabs use `display: none` toggling so chat scroll position, draft message, AI history, and Monaco buffer are all preserved across switches. `InlineNoTemplateDialog` is relocated from the task-lane `bottomSlot` into a dedicated chat-region placement wrapper, eliminating the narrow-lane viewport-breakpoint collision that made the three-option grid unusable.
**Key backend additions:**
- `PATCH /api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/script` — writes `ai_drafted_script` + `ai_drafted_parameters` without stamping `applied_at`; bumps `state_version` so Resolve/Escalate preview bundles regenerate; 409 on terminal fix status
- Alembic migration adds `origin VARCHAR(20) NOT NULL DEFAULT 'standalone'` to `script_builder_sessions` (CHECK enum `'standalone'|'pilot_inline'` + invariant `origin='pilot_inline' ⇒ ai_session_id IS NOT NULL`); reuses the pre-existing `ai_session_id` FK rather than adding a new parent column; partial unique index `ux_script_builder_sessions_pilot_inline` on `(user_id, ai_session_id) WHERE origin='pilot_inline'` backs get-or-create idempotency
- `POST /api/v1/scripts/builder/sessions` extended: accepts `origin` + `ai_session_id` with auth (pilot session must belong to caller); returns existing row on duplicate; race-safe via `IntegrityError` + re-read fallback; `list_sessions` and `count_user_sessions` default-scope to `origin='standalone'` so inline sessions don't pollute the dashboard or count against the 5-session cap
- `applied_at` semantics corrected: stamps only on run-declaring actions — `TemplateMatchPanel` "I ran this" click via new `onMarkRun` prop, and `NoTemplateDialog` decisions `one_off`/`draft_template` (both labelled "Run now, …"). `build_template` does NOT stamp. Script Builder tab Submit does NOT stamp. Banner `Apply` click no longer stamps directly
**Key frontend additions:**
- `ChatTabStrip` — `[Chat] [Script Builder ●]` header strip in the chat region when the active fix needs a drafted script (status proposed/applied_partial, no template, no drafted script)
- `ScriptBuilderTab` — new controller wrapping `ScriptBuilderChat` (AI mode) + `ScriptBodyEditor` (Monaco, "Write it myself" mode); get-or-create on mount; Submit calls `sessionSuggestedFixesApi.patchScript`
- `InlineNoTemplateDialog` — chat-region slide-up wrapper around the existing `NoTemplateDialog`; replaces the previous task-lane `bottomSlot` rendering of the drafted-script three-card decision
- `TemplateMatchPanel` gains `onMarkRun` optional prop + "✓ I ran this" primary button
- `EscalateInterceptDialog` gains a fourth "I applied some of it — partial" choice (dispatches `applied_partial` via the existing `FixOutcome` pass-through)
**Commit range:** `5bcb7aa` (Phase 9 Task 1 start) through `faf1d8d`
```
git commit -m "feat(pilot): Phase 9 — tabbed Script Builder + InlineNoTemplateDialog relocation"
```
---
## 10. Design system reference

View File

@@ -0,0 +1,165 @@
# Phase 8 Review Issues
Date: 2026-04-23
Scope reviewed:
- `backend/app/api/endpoints/session_suggested_fixes.py`
- `backend/app/services/unified_chat_service.py`
- `frontend/src/pages/AssistantChatPage.tsx`
- `frontend/src/components/pilot/ProposalBanner.tsx`
- `frontend/src/components/pilot/EscalateInterceptDialog.tsx`
## 1. Outcome writes do not invalidate Resolve/Escalate preview cache
Severity: High
`PATCH /suggested-fixes/{fix_id}/outcome` updates the fix row but does not bump
`ai_sessions.state_version`. Even after adding that bump, the preview input
bundle also needs to include the fix outcome fields; otherwise a regenerated
preview still cannot distinguish proposed, partially applied, failed, or
successful fixes.
Relevant files:
- `backend/app/api/endpoints/session_suggested_fixes.py:226`
- `backend/app/api/endpoints/session_suggested_fixes.py:146`
- `backend/app/services/resolution_note_generator.py:13`
- `backend/app/services/escalation_package_generator.py:14`
Why this matters:
- Resolve and Escalate previews are cached by `(session_id, state_version)`.
- The decision endpoint already bumps `state_version`.
- The new outcome endpoint does not.
- A user can record `applied_success` / `applied_failed` / `applied_partial`
and still see markdown generated from the pre-outcome session state.
- The preview generators currently pass only the active fix title,
confidence, description, and user decision into the LLM bundle. They do not
pass `status`, `applied_at`, `verified_at`, `partial_notes`, or
`failure_reason`.
- Therefore a cache miss alone is not enough: the generated markdown may still
describe the fix as merely proposed because the outcome is absent from the
prompt input.
Recommended fix:
- Bump `AISession.state_version` inside the outcome endpoint transaction.
- Include suggested-fix outcome state in both preview bundles:
- `status`
- `applied_at`
- `verified_at`
- `partial_notes`
- `failure_reason`
- Update the resolution-note prompt expectations so `applied_success` produces
closure language, `applied_failed` states that the proposed fix did not
resolve the issue, and `applied_partial` includes the engineer's partial
notes.
- Update the escalation-package prompt expectations so failed/partial outcomes
appear under "What we've tried" and inform "Suggested next steps."
- Add a test proving a preview generated before an outcome change is
invalidated after the outcome patch and that the regenerated preview input
includes the recorded outcome.
## 2. "Apply" is not persisted, so Verifying state is lost on reload/reselect
Severity: High
Phase 8 introduces a Verifying lifecycle in the UI, but clicking Apply only
sets local React state.
Relevant files:
- `frontend/src/pages/AssistantChatPage.tsx:142`
- `frontend/src/pages/AssistantChatPage.tsx:516`
- `backend/app/api/endpoints/session_suggested_fixes.py:276`
Why this matters:
- `bannerApplied` is a client-side-only flag.
- `handleApplyFix()` opens the script panel and flips local state, but does not
persist anything.
- `applied_at` is only stamped later when an outcome is patched.
- After refresh, chat reselect, or multi-tab use, a fix that had entered
Verifying falls back to `proposed`.
- Nudge timing, resolve auto-success, and escalate interception therefore do
not survive normal session resume.
Recommended fix:
- Persist "apply started" as part of the fix lifecycle.
- Either add an explicit backend transition for apply/start-verifying, or
persist `applied_at` when Apply is clicked.
- Add a test or browser regression check covering refresh/reselect continuity.
## 3. Rejecting an AI outcome proposal is only local and will reappear
Severity: Medium
Rejecting the AI-confirming banner clears `ai_outcome_proposal` only in local
component state.
Relevant files:
- `frontend/src/pages/AssistantChatPage.tsx:571`
- `frontend/src/pages/AssistantChatPage.tsx:431`
Why this matters:
- `handleRejectAIProposal()` only updates local `activeFix`.
- The server-side `ai_outcome_proposal` remains unchanged.
- The proposal comes back on the next `refreshSessionDerived()` call, which
happens after sends, task submissions, and chat selection.
- "Not yet" is therefore a temporary hide, not a real rejection/correction.
Recommended fix:
- Add a backend way to clear or reject `ai_outcome_proposal`.
- Make the reject action persist so the banner does not immediately re-arm on
the next refetch.
## 4. Pre-existing failing decision test
Severity: Low (test gap, no runtime regression)
`tests/test_session_suggested_fixes_api.py::test_record_decision_persists_and_bumps_state_version`
was authored in Phase 3 (`66e5920`) when the `decision` endpoint had no
validation on `ai_drafted_script`. Phase 5 (`fa61376`) added a 400 guard:
when the decision is `one_off`, `draft_template`, or `build_template` and the
fix has no `ai_drafted_script` (and the caller provides no `edited_script` in
the request body), the endpoint returns 400 with the message "Suggested fix has
no ai_drafted_script — use /api/v1/scripts/generate for template-matched
fixes."
The test creates a fix without an `ai_drafted_script` and posts
`{"decision": "draft_template"}` naked, so the guard fires and returns 400. The
test still asserts 200. This was already broken before Phase 8 began — commit
`cdd8bb0` (first Phase 8 commit) is 8 commits after `fa61376`.
Root cause: test was never updated to match the Phase 5 contract change.
Recommended fix for the next branch:
- Option A (minimal): supply `ai_drafted_script="echo hello"` when creating the
fix fixture, or add `edited_script` to the POST body. Validates the happy path
for `draft_template` with a real drafted body.
- Option B (comprehensive): add a separate test case asserting the 400 when
`ai_drafted_script` is null and no `edited_script` is provided, then fix the
existing test as in Option A. The 400-guard already has coverage in the
Phase 5 test file; the main gap is just the missing fixture update here.
No Phase 8 code change required — this is a test-fixture gap from Phase 3/5
drift, not a regression introduced in this branch.
## Test Context
Relevant backend suites were run serially from `backend/`:
```bash
pytest tests/test_fix_outcome_endpoint.py tests/test_fix_outcome_marker.py tests/test_session_suggested_fixes_api.py -q
```
Observed result:
- `28 passed`
- `1 failed`
Remaining failure:
- `tests/test_session_suggested_fixes_api.py::test_record_decision_persists_and_bumps_state_version`
Notes:
- That failing test is in the older decision-path suite and expects
`draft_template` to succeed without a drafted script.
- The new outcome endpoint tests and marker parser tests passed in the serial
run.
- The three issues above are based on code inspection and remain valid
regardless of that separate failing test.
- Full root cause analysis documented in section 4 above.

View File

@@ -0,0 +1,679 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>FlowPilot — Suggested Fix → Resolve CTA merge (Option A)</title>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=Bricolage+Grotesque:wght@500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root {
--bg-sidebar: #0e1016;
--bg-page: #16181f;
--bg-card: #1e2028;
--bg-elevated: #2a2d38;
--border-default: rgba(148, 163, 184, 0.12);
--border-hover: rgba(148, 163, 184, 0.22);
--text-heading: #f1f5f9;
--text-primary: #e2e8f0;
--text-muted-foreground: #94a3b8;
--text-muted: #64748b;
--accent: #60a5fa;
--accent-dim: rgba(96, 165, 250, 0.10);
--accent-border: rgba(96, 165, 250, 0.30);
--warning: #fbbf24;
--warning-dim: rgba(251, 191, 36, 0.10);
--warning-border: rgba(251, 191, 36, 0.28);
--success: #34d399;
--success-dim: rgba(52, 211, 153, 0.10);
--success-border: rgba(52, 211, 153, 0.28);
--danger: #f87171;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
background: var(--bg-sidebar);
color: var(--text-primary);
font-family: 'IBM Plex Sans', system-ui, -apple-system, sans-serif;
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
.page {
max-width: 1680px;
margin: 0 auto;
padding: 32px 24px 64px;
}
.page-header {
margin-bottom: 28px;
}
.page-title {
font-family: 'Bricolage Grotesque', sans-serif;
font-weight: 600;
font-size: 22px;
color: var(--text-heading);
letter-spacing: -0.01em;
}
.page-sub {
margin-top: 6px;
color: var(--text-muted-foreground);
font-size: 13px;
max-width: 840px;
}
.columns {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
}
/* ----- Column scaffold (pretending to be the task-lane rail) ----- */
.col {
background: var(--bg-page);
border: 1px solid var(--border-default);
border-radius: 12px;
display: flex;
flex-direction: column;
height: 760px;
overflow: hidden;
}
.col-head {
padding: 14px 16px;
border-bottom: 1px solid var(--border-default);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
background: var(--bg-sidebar);
}
.col-head-label {
font-family: 'Bricolage Grotesque', sans-serif;
font-weight: 600;
font-size: 13px;
color: var(--text-heading);
letter-spacing: 0.01em;
}
.col-head-tag {
font-size: 10px;
font-weight: 600;
letter-spacing: 1.2px;
text-transform: uppercase;
color: var(--text-muted);
}
.col-head-tag.today { color: var(--text-muted-foreground); }
.col-head-tag.opt-a { color: var(--accent); }
.col-head-tag.opt-a-disabled { color: var(--warning); }
.lane-body {
flex: 1;
overflow-y: auto;
padding: 14px 14px 10px;
display: flex;
flex-direction: column;
gap: 16px;
}
/* ----- Section labels (match current component styling) ----- */
.section-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 10px;
font-weight: 600;
letter-spacing: 1.2px;
text-transform: uppercase;
color: var(--text-muted-foreground);
padding: 0 2px 8px;
}
.dot {
width: 6px;
height: 6px;
border-radius: 50%;
display: inline-block;
}
.dot-accent { background: var(--accent); }
.dot-warning { background: var(--warning); }
.dot-success { background: var(--success); }
.section-meta {
color: var(--text-muted);
font-weight: 500;
letter-spacing: 0;
text-transform: none;
}
.conf-high { color: var(--success); font-variant-numeric: tabular-nums; letter-spacing: 0; text-transform: none; }
/* ----- What-we-know facts ----- */
.fact {
background: var(--bg-card);
border: 1px solid var(--border-default);
border-left: 3px solid var(--accent);
border-radius: 8px;
padding: 10px 12px;
display: flex;
gap: 10px;
align-items: flex-start;
}
.fact + .fact { margin-top: 8px; }
.fact-icon {
width: 14px;
height: 14px;
border-radius: 3px;
background: var(--accent-dim);
border: 1px solid var(--accent-border);
flex-shrink: 0;
margin-top: 2px;
}
.fact-body { min-width: 0; flex: 1; }
.fact-title {
font-size: 12.5px;
font-weight: 500;
color: var(--text-heading);
line-height: 1.4;
}
.fact-meta {
margin-top: 3px;
font-size: 11px;
color: var(--text-muted);
font-family: 'JetBrains Mono', monospace;
}
/* ----- Suggested fix card (today only) ----- */
.fix-card {
border-radius: 8px;
border: 1px solid var(--warning-border);
border-left: 3px solid var(--warning);
background: var(--warning-dim);
padding: 12px 14px;
display: flex;
gap: 10px;
align-items: flex-start;
}
.fix-spark {
color: var(--warning);
flex-shrink: 0;
margin-top: 1px;
}
.fix-title {
font-size: 13px;
font-weight: 500;
color: var(--text-heading);
line-height: 1.4;
}
.fix-desc {
margin-top: 4px;
font-size: 12px;
color: var(--text-muted-foreground);
line-height: 1.5;
}
.fix-hint {
margin-top: 6px;
font-size: 11px;
color: var(--success);
}
.fix-x {
margin-left: auto;
color: var(--text-muted);
background: transparent;
border: 0;
padding: 2px 4px;
border-radius: 4px;
cursor: pointer;
font-size: 11px;
}
/* ----- Action bar at bottom ----- */
.action-bar {
border-top: 1px solid var(--border-default);
padding: 12px 14px 14px;
background: var(--bg-sidebar);
display: flex;
flex-direction: column;
gap: 8px;
}
.action-row {
display: flex;
gap: 8px;
}
.btn {
appearance: none;
border: 1px solid var(--border-default);
background: var(--bg-card);
color: var(--text-primary);
padding: 10px 12px;
border-radius: 8px;
font-family: inherit;
font-weight: 500;
font-size: 13px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
cursor: pointer;
transition: border-color 0.12s ease, background-color 0.12s ease, color 0.12s ease;
}
.btn:hover { border-color: var(--border-hover); background: var(--bg-elevated); }
.btn-secondary {
flex: 0 0 auto;
min-width: 96px;
}
.btn-resolve-today {
flex: 1;
background: var(--accent);
color: #0a0d14;
border-color: transparent;
font-weight: 600;
}
.btn-resolve-today:hover { background: #7ab4fb; color: #0a0d14; }
/* Option A — Resolve w/ embedded fix */
.btn-resolve-merged {
flex: 1;
background: var(--accent);
color: #0a0d14;
border-color: transparent;
padding: 10px 14px;
display: flex;
align-items: center;
gap: 12px;
justify-content: flex-start;
min-height: 52px;
text-align: left;
}
.btn-resolve-merged:hover { background: #7ab4fb; color: #0a0d14; }
.btn-resolve-merged .rc-leading {
font-size: 11px;
font-weight: 600;
letter-spacing: 1px;
text-transform: uppercase;
color: rgba(10, 13, 20, 0.72);
font-family: 'Bricolage Grotesque', sans-serif;
}
.btn-resolve-merged .rc-title {
font-size: 13.5px;
font-weight: 600;
color: #0a0d14;
line-height: 1.25;
letter-spacing: -0.01em;
}
.btn-resolve-merged .rc-body {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.btn-resolve-merged .rc-conf {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 8px;
border-radius: 999px;
background: rgba(10, 13, 20, 0.14);
color: #0a0d14;
font-size: 11px;
font-weight: 700;
font-variant-numeric: tabular-nums;
flex-shrink: 0;
}
.btn-resolve-merged .rc-chevron {
color: rgba(10, 13, 20, 0.55);
flex-shrink: 0;
}
/* Disabled (no proposal yet) */
.btn-resolve-disabled {
flex: 1;
background: var(--bg-card);
color: var(--text-muted-foreground);
border: 1px dashed var(--border-hover);
padding: 10px 14px;
display: flex;
align-items: center;
gap: 10px;
justify-content: flex-start;
min-height: 52px;
cursor: not-allowed;
text-align: left;
}
.btn-resolve-disabled .rc-leading {
font-size: 11px;
font-weight: 600;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--text-muted);
font-family: 'Bricolage Grotesque', sans-serif;
}
.btn-resolve-disabled .rc-title {
font-size: 13px;
font-weight: 500;
color: var(--text-muted-foreground);
line-height: 1.25;
}
/* Escalate / overflow */
.btn-escalate {
background: transparent;
color: var(--text-muted-foreground);
}
.btn-escalate:hover { color: var(--text-primary); }
/* tiny spinner dot for the waiting state */
.pulse {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--warning);
box-shadow: 0 0 0 0 rgba(251, 191, 36, 0.5);
animation: pulse 1.6s infinite;
flex-shrink: 0;
}
@keyframes pulse {
0% { box-shadow: 0 0 0 0 rgba(251, 191, 36, 0.45); }
70% { box-shadow: 0 0 0 8px rgba(251, 191, 36, 0); }
100% { box-shadow: 0 0 0 0 rgba(251, 191, 36, 0); }
}
/* Annotation callouts beneath the columns */
.callout {
margin-top: 14px;
padding: 12px 14px;
background: var(--bg-page);
border: 1px solid var(--border-default);
border-radius: 10px;
font-size: 12px;
color: var(--text-muted-foreground);
line-height: 1.55;
}
.callout strong { color: var(--text-heading); font-weight: 600; }
.callout.note-accent { border-left: 3px solid var(--accent); }
.callout.note-warning { border-left: 3px solid var(--warning); }
.callout.note-muted { border-left: 3px solid var(--border-hover); }
.legend {
margin-top: 40px;
padding: 18px 20px;
background: var(--bg-page);
border: 1px solid var(--border-default);
border-radius: 12px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px 32px;
font-size: 12.5px;
color: var(--text-muted-foreground);
line-height: 1.55;
}
.legend h4 {
font-family: 'Bricolage Grotesque', sans-serif;
font-size: 13px;
font-weight: 600;
color: var(--text-heading);
margin-bottom: 6px;
letter-spacing: 0;
}
.legend li { margin-top: 4px; }
/* subtle faux scrollbar hint */
.lane-body::-webkit-scrollbar { width: 6px; }
.lane-body::-webkit-scrollbar-thumb { background: var(--border-hover); border-radius: 3px; }
</style>
</head>
<body>
<div class="page">
<div class="page-header">
<div class="page-title">Option A — Suggested Fix merges into the Resolve CTA</div>
<div class="page-sub">
Three versions of the same task lane. <strong style="color:var(--text-primary)">Today</strong> keeps Suggested Fix as a separate card that gets pushed down by a long facts list. <strong style="color:var(--text-primary)">Option A (armed)</strong> deletes the card — the Resolve button at the bottom becomes the proposal. <strong style="color:var(--text-primary)">Option A (waiting)</strong> is what the same bar looks like before the AI emits a proposal.
</div>
</div>
<div class="columns">
<!-- ============== COLUMN 1: TODAY ============== -->
<div>
<div class="col">
<div class="col-head">
<div class="col-head-label">Today</div>
<div class="col-head-tag today">Baseline</div>
</div>
<div class="lane-body">
<!-- What we know -->
<section>
<div class="section-label">
<span class="dot dot-accent"></span>
What we know
<span class="section-meta">· 5 facts</span>
</div>
<div class="fact">
<span class="fact-icon"></span>
<div class="fact-body">
<div class="fact-title">User cannot authenticate to Outlook; repeated 401s from Exchange Online.</div>
<div class="fact-meta">promoted 14:02 · from ticket</div>
</div>
</div>
<div class="fact">
<span class="fact-icon"></span>
<div class="fact-body">
<div class="fact-title">Cached credentials in Credential Manager reference a prior tenant the user migrated off six months ago.</div>
<div class="fact-meta">promoted 14:07 · from chat</div>
</div>
</div>
<div class="fact">
<span class="fact-icon"></span>
<div class="fact-body">
<div class="fact-title">MFA prompt appears then fails silently — no authenticator notification, no error code surfaced to the user.</div>
<div class="fact-meta">promoted 14:11 · from chat</div>
</div>
</div>
<div class="fact">
<span class="fact-icon"></span>
<div class="fact-body">
<div class="fact-title">Other devices under same account authenticate successfully, isolating the problem to this workstation.</div>
<div class="fact-meta">promoted 14:14 · from chat</div>
</div>
</div>
<div class="fact">
<span class="fact-icon"></span>
<div class="fact-body">
<div class="fact-title">Office 365 client last updated three weeks ago; local profile not recreated since migration.</div>
<div class="fact-meta">promoted 14:18 · from chat</div>
</div>
</div>
</section>
<!-- Suggested Fix card (this is the thing that gets buried) -->
<section>
<div class="section-label">
<span class="dot dot-warning"></span>
Suggested fix
<span class="section-meta">·</span>
<span class="conf-high">94% confidence</span>
</div>
<div class="fix-card">
<svg class="fix-spark" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/></svg>
<div style="min-width:0;flex:1">
<div class="fix-title">Clear cached credentials + rebuild Outlook profile</div>
<div class="fix-desc">Remove stale entries from Credential Manager referencing the prior tenant, then rebuild the local Outlook profile so the client re-authenticates cleanly against the current tenant.</div>
<div class="fix-hint">✓ Matches an existing Script Library template — click to use</div>
</div>
<button class="fix-x" aria-label="Dismiss"></button>
</div>
</section>
</div>
<div class="action-bar">
<div class="action-row">
<button class="btn btn-escalate btn-secondary">Escalate</button>
<button class="btn btn-resolve-today">Resolve</button>
</div>
</div>
</div>
<div class="callout note-muted">
<strong>Baseline problem.</strong> The Suggested Fix card sits after What-we-know. With 5+ facts (common by mid-session) it's below the fold. The generic <em>Resolve</em> button at the bottom doesn't surface what would be resolved, so the engineer has to scroll up, read the card, then scroll back down to act.
</div>
</div>
<!-- ============== COLUMN 2: OPTION A — ARMED ============== -->
<div>
<div class="col">
<div class="col-head">
<div class="col-head-label">Option A — armed</div>
<div class="col-head-tag opt-a">Proposal ready</div>
</div>
<div class="lane-body">
<!-- Same facts, but no Suggested Fix card -->
<section>
<div class="section-label">
<span class="dot dot-accent"></span>
What we know
<span class="section-meta">· 5 facts</span>
</div>
<div class="fact">
<span class="fact-icon"></span>
<div class="fact-body">
<div class="fact-title">User cannot authenticate to Outlook; repeated 401s from Exchange Online.</div>
<div class="fact-meta">promoted 14:02 · from ticket</div>
</div>
</div>
<div class="fact">
<span class="fact-icon"></span>
<div class="fact-body">
<div class="fact-title">Cached credentials in Credential Manager reference a prior tenant the user migrated off six months ago.</div>
<div class="fact-meta">promoted 14:07 · from chat</div>
</div>
</div>
<div class="fact">
<span class="fact-icon"></span>
<div class="fact-body">
<div class="fact-title">MFA prompt appears then fails silently — no authenticator notification, no error code surfaced to the user.</div>
<div class="fact-meta">promoted 14:11 · from chat</div>
</div>
</div>
<div class="fact">
<span class="fact-icon"></span>
<div class="fact-body">
<div class="fact-title">Other devices under same account authenticate successfully, isolating the problem to this workstation.</div>
<div class="fact-meta">promoted 14:14 · from chat</div>
</div>
</div>
<div class="fact">
<span class="fact-icon"></span>
<div class="fact-body">
<div class="fact-title">Office 365 client last updated three weeks ago; local profile not recreated since migration.</div>
<div class="fact-meta">promoted 14:18 · from chat</div>
</div>
</div>
</section>
<!-- NO Suggested Fix card here — it lives on the button -->
</div>
<div class="action-bar">
<div class="action-row">
<button class="btn btn-escalate btn-secondary">Escalate</button>
<button class="btn btn-resolve-merged" aria-label="Resolve with: Clear cached credentials + rebuild Outlook profile (94% confidence)">
<div class="rc-body">
<div class="rc-leading">Resolve with</div>
<div class="rc-title">Clear cached credentials + rebuild Outlook profile</div>
</div>
<span class="rc-conf">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/></svg>
94%
</span>
<svg class="rc-chevron" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
</button>
</div>
</div>
</div>
<div class="callout note-accent">
<strong>What changes.</strong> The Suggested Fix card is gone. Its content moved onto the Resolve button, which is always in view. One click = accept the fix + open the existing <code style="font-family:'JetBrains Mono',monospace;font-size:11.5px;background:var(--bg-card);padding:1px 5px;border-radius:3px;">ResolutionNotePreview</code> popover pre-filled. No card-then-button two-step.
</div>
</div>
<!-- ============== COLUMN 3: OPTION A — WAITING ============== -->
<div>
<div class="col">
<div class="col-head">
<div class="col-head-label">Option A — waiting</div>
<div class="col-head-tag opt-a-disabled">No proposal yet</div>
</div>
<div class="lane-body">
<section>
<div class="section-label">
<span class="dot dot-accent"></span>
What we know
<span class="section-meta">· 2 facts</span>
</div>
<div class="fact">
<span class="fact-icon"></span>
<div class="fact-body">
<div class="fact-title">User cannot authenticate to Outlook; repeated 401s from Exchange Online.</div>
<div class="fact-meta">promoted 14:02 · from ticket</div>
</div>
</div>
<div class="fact">
<span class="fact-icon"></span>
<div class="fact-body">
<div class="fact-title">Cached credentials in Credential Manager reference a prior tenant.</div>
<div class="fact-meta">promoted 14:07 · from chat</div>
</div>
</div>
</section>
</div>
<div class="action-bar">
<div class="action-row">
<button class="btn btn-escalate btn-secondary">Escalate</button>
<button class="btn btn-resolve-disabled" disabled aria-label="Resolve (waiting for AI proposal)">
<span class="pulse" aria-hidden="true"></span>
<div class="rc-body">
<div class="rc-leading">Resolve</div>
<div class="rc-title">Waiting for proposal…</div>
</div>
</button>
</div>
</div>
</div>
<div class="callout note-warning">
<strong>Before confidence threshold.</strong> Same slot, disabled state. Amber pulse signals the AI is still reasoning. Below threshold or no proposal yet → same visual — the engineer can still use <em>Escalate</em> at any time.
</div>
</div>
</div>
<!-- ============== LEGEND / TRADE-OFFS ============== -->
<div class="legend">
<div>
<h4>Why this helps discoverability</h4>
<ul style="padding-left:18px;list-style:disc">
<li>Proposal is in the place the engineer looks to <em>act</em>, not in the scrolling lane above.</li>
<li>Resolve bar is already sticky at the bottom — no new sticky patterns needed (preserves the <code style="font-family:'JetBrains Mono',monospace;font-size:11px">8879f96</code> fix).</li>
<li>Accepting a fix and resolving the session collapse into one click instead of two.</li>
</ul>
</div>
<div>
<h4>What you give up</h4>
<ul style="padding-left:18px;list-style:disc">
<li>No space for secondary info on the button (reasoning, alternative fixes). Would need an expand/chevron or hover tooltip.</li>
<li>No standalone "dismiss this fix" affordance — need to decide where dismiss/reject lives (chevron menu? secondary button?).</li>
<li>If the AI proposes multiple candidates, only the top one fits the button. Need a "▾ 2 other candidates" menu.</li>
</ul>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,849 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>FlowPilot — Suggested Fix as slide-up composer banner</title>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=Bricolage+Grotesque:wght@500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root {
--bg-sidebar: #0e1016;
--bg-page: #16181f;
--bg-card: #1e2028;
--bg-elevated: #2a2d38;
--border-default: rgba(148, 163, 184, 0.12);
--border-hover: rgba(148, 163, 184, 0.22);
--text-heading: #f1f5f9;
--text-primary: #e2e8f0;
--text-muted-foreground: #94a3b8;
--text-muted: #64748b;
--accent: #60a5fa;
--accent-dim: rgba(96, 165, 250, 0.10);
--accent-border: rgba(96, 165, 250, 0.30);
--warning: #fbbf24;
--warning-dim: rgba(251, 191, 36, 0.10);
--warning-dim-strong: rgba(251, 191, 36, 0.16);
--warning-border: rgba(251, 191, 36, 0.32);
--success: #34d399;
--success-dim: rgba(52, 211, 153, 0.10);
--success-border: rgba(52, 211, 153, 0.28);
--danger: #f87171;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
background: var(--bg-sidebar);
color: var(--text-primary);
font-family: 'IBM Plex Sans', system-ui, -apple-system, sans-serif;
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
.page {
max-width: 1680px;
margin: 0 auto;
padding: 32px 24px 72px;
}
.page-header { margin-bottom: 24px; }
.page-title {
font-family: 'Bricolage Grotesque', sans-serif;
font-weight: 600;
font-size: 22px;
color: var(--text-heading);
letter-spacing: -0.01em;
}
.page-sub {
margin-top: 6px;
color: var(--text-muted-foreground);
font-size: 13px;
max-width: 980px;
line-height: 1.55;
}
/* =================== Main frame =================== */
.frame {
background: var(--bg-page);
border: 1px solid var(--border-default);
border-radius: 14px;
overflow: hidden;
display: grid;
grid-template-columns: 1fr 380px;
height: 780px;
}
/* ------ Chat area ------ */
.chat {
display: flex;
flex-direction: column;
background: var(--bg-page);
min-width: 0;
}
.chat-head {
padding: 14px 20px;
border-bottom: 1px solid var(--border-default);
background: var(--bg-sidebar);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.chat-head-title {
font-family: 'Bricolage Grotesque', sans-serif;
font-weight: 600;
font-size: 14px;
color: var(--text-heading);
}
.chat-head-sub {
font-size: 11.5px;
color: var(--text-muted);
font-family: 'JetBrains Mono', monospace;
}
.chat-head-actions {
display: flex;
gap: 8px;
}
.chat-scroll {
flex: 1;
overflow-y: auto;
padding: 24px 28px 16px;
display: flex;
flex-direction: column;
gap: 16px;
}
.msg {
max-width: 640px;
display: flex;
gap: 10px;
align-items: flex-start;
}
.msg.user { align-self: flex-end; }
.msg-av {
width: 26px; height: 26px;
border-radius: 50%;
flex-shrink: 0;
font-size: 11px;
font-weight: 600;
display: flex; align-items: center; justify-content: center;
margin-top: 2px;
}
.msg.user .msg-av {
background: var(--accent-dim);
color: var(--accent);
border: 1px solid var(--accent-border);
}
.msg.ai .msg-av {
background: var(--warning-dim);
color: var(--warning);
border: 1px solid var(--warning-border);
}
.msg-body {
background: var(--bg-card);
border: 1px solid var(--border-default);
border-radius: 10px;
padding: 10px 13px;
font-size: 13px;
color: var(--text-primary);
line-height: 1.55;
}
.msg.user .msg-body {
background: var(--accent-dim);
border-color: var(--accent-border);
color: var(--text-heading);
}
.msg-meta {
margin-top: 4px;
font-size: 10.5px;
color: var(--text-muted);
font-family: 'JetBrains Mono', monospace;
}
/* ------ Composer area (sticky bottom of chat) ------ */
.composer-wrap {
border-top: 1px solid var(--border-default);
background: var(--bg-page);
position: relative;
}
/* ------ Slide-up banner ------ */
.proposal-banner {
margin: 0;
border-top: 1px solid var(--warning-border);
background: linear-gradient(180deg, var(--warning-dim-strong) 0%, var(--warning-dim) 100%);
padding: 12px 20px 14px;
display: flex;
gap: 14px;
align-items: flex-start;
position: relative;
animation: slideUp 320ms cubic-bezier(.22, .9, .28, 1) both;
}
.proposal-banner::before {
content: '';
position: absolute;
left: 0; top: 0; bottom: 0;
width: 3px;
background: var(--warning);
}
@keyframes slideUp {
from { transform: translateY(14px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.proposal-icon {
width: 28px; height: 28px;
border-radius: 7px;
background: var(--warning-dim-strong);
border: 1px solid var(--warning-border);
display: flex; align-items: center; justify-content: center;
color: var(--warning);
flex-shrink: 0;
margin-top: 2px;
}
.proposal-body {
flex: 1;
min-width: 0;
}
.proposal-head {
display: flex;
align-items: center;
gap: 8px;
font-size: 10px;
font-weight: 600;
letter-spacing: 1.2px;
text-transform: uppercase;
color: var(--warning);
font-family: 'Bricolage Grotesque', sans-serif;
}
.proposal-head .pill {
padding: 2px 7px;
border-radius: 999px;
background: rgba(251, 191, 36, 0.20);
color: var(--warning);
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.5px;
font-family: 'IBM Plex Sans', sans-serif;
font-variant-numeric: tabular-nums;
}
.proposal-title {
margin-top: 3px;
font-size: 14px;
font-weight: 600;
color: var(--text-heading);
line-height: 1.35;
letter-spacing: -0.005em;
}
.proposal-desc {
margin-top: 3px;
font-size: 12.5px;
color: var(--text-muted-foreground);
line-height: 1.5;
}
.proposal-hint {
margin-top: 6px;
font-size: 11.5px;
color: var(--success);
display: inline-flex;
align-items: center;
gap: 5px;
}
.proposal-actions {
display: flex;
gap: 8px;
align-items: center;
flex-shrink: 0;
padding-top: 2px;
}
.btn {
appearance: none;
border: 1px solid var(--border-default);
background: var(--bg-card);
color: var(--text-primary);
padding: 8px 12px;
border-radius: 8px;
font-family: inherit;
font-weight: 500;
font-size: 12.5px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
cursor: pointer;
transition: border-color 0.12s, background-color 0.12s, color 0.12s;
white-space: nowrap;
}
.btn:hover { border-color: var(--border-hover); background: var(--bg-elevated); }
.btn-apply {
background: var(--warning);
color: #1a1200;
border-color: transparent;
font-weight: 600;
padding: 9px 14px;
}
.btn-apply:hover { background: #ffce4f; color: #1a1200; }
.btn-ghost {
background: transparent;
color: var(--text-muted-foreground);
border-color: transparent;
padding: 8px 10px;
}
.btn-ghost:hover {
background: rgba(148, 163, 184, 0.08);
color: var(--text-primary);
border-color: transparent;
}
.icon-btn {
width: 30px; height: 30px;
padding: 0;
background: transparent;
color: var(--text-muted-foreground);
border: 1px solid transparent;
}
.icon-btn:hover {
background: rgba(148, 163, 184, 0.08);
color: var(--text-primary);
}
/* ------ Composer ------ */
.composer {
padding: 14px 20px 16px;
display: flex;
align-items: flex-end;
gap: 10px;
}
.composer-input {
flex: 1;
min-height: 44px;
background: var(--bg-card);
border: 1px solid var(--border-default);
border-radius: 10px;
padding: 10px 14px;
color: var(--text-muted-foreground);
font-size: 13px;
line-height: 1.4;
display: flex;
align-items: center;
}
.composer-send {
width: 44px; height: 44px;
border-radius: 10px;
background: var(--accent);
color: #0a0d14;
border: 0;
display: flex; align-items: center; justify-content: center;
cursor: pointer;
flex-shrink: 0;
}
/* ------ Task lane (right rail) ------ */
.lane {
border-left: 1px solid var(--border-default);
background: var(--bg-sidebar);
display: flex;
flex-direction: column;
min-height: 0;
}
.lane-head {
padding: 14px 16px;
border-bottom: 1px solid var(--border-default);
display: flex;
align-items: center;
justify-content: space-between;
}
.lane-head-label {
font-family: 'Bricolage Grotesque', sans-serif;
font-weight: 600;
font-size: 13px;
color: var(--text-heading);
}
.lane-body {
flex: 1;
overflow-y: auto;
padding: 14px 14px 10px;
display: flex;
flex-direction: column;
gap: 16px;
}
.section-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 10px;
font-weight: 600;
letter-spacing: 1.2px;
text-transform: uppercase;
color: var(--text-muted-foreground);
padding: 0 2px 8px;
}
.dot { width: 6px; height: 6px; border-radius: 50%; display: inline-block; }
.dot-accent { background: var(--accent); }
.dot-muted { background: var(--text-muted); }
.section-meta {
color: var(--text-muted);
font-weight: 500;
letter-spacing: 0;
text-transform: none;
}
.fact {
background: var(--bg-card);
border: 1px solid var(--border-default);
border-left: 3px solid var(--accent);
border-radius: 8px;
padding: 10px 12px;
}
.fact + .fact { margin-top: 8px; }
.fact-title {
font-size: 12.5px;
font-weight: 500;
color: var(--text-heading);
line-height: 1.4;
}
.fact-meta {
margin-top: 3px;
font-size: 10.5px;
color: var(--text-muted);
font-family: 'JetBrains Mono', monospace;
}
.dismissed-pill {
padding: 8px 10px;
background: var(--bg-card);
border: 1px dashed var(--border-hover);
border-radius: 8px;
display: flex;
align-items: center;
gap: 8px;
font-size: 11.5px;
color: var(--text-muted-foreground);
cursor: pointer;
transition: border-color 0.12s, color 0.12s;
}
.dismissed-pill:hover { border-color: var(--warning-border); color: var(--warning); }
.action-bar {
border-top: 1px solid var(--border-default);
padding: 12px 14px 14px;
display: flex;
gap: 8px;
}
.btn-escalate { flex: 0 0 auto; min-width: 96px; background: transparent; color: var(--text-muted-foreground); }
.btn-resolve {
flex: 1;
background: var(--accent);
color: #0a0d14;
border-color: transparent;
font-weight: 600;
padding: 10px 12px;
}
.btn-resolve:hover { background: #7ab4fb; color: #0a0d14; }
/* =================== Callouts =================== */
.callout {
margin-top: 20px;
padding: 14px 16px;
background: var(--bg-page);
border: 1px solid var(--border-default);
border-radius: 10px;
font-size: 13px;
color: var(--text-muted-foreground);
line-height: 1.55;
border-left: 3px solid var(--warning);
}
.callout strong { color: var(--text-heading); font-weight: 600; }
/* =================== State detail row =================== */
.states-title {
margin-top: 48px;
font-family: 'Bricolage Grotesque', sans-serif;
font-weight: 600;
font-size: 18px;
color: var(--text-heading);
}
.states-sub {
margin-top: 4px;
color: var(--text-muted-foreground);
font-size: 13px;
}
.states {
margin-top: 16px;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
}
.state {
background: var(--bg-page);
border: 1px solid var(--border-default);
border-radius: 10px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.state-label {
padding: 10px 14px;
border-bottom: 1px solid var(--border-default);
font-family: 'Bricolage Grotesque', sans-serif;
font-weight: 600;
font-size: 12.5px;
color: var(--text-heading);
background: var(--bg-sidebar);
}
.state-body {
padding: 0;
background: var(--bg-page);
min-height: 220px;
display: flex;
flex-direction: column;
justify-content: flex-end;
}
.state-mini-chat {
flex: 1;
padding: 14px;
opacity: 0.55;
font-size: 11px;
color: var(--text-muted);
display: flex;
align-items: flex-end;
font-family: 'JetBrains Mono', monospace;
}
/* Collapsed banner variant */
.banner-collapsed {
border-top: 1px solid var(--warning-border);
background: var(--warning-dim);
padding: 8px 14px;
display: flex;
align-items: center;
gap: 10px;
font-size: 12px;
color: var(--text-primary);
position: relative;
}
.banner-collapsed::before {
content: '';
position: absolute;
left: 0; top: 0; bottom: 0;
width: 3px;
background: var(--warning);
}
.banner-collapsed-title {
font-weight: 500;
color: var(--text-heading);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
.banner-collapsed .pill {
padding: 1px 7px;
border-radius: 999px;
background: rgba(251, 191, 36, 0.20);
color: var(--warning);
font-size: 10.5px;
font-weight: 700;
}
.banner-collapsed .expand {
color: var(--text-muted-foreground);
font-size: 11px;
}
/* mini composer for the detail states */
.mini-composer {
border-top: 1px solid var(--border-default);
padding: 10px 14px;
display: flex;
gap: 8px;
align-items: center;
}
.mini-input {
flex: 1;
background: var(--bg-card);
border: 1px solid var(--border-default);
border-radius: 8px;
padding: 7px 10px;
font-size: 11.5px;
color: var(--text-muted);
}
.mini-send {
width: 28px; height: 28px;
border-radius: 7px;
background: var(--accent);
color: #0a0d14;
border: 0;
font-size: 14px;
display: flex; align-items: center; justify-content: center;
}
/* pill in chat stream (replaced state) */
.replaced-note {
align-self: flex-end;
font-size: 10.5px;
color: var(--text-muted);
font-family: 'JetBrains Mono', monospace;
padding: 4px 8px;
background: var(--bg-card);
border: 1px dashed var(--border-hover);
border-radius: 6px;
}
/* annotation captions under each state */
.state-caption {
padding: 10px 14px 12px;
font-size: 11.5px;
color: var(--text-muted-foreground);
line-height: 1.5;
border-top: 1px solid var(--border-default);
background: var(--bg-sidebar);
}
.state-caption strong { color: var(--text-heading); font-weight: 600; }
.lane-body::-webkit-scrollbar { width: 6px; }
.lane-body::-webkit-scrollbar-thumb { background: var(--border-hover); border-radius: 3px; }
.chat-scroll::-webkit-scrollbar { width: 6px; }
.chat-scroll::-webkit-scrollbar-thumb { background: var(--border-hover); border-radius: 3px; }
</style>
</head>
<body>
<div class="page">
<div class="page-header">
<div class="page-title">Option C — Suggested Fix slides up from the chat composer</div>
<div class="page-sub">
The AI's proposal docks as a persistent banner just above the chat composer — right where the engineer's eyes already are. Apply lives on the banner (warning amber). <em>Resolve</em> stays generic at the bottom of the task lane, so there's no false-resolve risk. The Suggested Fix card is removed from the task lane entirely.
</div>
</div>
<!-- ============ MAIN: Armed + expanded ============ -->
<div class="frame">
<div class="chat">
<div class="chat-head">
<div>
<div class="chat-head-title">Outlook won't authenticate after tenant migration</div>
<div class="chat-head-sub">ticket #48213 · in progress · 14:22</div>
</div>
<div class="chat-head-actions">
<button class="btn btn-ghost">Share update</button>
<button class="btn icon-btn" aria-label="More">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="19" cy="12" r="1.5"/></svg>
</button>
</div>
</div>
<div class="chat-scroll">
<div class="msg ai">
<div class="msg-av">AI</div>
<div>
<div class="msg-body">
A few things we know line up with a stale-credential pattern rather than an MFA or network issue. Can you confirm whether the user has other Microsoft 365 services (Teams, SharePoint) working on the same workstation?
</div>
<div class="msg-meta">14:16</div>
</div>
</div>
<div class="msg user">
<div>
<div class="msg-body">Teams works fine. SharePoint in browser is fine too. It's just Outlook.</div>
<div class="msg-meta">14:17</div>
</div>
<div class="msg-av">ME</div>
</div>
<div class="msg ai">
<div class="msg-av">AI</div>
<div>
<div class="msg-body">
That narrows it to the Outlook profile specifically. Given Credential Manager still has entries pointing at the prior tenant, the cleanest path is to clear those entries and rebuild the local Outlook profile — the client will re-auth against the current tenant from scratch.
</div>
<div class="msg-meta">14:22</div>
</div>
</div>
</div>
<!-- ============ Slide-up banner ============ -->
<div class="composer-wrap">
<div class="proposal-banner" role="region" aria-label="AI proposed fix">
<div class="proposal-icon">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/></svg>
</div>
<div class="proposal-body">
<div class="proposal-head">
<span>Suggested Fix</span>
<span class="pill">94% confidence</span>
</div>
<div class="proposal-title">Clear cached credentials + rebuild Outlook profile</div>
<div class="proposal-desc">
Remove stale Credential Manager entries referencing the prior tenant, then rebuild the local Outlook profile so the client re-authenticates cleanly against the current tenant.
</div>
<div class="proposal-hint">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
Matches an existing Script Library template — one-click apply
</div>
</div>
<div class="proposal-actions">
<button class="btn btn-ghost" aria-label="Collapse banner">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
</button>
<button class="btn btn-ghost" aria-label="Dismiss fix">Dismiss</button>
<button class="btn btn-apply">
Apply fix
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
</button>
</div>
</div>
<div class="composer">
<div class="composer-input">Ask a follow-up, paste an error, drop a screenshot…</div>
<button class="composer-send" aria-label="Send">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
</button>
</div>
</div>
</div>
<!-- ============ Task lane (no Suggested Fix card) ============ -->
<div class="lane">
<div class="lane-head">
<div class="lane-head-label">Task lane</div>
</div>
<div class="lane-body">
<section>
<div class="section-label">
<span class="dot dot-accent"></span>
What we know
<span class="section-meta">· 5 facts</span>
</div>
<div class="fact">
<div class="fact-title">User cannot authenticate to Outlook; repeated 401s from Exchange Online.</div>
<div class="fact-meta">promoted 14:02 · from ticket</div>
</div>
<div class="fact">
<div class="fact-title">Credential Manager still references the prior tenant from six months ago.</div>
<div class="fact-meta">promoted 14:07 · from chat</div>
</div>
<div class="fact">
<div class="fact-title">MFA prompt appears but fails silently — no authenticator notification.</div>
<div class="fact-meta">promoted 14:11 · from chat</div>
</div>
<div class="fact">
<div class="fact-title">Other devices under same account authenticate successfully.</div>
<div class="fact-meta">promoted 14:14 · from chat</div>
</div>
<div class="fact">
<div class="fact-title">Teams + SharePoint work on same workstation — isolated to Outlook.</div>
<div class="fact-meta">promoted 14:22 · from chat</div>
</div>
</section>
</div>
<div class="action-bar">
<button class="btn btn-escalate">Escalate</button>
<button class="btn btn-resolve">Resolve</button>
</div>
</div>
</div>
<div class="callout">
<strong>How it reads.</strong> Proposal arrives with a 320ms slide-up from below the composer, docks as a persistent banner until applied, dismissed, or replaced. Apply is amber (not accent-blue) so it visually belongs to the proposal, not the chat send button. Resolve in the task lane stays generic — there's no false-resolve risk because the two actions are spatially and visually separate.
</div>
<!-- ============ State detail row ============ -->
<div class="states-title">Banner states</div>
<div class="states-sub">What the same region looks like in the other three states — collapsed to save chat space, after the engineer dismisses it, and when a new proposal replaces an existing one.</div>
<div class="states">
<!-- STATE 1: Collapsed -->
<div class="state">
<div class="state-label">Collapsed (saves chat space)</div>
<div class="state-body">
<div class="state-mini-chat">…earlier messages…</div>
<div class="banner-collapsed">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="var(--warning)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/></svg>
<span class="banner-collapsed-title">Clear cached credentials + rebuild Outlook profile</span>
<span class="pill">94%</span>
<span class="expand">▸ expand</span>
</div>
<div class="mini-composer">
<div class="mini-input">Type a message…</div>
<button class="mini-send"></button>
</div>
</div>
<div class="state-caption">
<strong>~28px strip.</strong> Auto-collapses after 30s of no interaction, or when the engineer clicks the chevron. Title + confidence still visible. Click strip → expands. Apply still reachable via the expanded state.
</div>
</div>
<!-- STATE 2: Dismissed (pill in lane) -->
<div class="state">
<div class="state-label">Dismissed — parked in the task lane</div>
<div class="state-body">
<div class="state-mini-chat">chat unobstructed · banner gone</div>
<div class="mini-composer">
<div class="mini-input">Type a message…</div>
<button class="mini-send"></button>
</div>
</div>
<div style="padding: 12px 14px; background: var(--bg-sidebar); border-top: 1px solid var(--border-default);">
<div class="section-label" style="padding-bottom: 6px">
<span class="dot dot-muted"></span>
Dismissed proposals
<span class="section-meta">· 1</span>
</div>
<div class="dismissed-pill">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="var(--warning)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/></svg>
<span style="flex:1;color:var(--text-heading)">Clear cached credentials…</span>
<span style="color:var(--text-muted)">restore ↺</span>
</div>
</div>
<div class="state-caption">
<strong>Recoverable, out of the way.</strong> Dismissing the banner parks the proposal as a pill in the task lane. Clicking restore → banner slides back in. Prevents accidental loss.
</div>
</div>
<!-- STATE 3: Replaced -->
<div class="state">
<div class="state-label">Replaced — new proposal overrides old</div>
<div class="state-body">
<div class="state-mini-chat" style="flex-direction:column;align-items:flex-end;gap:8px;justify-content:flex-end;">
<span class="replaced-note">previous: "Rebuild Outlook profile" — didn't resolve, new proposal below</span>
</div>
<div class="proposal-banner" style="padding:10px 14px;gap:10px;">
<div class="proposal-icon" style="width:22px;height:22px;border-radius:6px">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/></svg>
</div>
<div class="proposal-body">
<div class="proposal-head" style="font-size:9px">
<span>New suggested fix</span>
<span class="pill" style="font-size:9.5px;padding:1px 6px">78%</span>
</div>
<div class="proposal-title" style="font-size:12.5px">Reset Autodiscover registry entries for this user</div>
</div>
<button class="btn btn-apply" style="padding:6px 10px;font-size:11.5px">Apply</button>
</div>
<div class="mini-composer">
<div class="mini-input">Type a message…</div>
<button class="mini-send"></button>
</div>
</div>
<div class="state-caption">
<strong>Old proposal cross-fades out, new one slides in.</strong> 200ms cross-fade, same slot. A tiny footnote in chat ("previous didn't resolve") preserves the audit trail without re-stacking banners.
</div>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,805 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>FlowPilot — Post-apply outcome states</title>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=Bricolage+Grotesque:wght@500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root {
--bg-sidebar: #0e1016;
--bg-page: #16181f;
--bg-card: #1e2028;
--bg-elevated: #2a2d38;
--border-default: rgba(148, 163, 184, 0.12);
--border-hover: rgba(148, 163, 184, 0.22);
--text-heading: #f1f5f9;
--text-primary: #e2e8f0;
--text-muted-foreground: #94a3b8;
--text-muted: #64748b;
--accent: #60a5fa;
--accent-dim: rgba(96, 165, 250, 0.10);
--accent-dim-strong: rgba(96, 165, 250, 0.16);
--accent-border: rgba(96, 165, 250, 0.30);
--warning: #fbbf24;
--warning-dim: rgba(251, 191, 36, 0.10);
--warning-dim-strong: rgba(251, 191, 36, 0.16);
--warning-border: rgba(251, 191, 36, 0.32);
--success: #34d399;
--success-dim: rgba(52, 211, 153, 0.10);
--success-dim-strong: rgba(52, 211, 153, 0.16);
--success-border: rgba(52, 211, 153, 0.30);
--info: #67e8f9;
--info-dim: rgba(103, 232, 249, 0.10);
--info-dim-strong: rgba(103, 232, 249, 0.16);
--info-border: rgba(103, 232, 249, 0.30);
--danger: #f87171;
--danger-dim: rgba(248, 113, 113, 0.10);
--danger-dim-strong: rgba(248, 113, 113, 0.16);
--danger-border: rgba(248, 113, 113, 0.30);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
background: var(--bg-sidebar);
color: var(--text-primary);
font-family: 'IBM Plex Sans', system-ui, -apple-system, sans-serif;
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
.page {
max-width: 1680px;
margin: 0 auto;
padding: 32px 24px 72px;
}
.page-header { margin-bottom: 24px; }
.page-title {
font-family: 'Bricolage Grotesque', sans-serif;
font-weight: 600;
font-size: 22px;
color: var(--text-heading);
letter-spacing: -0.01em;
}
.page-sub {
margin-top: 6px;
color: var(--text-muted-foreground);
font-size: 13px;
max-width: 1020px;
line-height: 1.55;
}
/* ====== Shared button styles ====== */
.btn {
appearance: none;
border: 1px solid var(--border-default);
background: var(--bg-card);
color: var(--text-primary);
padding: 8px 12px;
border-radius: 8px;
font-family: inherit;
font-weight: 500;
font-size: 12.5px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
cursor: pointer;
transition: border-color 0.12s, background-color 0.12s, color 0.12s;
white-space: nowrap;
}
.btn:hover { border-color: var(--border-hover); background: var(--bg-elevated); }
.btn-ghost {
background: transparent;
border-color: transparent;
color: var(--text-muted-foreground);
padding: 8px 10px;
}
.btn-ghost:hover {
background: rgba(148, 163, 184, 0.08);
color: var(--text-primary);
border-color: transparent;
}
.icon-btn {
width: 30px; height: 30px; padding: 0;
background: transparent; border: 1px solid transparent;
color: var(--text-muted-foreground);
}
.icon-btn:hover { background: rgba(148, 163, 184, 0.08); color: var(--text-primary); }
.btn-success {
background: var(--success); color: #0a1a12; border-color: transparent; font-weight: 600;
}
.btn-success:hover { background: #55e0af; color: #0a1a12; }
.btn-danger-outline {
background: transparent; color: var(--danger); border-color: var(--danger-border);
}
.btn-danger-outline:hover { background: var(--danger-dim); color: var(--danger); border-color: var(--danger); }
.btn-danger {
background: var(--danger); color: #180808; border-color: transparent; font-weight: 600;
}
.btn-danger:hover { background: #fa8a8a; color: #180808; }
/* ====== Frame ====== */
.frame {
background: var(--bg-page);
border: 1px solid var(--border-default);
border-radius: 14px;
overflow: hidden;
display: grid;
grid-template-columns: 1fr 380px;
height: 760px;
}
.chat {
display: flex; flex-direction: column;
background: var(--bg-page);
min-width: 0;
}
.chat-head {
padding: 14px 20px;
border-bottom: 1px solid var(--border-default);
background: var(--bg-sidebar);
display: flex; align-items: center; justify-content: space-between; gap: 12px;
}
.chat-head-title {
font-family: 'Bricolage Grotesque', sans-serif;
font-weight: 600; font-size: 14px; color: var(--text-heading);
}
.chat-head-sub {
font-size: 11.5px; color: var(--text-muted);
font-family: 'JetBrains Mono', monospace;
}
.chat-scroll {
flex: 1; overflow-y: auto;
padding: 24px 28px 16px;
display: flex; flex-direction: column; gap: 16px;
}
.msg { max-width: 640px; display: flex; gap: 10px; align-items: flex-start; }
.msg.user { align-self: flex-end; }
.msg-av {
width: 26px; height: 26px; border-radius: 50%;
flex-shrink: 0; font-size: 11px; font-weight: 600;
display: flex; align-items: center; justify-content: center; margin-top: 2px;
}
.msg.user .msg-av { background: var(--accent-dim); color: var(--accent); border: 1px solid var(--accent-border); }
.msg.ai .msg-av { background: var(--warning-dim); color: var(--warning); border: 1px solid var(--warning-border); }
.msg.system .msg-av { background: rgba(148,163,184,0.08); color: var(--text-muted); border: 1px solid var(--border-default); }
.msg-body {
background: var(--bg-card); border: 1px solid var(--border-default);
border-radius: 10px; padding: 10px 13px; font-size: 13px; color: var(--text-primary);
line-height: 1.55;
}
.msg.user .msg-body { background: var(--accent-dim); border-color: var(--accent-border); color: var(--text-heading); }
.msg.system .msg-body { background: transparent; border-style: dashed; color: var(--text-muted); font-size: 12px; font-style: italic; }
.msg-meta {
margin-top: 4px; font-size: 10.5px; color: var(--text-muted);
font-family: 'JetBrains Mono', monospace;
}
.composer-wrap { border-top: 1px solid var(--border-default); background: var(--bg-page); position: relative; }
.composer { padding: 14px 20px 16px; display: flex; align-items: flex-end; gap: 10px; }
.composer-input {
flex: 1; min-height: 44px; background: var(--bg-card);
border: 1px solid var(--border-default); border-radius: 10px;
padding: 10px 14px; color: var(--text-muted-foreground);
font-size: 13px; line-height: 1.4;
display: flex; align-items: center;
}
.composer-send {
width: 44px; height: 44px; border-radius: 10px;
background: var(--accent); color: #0a0d14; border: 0;
display: flex; align-items: center; justify-content: center; cursor: pointer;
}
/* ====== Banner generic ====== */
.banner {
position: relative;
padding: 12px 20px 14px;
display: flex; gap: 14px; align-items: flex-start;
border-top-width: 1px; border-top-style: solid;
animation: fadeIn 260ms ease-out both;
}
.banner::before {
content: '';
position: absolute; left: 0; top: 0; bottom: 0;
width: 3px;
}
@keyframes fadeIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
.banner-icon {
width: 28px; height: 28px; border-radius: 7px;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0; margin-top: 2px;
}
.banner-body { flex: 1; min-width: 0; }
.banner-head {
display: flex; align-items: center; gap: 8px;
font-size: 10px; font-weight: 600; letter-spacing: 1.2px;
text-transform: uppercase; font-family: 'Bricolage Grotesque', sans-serif;
}
.banner-title {
margin-top: 3px; font-size: 14px; font-weight: 600;
color: var(--text-heading); line-height: 1.35; letter-spacing: -0.005em;
}
.banner-note {
margin-top: 3px; font-size: 12.5px; color: var(--text-muted-foreground);
line-height: 1.5;
}
.banner-actions {
display: flex; gap: 8px; align-items: center;
flex-shrink: 0; padding-top: 2px;
}
.pill {
padding: 2px 7px; border-radius: 999px;
font-size: 10.5px; font-weight: 700; letter-spacing: 0.5px;
font-variant-numeric: tabular-nums;
}
/* Verifying — amber pulse, mirrors the proposed color but with pulse */
.banner-verify {
background: linear-gradient(180deg, var(--warning-dim-strong) 0%, var(--warning-dim) 100%);
border-top-color: var(--warning-border);
}
.banner-verify::before { background: var(--warning); }
.banner-verify .banner-icon {
background: var(--warning-dim-strong); border: 1px solid var(--warning-border); color: var(--warning);
position: relative;
}
.banner-verify .banner-icon::after {
content: ''; position: absolute; inset: -3px; border-radius: 9px;
box-shadow: 0 0 0 0 rgba(251, 191, 36, 0.45);
animation: pulseAmber 1.6s infinite;
}
@keyframes pulseAmber {
0% { box-shadow: 0 0 0 0 rgba(251, 191, 36, 0.45); }
70% { box-shadow: 0 0 0 10px rgba(251, 191, 36, 0); }
100% { box-shadow: 0 0 0 0 rgba(251, 191, 36, 0); }
}
.banner-verify .banner-head { color: var(--warning); }
.banner-verify .pill { background: rgba(251, 191, 36, 0.20); color: var(--warning); }
/* Partial — muted info/cyan to communicate "parked, outcome unknown" */
.banner-partial {
background: linear-gradient(180deg, var(--info-dim-strong) 0%, var(--info-dim) 100%);
border-top-color: var(--info-border);
}
.banner-partial::before { background: var(--info); }
.banner-partial .banner-icon { background: var(--info-dim-strong); border: 1px solid var(--info-border); color: var(--info); }
.banner-partial .banner-head { color: var(--info); }
.banner-partial .pill { background: rgba(103, 232, 249, 0.18); color: var(--info); }
/* AI-inferred — accent blue, AI-sourced */
.banner-ai {
background: linear-gradient(180deg, var(--accent-dim-strong) 0%, var(--accent-dim) 100%);
border-top-color: var(--accent-border);
}
.banner-ai::before { background: var(--accent); }
.banner-ai .banner-icon { background: var(--accent-dim-strong); border: 1px solid var(--accent-border); color: var(--accent); }
.banner-ai .banner-head { color: var(--accent); }
.banner-ai .pill { background: rgba(96, 165, 250, 0.20); color: var(--accent); }
/* Nudge — compact strip */
.banner-nudge {
padding: 8px 20px;
background: var(--warning-dim);
border-top-color: var(--warning-border);
align-items: center;
gap: 10px;
}
.banner-nudge::before { background: var(--warning); }
.banner-nudge .nudge-icon {
width: 16px; height: 16px; flex-shrink: 0; color: var(--warning);
}
.banner-nudge .nudge-title {
flex: 1; font-size: 12.5px; color: var(--text-primary); font-weight: 500;
}
/* ====== Task lane ====== */
.lane {
border-left: 1px solid var(--border-default);
background: var(--bg-sidebar);
display: flex; flex-direction: column; min-height: 0;
}
.lane-head {
padding: 14px 16px;
border-bottom: 1px solid var(--border-default);
display: flex; align-items: center; justify-content: space-between;
}
.lane-head-label {
font-family: 'Bricolage Grotesque', sans-serif;
font-weight: 600; font-size: 13px; color: var(--text-heading);
}
.lane-body {
flex: 1; overflow-y: auto;
padding: 14px 14px 10px;
display: flex; flex-direction: column; gap: 16px;
}
.section-label {
display: flex; align-items: center; gap: 8px;
font-size: 10px; font-weight: 600; letter-spacing: 1.2px;
text-transform: uppercase; color: var(--text-muted-foreground);
padding: 0 2px 8px;
}
.dot { width: 6px; height: 6px; border-radius: 50%; display: inline-block; }
.dot-accent { background: var(--accent); }
.dot-danger { background: var(--danger); }
.section-meta {
color: var(--text-muted); font-weight: 500; letter-spacing: 0; text-transform: none;
}
.fact {
background: var(--bg-card); border: 1px solid var(--border-default);
border-left: 3px solid var(--accent); border-radius: 8px;
padding: 10px 12px;
}
.fact + .fact { margin-top: 8px; }
.fact-title { font-size: 12.5px; font-weight: 500; color: var(--text-heading); line-height: 1.4; }
.fact-meta { margin-top: 3px; font-size: 10.5px; color: var(--text-muted); font-family: 'JetBrains Mono', monospace; }
.failed-pill {
padding: 9px 11px; background: var(--bg-card);
border: 1px dashed var(--danger-border); border-radius: 8px;
display: flex; align-items: center; gap: 8px;
font-size: 11.5px; color: var(--text-muted-foreground);
}
.failed-pill-title { flex: 1; color: var(--text-heading); font-weight: 500; }
.failed-pill-badge {
padding: 1px 6px; border-radius: 4px; font-size: 9.5px;
font-weight: 700; letter-spacing: 0.4px;
background: var(--danger-dim); color: var(--danger);
text-transform: uppercase;
}
.action-bar {
border-top: 1px solid var(--border-default);
padding: 12px 14px 14px;
display: flex; gap: 8px;
position: relative;
}
.btn-escalate { flex: 0 0 auto; min-width: 96px; background: transparent; color: var(--text-muted-foreground); }
.btn-resolve {
flex: 1; background: var(--accent); color: #0a0d14;
border-color: transparent; font-weight: 600; padding: 10px 12px;
}
.btn-resolve:hover { background: #7ab4fb; color: #0a0d14; }
/* ====== Callouts ====== */
.callout {
margin-top: 20px; padding: 14px 16px;
background: var(--bg-page); border: 1px solid var(--border-default);
border-radius: 10px; font-size: 13px; color: var(--text-muted-foreground);
line-height: 1.55; border-left: 3px solid var(--warning);
}
.callout strong { color: var(--text-heading); font-weight: 600; }
/* ====== State detail panels ====== */
.states-title {
margin-top: 48px; font-family: 'Bricolage Grotesque', sans-serif;
font-weight: 600; font-size: 18px; color: var(--text-heading);
}
.states-sub { margin-top: 4px; color: var(--text-muted-foreground); font-size: 13px; }
.states {
margin-top: 16px;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20px;
}
.state {
background: var(--bg-page); border: 1px solid var(--border-default);
border-radius: 10px; overflow: hidden;
display: flex; flex-direction: column;
}
.state-label {
padding: 10px 14px; border-bottom: 1px solid var(--border-default);
font-family: 'Bricolage Grotesque', sans-serif;
font-weight: 600; font-size: 12.5px; color: var(--text-heading);
background: var(--bg-sidebar);
}
.state-body {
padding: 0; background: var(--bg-page);
min-height: 280px;
display: flex; flex-direction: column; justify-content: flex-end;
position: relative;
}
.state-mini-chat {
flex: 1; padding: 14px 16px;
font-size: 11px; color: var(--text-muted);
display: flex; align-items: flex-end; gap: 6px;
font-family: 'JetBrains Mono', monospace;
opacity: 0.6;
}
.mini-composer {
border-top: 1px solid var(--border-default);
padding: 10px 14px; display: flex; gap: 8px; align-items: center;
}
.mini-input {
flex: 1; background: var(--bg-card);
border: 1px solid var(--border-default); border-radius: 8px;
padding: 7px 10px; font-size: 11.5px; color: var(--text-muted);
}
.mini-send {
width: 28px; height: 28px; border-radius: 7px;
background: var(--accent); color: #0a0d14; border: 0; font-size: 14px;
display: flex; align-items: center; justify-content: center;
}
.state-caption {
padding: 10px 14px 12px; font-size: 11.5px;
color: var(--text-muted-foreground); line-height: 1.5;
border-top: 1px solid var(--border-default); background: var(--bg-sidebar);
}
.state-caption strong { color: var(--text-heading); font-weight: 600; }
/* ====== Escalate intercept popover ====== */
.intercept-wrap {
position: relative;
padding: 24px 14px 14px;
background: var(--bg-page);
flex: 1;
display: flex;
align-items: flex-end;
justify-content: flex-start;
}
.intercept-popover {
position: absolute;
bottom: 70px;
left: 14px;
width: 340px;
background: var(--bg-card);
border: 1px solid var(--border-hover);
border-radius: 10px;
padding: 14px;
box-shadow: 0 18px 40px rgba(0,0,0,0.55), 0 0 0 1px rgba(96,165,250,0.15);
}
.intercept-popover::after {
content: '';
position: absolute;
bottom: -7px; left: 40px;
width: 14px; height: 14px;
background: var(--bg-card);
border-right: 1px solid var(--border-hover);
border-bottom: 1px solid var(--border-hover);
transform: rotate(45deg);
}
.intercept-head {
font-family: 'Bricolage Grotesque', sans-serif;
font-weight: 600; font-size: 13px; color: var(--text-heading);
margin-bottom: 4px;
}
.intercept-sub {
font-size: 12px; color: var(--text-muted-foreground);
line-height: 1.5; margin-bottom: 12px;
}
.intercept-options {
display: flex; flex-direction: column; gap: 6px;
}
.intercept-option {
display: flex; align-items: center; gap: 10px;
padding: 10px 12px; border-radius: 8px;
background: var(--bg-elevated); border: 1px solid var(--border-default);
font-size: 12.5px; color: var(--text-primary);
cursor: pointer; text-align: left; width: 100%;
transition: border-color 0.12s, background-color 0.12s;
font-family: inherit;
}
.intercept-option:hover { border-color: var(--border-hover); background: var(--bg-sidebar); }
.intercept-option.primary {
border-color: var(--danger-border); background: var(--danger-dim);
}
.intercept-option.primary:hover { border-color: var(--danger); background: var(--danger-dim-strong); }
.intercept-kbd {
margin-left: auto; font-size: 10.5px; color: var(--text-muted);
font-family: 'JetBrains Mono', monospace;
background: rgba(148,163,184,0.08);
padding: 2px 6px; border-radius: 4px;
}
.mock-btn-row {
display: flex; gap: 8px;
padding: 12px 14px 14px;
border-top: 1px solid var(--border-default);
}
.mock-escalate {
background: transparent; color: var(--text-muted-foreground);
border: 1px solid var(--border-default); padding: 9px 14px;
border-radius: 8px; font-size: 12.5px; min-width: 96px;
position: relative;
}
.mock-escalate.active {
border-color: var(--danger-border); color: var(--danger);
background: var(--danger-dim);
}
.mock-resolve {
flex: 1; background: var(--accent); color: #0a0d14;
border: 0; font-weight: 600; padding: 9px 12px;
border-radius: 8px; font-size: 12.5px;
}
/* Partial inline input row */
.partial-note {
margin-top: 4px;
padding: 6px 10px;
background: rgba(103, 232, 249, 0.08);
border: 1px solid var(--info-border);
border-radius: 6px;
font-size: 12px; color: var(--text-primary);
display: flex; align-items: center; gap: 8px;
font-style: italic;
}
.partial-note-label {
font-style: normal; color: var(--info);
font-size: 10.5px; font-weight: 700; letter-spacing: 0.6px;
text-transform: uppercase;
}
.lane-body::-webkit-scrollbar,
.chat-scroll::-webkit-scrollbar { width: 6px; }
.lane-body::-webkit-scrollbar-thumb,
.chat-scroll::-webkit-scrollbar-thumb { background: var(--border-hover); border-radius: 3px; }
</style>
</head>
<body>
<div class="page">
<div class="page-header">
<div class="page-title">Post-apply outcome states — how we recognize whether a fix worked</div>
<div class="page-sub">
Hero frame shows the <strong style="color:var(--text-primary)">Verifying</strong> state — what the banner becomes the moment the engineer clicks Apply. Below, four detail panels show the other outcome paths: <strong style="color:var(--text-primary)">Partial apply</strong>, <strong style="color:var(--text-primary)">AI-inferred outcome</strong> from chat, <strong style="color:var(--text-primary)">Escalate-intercept</strong>, and the <strong style="color:var(--text-primary)">Nudge</strong> that appears when the engineer keeps chatting without confirming.
</div>
</div>
<!-- ============ HERO: VERIFYING ============ -->
<div class="frame">
<div class="chat">
<div class="chat-head">
<div>
<div class="chat-head-title">Outlook won't authenticate after tenant migration</div>
<div class="chat-head-sub">ticket #48213 · in progress · 14:26</div>
</div>
</div>
<div class="chat-scroll">
<div class="msg ai">
<div class="msg-av">AI</div>
<div>
<div class="msg-body">Given Credential Manager still has entries for the prior tenant, the cleanest path is to clear those and rebuild the local Outlook profile.</div>
<div class="msg-meta">14:22</div>
</div>
</div>
<div class="msg user">
<div>
<div class="msg-body">Okay, I'll run the script now.</div>
<div class="msg-meta">14:24</div>
</div>
<div class="msg-av">ME</div>
</div>
<div class="msg system">
<div class="msg-av"></div>
<div>
<div class="msg-body">Applied fix: Clear cached credentials + rebuild Outlook profile — script completed without errors at 14:24.</div>
</div>
</div>
</div>
<!-- VERIFY BANNER (persistent after Apply) -->
<div class="composer-wrap">
<div class="banner banner-verify" role="region" aria-label="Verify fix outcome">
<div class="banner-icon">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
</div>
<div class="banner-body">
<div class="banner-head">
<span>Verifying</span>
<span class="pill">Applied 14:24 · 2m ago</span>
</div>
<div class="banner-title">Did "Clear cached credentials + rebuild Outlook profile" work?</div>
<div class="banner-note">Mark the outcome so the AI can either close the session with this as the resolution, or propose something else.</div>
</div>
<div class="banner-actions">
<button class="btn btn-ghost" aria-label="More options" title="Mark partial apply, re-open details">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/></svg>
</button>
<button class="btn btn-danger-outline">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
Didn't work
</button>
<button class="btn btn-success">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
It worked
</button>
</div>
</div>
<div class="composer">
<div class="composer-input">Tell the AI what happened — or click an outcome above</div>
<button class="composer-send" aria-label="Send">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
</button>
</div>
</div>
</div>
<!-- Task lane: fix is now in "verifying" status — no longer a standalone suggested fix -->
<div class="lane">
<div class="lane-head">
<div class="lane-head-label">Task lane</div>
</div>
<div class="lane-body">
<section>
<div class="section-label">
<span class="dot dot-accent"></span>
What we know
<span class="section-meta">· 5 facts</span>
</div>
<div class="fact">
<div class="fact-title">User cannot authenticate to Outlook; repeated 401s from Exchange Online.</div>
<div class="fact-meta">promoted 14:02 · from ticket</div>
</div>
<div class="fact">
<div class="fact-title">Credential Manager still references the prior tenant from six months ago.</div>
<div class="fact-meta">promoted 14:07 · from chat</div>
</div>
<div class="fact">
<div class="fact-title">Teams + SharePoint work on same workstation — isolated to Outlook.</div>
<div class="fact-meta">promoted 14:22 · from chat</div>
</div>
</section>
</div>
<div class="action-bar">
<button class="btn btn-escalate">Escalate</button>
<button class="btn btn-resolve">Resolve</button>
</div>
</div>
</div>
<div class="callout">
<strong>How Verifying works.</strong> Clicking Apply transitions the banner into this state instead of dismissing it. No timeout — the banner stays pinned until the engineer marks <em>Worked</em>, <em>Didn't work</em>, or <em>Partial</em> (overflow). If they ignore it and keep chatting, the Nudge state (panel D below) appears after a few messages. If they hit the task lane's <em>Resolve</em> button without clicking either outcome, we auto-stamp <code style="font-family:'JetBrains Mono',monospace;font-size:11.5px;background:var(--bg-card);padding:1px 5px;border-radius:3px;">applied_success</code>. If they hit <em>Escalate</em>, panel C intercepts.
</div>
<!-- ============ DETAIL PANELS ============ -->
<div class="states-title">Outcome branches</div>
<div class="states-sub">Four paths from Verifying to a final status. Each one writes to <code style="font-family:'JetBrains Mono',monospace;font-size:12px;background:var(--bg-card);padding:1px 6px;border-radius:3px;color:var(--text-primary)">session_suggested_fixes.status</code> so the AI's next turn has ground truth about what's been tried.</div>
<div class="states">
<!-- A. PARTIAL -->
<div class="state">
<div class="state-label">A. Partial apply — "I did some of it"</div>
<div class="state-body">
<div class="state-mini-chat">…engineer picked "Mark partial…" from the verify banner's overflow menu</div>
<div class="banner banner-partial">
<div class="banner-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
</div>
<div class="banner-body">
<div class="banner-head">
<span>Partially applied</span>
<span class="pill">Parked</span>
</div>
<div class="banner-title">Clear cached credentials + rebuild Outlook profile</div>
<div class="partial-note">
<span class="partial-note-label">Note</span>
<span>Ran cred clear — skipped profile rebuild, user in a meeting. Back at 3:30.</span>
</div>
</div>
<div class="banner-actions">
<button class="btn btn-ghost">Edit note</button>
<button class="btn btn-danger-outline">Didn't work</button>
<button class="btn">Finish it </button>
</div>
</div>
<div class="mini-composer">
<div class="mini-input">Type a message…</div>
<button class="mini-send"></button>
</div>
</div>
<div class="state-caption">
<strong>Status:</strong> <code style="font-family:'JetBrains Mono',monospace;font-size:11.5px">applied_partial</code>, with <code style="font-family:'JetBrains Mono',monospace;font-size:11.5px">partial_notes</code> free-text. Not terminal — banner stays pinned until engineer marks a terminal outcome, or clicks <em>Finish it</em> to re-run the remainder and flip back to Verifying. AI treats partial as "tried but uncertain" — doesn't re-propose, but doesn't assume failure either.
</div>
</div>
<!-- B. AI-INFERRED CONFIRM -->
<div class="state">
<div class="state-label">B. AI-inferred outcome — from chat</div>
<div class="state-body">
<div class="state-mini-chat" style="flex-direction:column;align-items:flex-end;gap:8px;opacity:0.8">
<div style="background:var(--bg-card);border:1px solid var(--border-default);border-radius:10px;padding:8px 12px;font-size:12px;color:var(--text-heading);font-style:normal;font-family:inherit;max-width:80%;"><strong style="font-weight:500">Engineer:</strong> "yep that fixed it, thanks"</div>
<div style="font-size:10.5px;color:var(--text-muted);padding-right:2px;">14:31 · user message triggered <code style="font-family:'JetBrains Mono',monospace;font-size:10.5px">[FIX_OUTCOME]</code></div>
</div>
<div class="banner banner-ai">
<div class="banner-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/></svg>
</div>
<div class="banner-body">
<div class="banner-head">
<span>AI detected outcome</span>
<span class="pill">Success · 92%</span>
</div>
<div class="banner-title">AI thinks the fix resolved the issue — confirm?</div>
<div class="banner-note">Based on your message at 14:31. One click closes the session with this fix as the documented resolution.</div>
</div>
<div class="banner-actions">
<button class="btn btn-ghost">Not yet</button>
<button class="btn btn-danger-outline">No, didn't work</button>
<button class="btn btn-success">Confirm · Resolve</button>
</div>
</div>
<div class="mini-composer">
<div class="mini-input">Type a message…</div>
<button class="mini-send"></button>
</div>
</div>
<div class="state-caption">
<strong>Triggered by</strong> the new <code style="font-family:'JetBrains Mono',monospace;font-size:11.5px">[FIX_OUTCOME fix_id=… outcome=success]</code> marker from the system prompt. Engineer stays in the loop — the AI <em>proposes</em> the outcome, doesn't set it. One-click accept fires the normal Resolve flow. Works for failure too ("still broken" → <em>No, didn't work</em> pre-selected, with the AI's reasoning shown).
</div>
</div>
<!-- C. ESCALATE INTERCEPT -->
<div class="state">
<div class="state-label">C. Escalate-intercept — capture outcome before handoff</div>
<div class="state-body">
<div class="intercept-wrap">
<div class="intercept-popover">
<div class="intercept-head">Before escalating — what happened with the fix?</div>
<div class="intercept-sub">"Clear cached credentials" is still in the Verifying state. Tag its outcome so the senior picking this up knows what's been tried.</div>
<div class="intercept-options">
<button class="intercept-option primary">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--danger)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
The fix didn't work
<span class="intercept-kbd"></span>
</button>
<button class="intercept-option">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
It worked — escalating for another reason
</button>
<button class="intercept-option">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
Never actually applied it
</button>
</div>
</div>
</div>
<div class="mock-btn-row">
<button class="mock-escalate active">Escalate</button>
<button class="mock-resolve">Resolve</button>
</div>
</div>
<div class="state-caption">
<strong>Fires when</strong> engineer clicks Escalate while a fix is in Verifying (or Partial). Defaults to <em>Didn't work</em> on Enter — common case. <em>Escalating for another reason</em> preserves success; <em>Never applied</em> flips to <code style="font-family:'JetBrains Mono',monospace;font-size:11.5px">dismissed</code>. Takes 1s and makes the escalation narrative honest for whoever picks it up.
</div>
</div>
<!-- D. NUDGE -->
<div class="state">
<div class="state-label">D. Nudge — passive prompt after a few messages</div>
<div class="state-body">
<div class="state-mini-chat" style="flex-direction:column;align-items:flex-end;gap:6px;opacity:0.8;">
<div style="background:var(--bg-card);border:1px solid var(--border-default);border-radius:10px;padding:7px 11px;font-size:11.5px;color:var(--text-heading);font-style:normal;font-family:inherit;max-width:70%;">"user is rebooting"</div>
<div style="background:var(--bg-card);border:1px solid var(--border-default);border-radius:10px;padding:7px 11px;font-size:11.5px;color:var(--text-heading);font-style:normal;font-family:inherit;max-width:75%;">"okay it's back up, signing in now"</div>
<div style="background:var(--bg-card);border:1px solid var(--border-default);border-radius:10px;padding:7px 11px;font-size:11.5px;color:var(--text-heading);font-style:normal;font-family:inherit;max-width:75%;">"going to try opening Outlook"</div>
</div>
<div class="banner banner-nudge">
<svg class="nudge-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 8v4"/><path d="M12 16h.01"/></svg>
<span class="nudge-title">Did <strong style="color:var(--text-heading)">"Clear cached credentials"</strong> work?</span>
<button class="btn btn-ghost" style="padding:4px 10px">Still checking</button>
<button class="btn btn-danger-outline" style="padding:4px 10px">No</button>
<button class="btn btn-success" style="padding:4px 10px">Yes</button>
</div>
<div class="mini-composer">
<div class="mini-input">Type a message…</div>
<button class="mini-send"></button>
</div>
</div>
<div class="state-caption">
<strong>Appears after</strong> ~3 post-apply engineer messages with no outcome click. Collapses the verify banner into this thin nudge strip above it so chat space isn't eaten. Passive — never auto-marks anything. <em>Still checking</em> silences the nudge for another 3 messages. Yes/No route to the normal Success / Failed flows.
</div>
</div>
</div>
</div>
</body>
</html>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,384 @@
# FlowPilot Phase 9 — Tabbed Script Builder + NoTemplateDialog relocation
**Date:** 2026-04-23
**Branch target:** `feat/flowpilot-migration` (continuation of Phases 08)
**Depends on:** Phase 8 (ProposalBanner in chat region)
---
## Goal
Close the two remaining open items from the FlowPilot migration handoff:
1. **NoTemplateDialog narrow-lane bug** — today the dialog renders in the task lane (~340px) and its `grid-cols-1 sm:grid-cols-3` layout crushes the three option cards. When the AI proposes a fix with no drafted script, all three cards render disabled, producing a dead end.
2. **Tabbed Script Builder inside the chat** — give the engineer a way to draft the missing script without leaving the session (either by chatting with the AI or hand-writing in a code editor), then feed the draft back into the existing fix lifecycle.
Plus two Phase 8 cleanup items flagged during code review:
3. **`EscalateInterceptDialog` missing the Partial choice** — if a fix is in `applied_partial` when the engineer escalates, the intercept dialog's current three choices (worked / didn't work / never applied) don't match. Add a fourth choice for partial.
4. **`applied_at` semantics correction** — today Phase 8's `handleApplyFix` stamps `applied_at` on every banner Apply click, starting the Verifying timer even when the engineer is only opening a drafting/evaluation surface. Move the stamp to the actual run-action handlers (see §5).
This phase depends on Phase 8's `ProposalBanner` already being in the chat region — it reuses the same "chat-region-owns-Apply-flow" philosophy.
---
## Architectural decisions (settled during brainstorming)
| # | Decision | Rationale |
|---|---|---|
| 1 | When a fix has no `ai_drafted_script`, the banner's Apply button routes **directly** to the Script Builder tab (bypassing `NoTemplateDialog` entirely). | Banner is the single entry point for Apply. `NoTemplateDialog` stays narrowly scoped to evaluating a draft that actually exists. |
| 2 | Inside the Script Builder tab, the default experience is AI-driven — a new `ScriptBuilderTab` controller owns session lifecycle + submit, and *renders* `ScriptBuilderChat` (which stays purely presentational). A "✎ Write it myself" button in the tab's header toolbar swaps the controller's render into a Monaco editor. | AI is the common path. Persistence semantics belong on the controller, not the chat display component (`ScriptBuilderChat` already exposes `onSaveScript` as its seam — the controller wires that callback). |
| 3 | The manual editor uses **Monaco**, reusing the pattern from `frontend/src/components/tree-editor/code-mode/CodeModeEditor.tsx`. | Monaco is already a dependency (`@monaco-editor/react` + `monaco-editor`). No bundle cost, proven pattern. |
| 4 | The Script Builder tab is **always present while the fix is non-terminal** (no close affordance). An **indicator dot** on the tab signals in-progress draft state. | Matches Phase 8's `display: none` philosophy — engineers move freely between chat and draft without tracking a separate open/close state. |
| 5 | `NoTemplateDialog` (draft-exists case) moves from `TaskLane.bottomSlot` to the **chat region** (sibling of `ProposalBanner`, slides up above composer). | Script evaluation is an action surface, not a context surface — belongs with the other action surfaces. Chat region is wide enough for the three cards to actually fit side-by-side. |
| 6 | `EscalateInterceptDialog` gains a **fourth "Partial" choice** that writes `applied_partial` with a notes prompt. | Closes the gap flagged in Phase 8 final review. Minimal incremental cost since the dialog is already getting touched. |
| 7 | `applied_at` is stamped only when the engineer commits to an action that **runs or triggers** a script — not on banner Apply click. Opening a drafting/evaluation surface no longer starts the Verifying timer. | Prevents false "applied" state when the engineer is still authoring. Corrects a Phase 8 over-eager stamp that this phase would otherwise multiply across three surfaces. |
---
## Architecture
### 1. Chat region gets a tab strip
A two-tab strip at the top of the chat region:
```
┌──────────────────────────────────────┐
│ [Chat] [Script Builder ●] │
├──────────────────────────────────────┤
│ │
│ (content per active tab, │
│ via display:none toggling) │
│ │
└──────────────────────────────────────┘
```
- **When the strip renders:** only when an `activeFix` exists AND the fix is non-terminal AND (`fix.ai_drafted_script` is null AND `fix.script_template_id` is null) — i.e., the fix genuinely needs a script drafted. Otherwise the chat region shows without tabs.
- **Tab switching uses `display: none`**, not unmount. Chat scroll position, draft message, and Script Builder state all persist across switches.
- **Indicator dot** on the Script Builder tab fires when there's in-progress draft state: at least one AI message sent in the `ScriptBuilderChat`, or non-empty Monaco buffer. Clears when the draft is submitted.
- **Session switch** clears tab state via the existing `resetSessionDerivedState` helper.
### 2. Script Builder tab content
A new controller component `ScriptBuilderTab` owns the inline lifecycle:
- Creates / resumes a `script_builder_sessions` row with `origin='pilot_inline'` + `ai_session_id = <pilot session id>`.
- Manages AI-chat message state (via the existing script-builder message endpoints) and the Monaco editor buffer.
- On submit, fires `PATCH /ai-sessions/{sid}/suggested-fixes/{fid}/script`.
`ScriptBuilderChat` itself is **unchanged** — it stays a pure display component taking `messages`, `language`, `onViewScript`, `onSaveScript`, `isLoading`. The controller wires `onSaveScript` to its submit path instead of the template-creation path the standalone `/script-builder` page uses.
A header toolbar above the controller's render area hosts the mode toggle:
```
┌──────────────────────────────────────┐
│ Script Builder · Outlook fix │
│ [✎ Write myself]│
├──────────────────────────────────────┤
│ (mode-specific content) │
│ │
└──────────────────────────────────────┘
```
- Clicking **✎ Write myself** flips `scriptBuilderMode` to `'editor'` — the controller renders Monaco in place of `ScriptBuilderChat`, pre-loaded with a scaffold (fix description as a language-appropriate comment header + an empty body).
- A reciprocal **✨ Back to AI** button in editor mode returns to the chat.
- Switching modes **does not discard** work. The Monaco buffer and the script-builder session both persist across toggles. This matters when an engineer drafts with AI, switches to editor to tweak a line, then considers going back.
- Both modes share a single terminal action: the controller's **Submit → `PATCH /ai-sessions/{sid}/suggested-fixes/{fid}/script`**. On success the fix gains `ai_drafted_script`; the tab strip disappears (since the fix no longer needs a script) and the banner's Apply button now routes to `NoTemplateDialog` in the chat region.
- **Submit does NOT stamp `applied_at`.** A draft is not an application — see §5 Apply lifecycle below.
### 3. NoTemplateDialog relocation to chat region
- Removed from `TaskLane.bottomSlot`. Renders in the chat region, slide-up-above-composer (same mechanical placement as `ProposalBanner`).
- The three-card layout (`grid-cols-3` at the chat region's natural width) actually fits — no `grid-cols-1` regression needed.
- Opens when the engineer clicks Apply on the banner AND `fix.ai_drafted_script` is non-empty.
- Decision semantics unchanged (still `one_off` / `draft_template` / `build_template` with the same server-side effects) except for the moved apply stamp — see §5. Only the render location changes beyond that.
### 4. Banner Apply routing (updated)
Three mutually-exclusive outcomes based on the fix's shape:
```
handleApplyFix():
if fix.script_template_id:
open TemplateMatchPanel (unchanged — still renders in task lane for now)
elif fix.ai_drafted_script:
open NoTemplateDialog in chat region (new location, Chat tab)
else:
open Script Builder tab in chat region (new tab)
```
The NoTemplateDialog-in-chat-region path lives on the **Chat tab** (slides up above composer; the tab strip only renders for the no-draft case, so when NoTemplateDialog shows, the tab strip is not on screen). The Script Builder tab path is the opposite — tab strip renders, engineer is on the Script Builder tab.
`TemplateMatchPanel` stays in the task lane for this phase — it's a different surface with different interactions and it's not broken. Moving it is possible future work.
### 5. Apply lifecycle — `applied_at` semantics correction
**Problem.** Today (Phase 8) `handleApplyFix` calls `POST /apply` the moment the banner's Apply button is clicked, stamping `applied_at` regardless of what happens next. This starts the Verifying timer (nudge countdown, Resolve auto-success, Escalate intercept) even if the engineer is only opening a drafting surface and hasn't actually run anything yet. For the no-draft path introduced in this phase, that's clearly wrong — opening the Script Builder tab is the start of *authoring*, not the start of *verifying*.
**Rule.** `applied_at` is stamped **only when the engineer commits to an action that produces or triggers a run**, not when they open a surface:
| Banner Apply click → routes to... | Stamps `applied_at`? |
|---|---|
| `TemplateMatchPanel` (existing flow) | Only when the engineer clicks a new explicit **"✓ I ran this"** action inside the panel (see below) |
| `NoTemplateDialog``one_off` card ("Run now, no template") | **Yes** — the card click declares "I'm running this now" |
| `NoTemplateDialog``draft_template` card ("Run now, templatize after") | **Yes** — same declaration, the template proposal is a side effect |
| `NoTemplateDialog``build_template` card ("Just open the builder") | No — no run is declared; the engineer is going off to author a proper template |
| Script Builder tab → Submit | No — just produces a draft. Engineer then clicks Apply again, gets `NoTemplateDialog`, picks `one_off` or `draft_template` to declare the run |
**New explicit "I ran this" action in `TemplateMatchPanel`.** Today the panel has Generate, Copy, and Edit Parameters — none of which commit to running. Copying doesn't imply running; the engineer can walk away. This phase adds a distinct primary button (accent-colored, below Copy) labeled **"✓ I ran this"** or **"Mark as applied"**. Click → calls `applyFix` → fix transitions to Verifying. Until clicked, the fix stays in `proposed`.
**Implementation.**
- Remove `sessionSuggestedFixesApi.applyFix(...)` call from `handleApplyFix`. Move it to the three run-declaring call sites: `NoTemplateDialog`'s `handleScriptDecision('one_off' | 'draft_template')` paths AND the new `TemplateMatchPanel` "I ran this" button. The `applyFix` endpoint itself (from Phase 8 Issue #2) stays unchanged — only its call sites move.
- Until `applied_at` is stamped, the fix remains in `proposed`. `bannerMode` computation already returns `'proposed'` when `applied_at` is null, so the banner naturally stays on Proposed state through the entire drafting phase.
- **Phase 8 consequence.** This is a semantic revision of Phase 8, not just Phase 9 behavior. Tests must assert: opening `TemplateMatchPanel` does NOT stamp `applied_at`; clicking "I ran this" DOES; `NoTemplateDialog` `one_off` AND `draft_template` both DO; `build_template` does NOT.
### 6. EscalateInterceptDialog partial choice
Adds a fourth button to the existing popover:
| Existing choices | New choice |
|---|---|
| The fix didn't work | (existing) |
| It worked — escalating for another reason | (existing) |
| Never actually applied it | (existing) |
| **I applied some of it — partial** | **NEW** |
- When clicked: prompts for partial notes (same pattern as the banner's Partial path — `window.prompt` for now, matching Phase 8's interim), then calls `patchOutcome('applied_partial', notes)`.
- `handleInterceptChoice` gains an `applied_partial` branch. The `InterceptChoice` type already includes `'applied_partial'` via `FixOutcome | 'never_applied'`, so no type changes needed.
- When a fix enters the dialog already in `applied_partial` state, the fourth button is hidden (can't transition partial → partial with different semantics). The "didn't work" button remains available to progress to `applied_failed`.
---
## Data model
### New migration
`script_builder_sessions` **already has** `ai_session_id` (FK → `ai_sessions.id`, nullable, `ON DELETE SET NULL`) with the comment "Link to FlowPilot session if launched from there." The existing column is the link we need — no new FK is added. The migration introduces only the `origin` discriminator plus a uniqueness guard for inline sessions:
```sql
ALTER TABLE script_builder_sessions
ADD COLUMN origin VARCHAR(20) NOT NULL DEFAULT 'standalone';
ALTER TABLE script_builder_sessions
ADD CONSTRAINT ck_script_builder_sessions_origin
CHECK (origin IN ('standalone', 'pilot_inline'));
-- Invariant: pilot_inline rows must be linked to a pilot session.
-- Standalone rows may or may not be linked (legacy back-channel).
ALTER TABLE script_builder_sessions
ADD CONSTRAINT ck_script_builder_sessions_origin_ai_session
CHECK (origin <> 'pilot_inline' OR ai_session_id IS NOT NULL);
-- Uniqueness: at most one pilot_inline session per (user, pilot session).
-- Required to back the get-or-create semantics on the endpoint and prevent
-- duplicate scratch rows on remount. Partial index scoped to pilot_inline
-- so standalone rows are unaffected.
CREATE UNIQUE INDEX ux_script_builder_sessions_pilot_inline
ON script_builder_sessions (user_id, ai_session_id)
WHERE origin = 'pilot_inline';
```
`origin = 'standalone'` → existing `/script-builder` page usage (existing rows backfill to this default). `origin = 'pilot_inline'` → new Script Builder tab; `ai_session_id` is populated at row creation.
`origin` earns its keep as an explicit discriminator for:
- Filtering (`list_sessions` / `count_user_sessions` exclude `pilot_inline` by default — see §Data model filter changes below).
- Future split-quota billing (decided to count as one billable session for now, but tagged for analytics).
### Data-model filter changes — `script_builder_sessions` list + count
Inline sessions would otherwise pollute the standalone `/script-builder` dashboard and count against the per-user 5-session cap enforced by the `POST /script-builder/sessions` endpoint. Required changes:
- `script_builder_service.list_sessions(user_id)` → default scope `origin = 'standalone'`. Callers that genuinely want all rows (e.g., an admin dashboard in a future phase) can pass an explicit `include_inline=True` flag, but no current caller needs it.
- `script_builder_service.count_user_sessions(user_id)` → same scope.
- Both changes covered by tests:
- 5 `pilot_inline` sessions should still leave the engineer free to create 5 standalone sessions (no cap interaction).
- `list_sessions` returns only `standalone` rows.
### New backend endpoint
```
PATCH /api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/script
```
Request:
```json
{
"ai_drafted_script": "string (required, 1..50_000 chars)",
"ai_drafted_parameters": { /* optional JSONB */ }
}
```
Behavior:
- Auth: `require_engineer_or_admin` + `_load_session_or_404`.
- 404 if fix not found on that session.
- 409 if fix is in a terminal status (`applied_success`, `applied_failed`, `dismissed`) — a drafted script can't be attached after the fix is done.
- Sets `fix.ai_drafted_script` + `fix.ai_drafted_parameters`.
- **Does NOT stamp `fix.applied_at`.** A draft is not an application — see §5 above.
- **Bumps `ai_sessions.state_version`** — the fix just transitioned from "needs drafting" to "has draft", which affects Resolve/Escalate preview regeneration.
- Returns `SessionSuggestedFixResponse`.
### ScriptBuilderTab controller (frontend) — no changes to `ScriptBuilderChat`
`ScriptBuilderChat` (`frontend/src/components/script-builder/ScriptBuilderChat.tsx`) is a presentational component taking `messages`, `language`, `onViewScript`, `onSaveScript`, `isLoading`. **It does not need a `mode` prop** — adding persistence semantics to a display component would be wrong.
Instead, introduce a new controller component `frontend/src/components/pilot/ScriptBuilderTab.tsx` that owns the inline lifecycle:
- On mount: **get-or-create** the single inline `script_builder_sessions` row for `(current user, current pilot session)` via the existing `POST /script-builder/sessions` endpoint, passing `origin: 'pilot_inline'` and the current pilot session id for `ai_session_id`. The endpoint becomes idempotent for `origin='pilot_inline'` — if a row exists for that `(user_id, ai_session_id)` pair, it's returned; otherwise created. The partial unique index on the DB backs the invariant independent of endpoint code. Remounting (tab hide/show, page refresh) resumes the same session — no duplicates, no lost draft continuity.
- Holds local state for the AI message list, the Monaco buffer, and `scriptBuilderMode`.
- Renders `ScriptBuilderChat` in AI mode with `onSaveScript` wired to the inline submit path (PATCH /script), NOT the standalone template-creation path.
- Renders Monaco (via existing `CodeModeEditor` pattern) in `'editor'` mode with its own Save button that triggers the same submit.
- Emits an `onScriptDrafted` event to `AssistantChatPage` on success so the page can `setActiveFix(updated)`, hide the tab strip, and return the engineer to Chat tab.
The standalone `/script-builder` page retains its current behavior unchanged — it continues to create `script_templates` rows on submit. The split happens cleanly at the controller layer, not inside `ScriptBuilderChat`.
### `POST /script-builder/sessions` — changes for inline origin
The existing endpoint is extended in three ways:
1. **Accepts `origin`** in the request body (`Literal['standalone', 'pilot_inline']`, default `'standalone'`). Legacy callers unchanged.
2. **Authorization on `ai_session_id`.** When `origin='pilot_inline'` is passed AND `ai_session_id` is provided, the handler MUST verify the referenced `ai_sessions` row is owned by the current user (or within their account — whichever guard `_load_session_or_404(db, ai_session_id, current_user)` already enforces for the pilot endpoints). Without this check, a caller could attach an inline scratch session to an arbitrary pilot session. The check fires before any row lookup or creation.
3. **Idempotent for `origin='pilot_inline'`.** If a row with `(user_id = current, ai_session_id = provided, origin = 'pilot_inline')` already exists, the handler returns that row (200) instead of creating a new one (201). The unique partial index enforces at-most-one at the DB layer; a race between two concurrent POSTs surfaces as an integrity error that the handler catches and re-reads.
For `origin='standalone'`, behavior is unchanged — always creates, still subject to the 5-session cap.
The 5-session cap applies only to `standalone` rows (see §Data-model filter changes). Inline sessions are out of that accounting entirely.
---
## State
### Frontend state (AssistantChatPage)
New local state on the page:
- `chatTab: 'chat' | 'script_builder'` — which tab is visible. Defaults to `'chat'`.
- `scriptBuilderHasProgress: boolean` — drives the indicator dot. Set by `ScriptBuilderTab` via an `onProgressChange` callback.
Reset in `resetSessionDerivedState`: both back to defaults.
`scriptBuilderMode` ('ai' | 'editor') lives **inside `ScriptBuilderTab`**, not on the page — the parent never needs to drive the AI/editor toggle. The controller resets it naturally via unmount/remount when the page switches sessions.
Banner's Apply handler (`handleApplyFix`) updated:
- If no script + no template → set `chatTab = 'script_builder'` (and show tab strip).
- If drafted script → open NoTemplateDialog in the chat region (new state or existing `scriptPanelOpen` reused).
- If template → open `TemplateMatchPanel` in the task lane (render location unchanged); run stamping happens via the new "I ran this" action inside the panel (see §5), not on Apply click.
### Tab strip visibility
The tab strip is derived, not state:
```ts
const showTabStrip =
activeFix != null &&
activeFix.status !== 'dismissed' &&
activeFix.status !== 'applied_success' &&
activeFix.status !== 'applied_failed' &&
!activeFix.script_template_id &&
!activeFix.ai_drafted_script
```
When the strip hides (e.g., after script is drafted), `chatTab` resets to `'chat'` to avoid stuck state.
### Tab switching guard
The existing `currentChatRef` pattern (Async-select-load-apply guard) applies: when the engineer switches chats, any in-flight tab-derived state is discarded.
---
## Out of scope
- **NoTemplateDialog grid fix.** Moved to the chat region (wide enough), so the `grid-cols-1 sm:grid-cols-3` layout now works as intended. No grid edit required.
- **`window.prompt` replacement** for partial-notes / failure-reason capture. Still the Phase 8 interim pattern; replacement is deferred to a later design debt pass.
- **TemplateMatchPanel relocation** to the chat region. Different surface, different interactions, not broken today. Possible future work.
- **Dedicated "clear AI outcome proposal" button in the UI.** Already covered by Phase 8 Issue #3 fix (DELETE endpoint + clear-on-outcome-write).
- **Task lane bottom-slot audit.** With NoTemplateDialog removed from the slot, it may be empty on most sessions. Keep the slot API stable; any cleanup is out of scope.
---
## Tests
### Backend
- **Migration:** forward + downgrade reversibility; existing rows backfill to `origin='standalone'`; the `origin='pilot_inline' ⇒ ai_session_id IS NOT NULL` invariant is enforced by the check constraint.
- **PATCH /script endpoint** (new test file `test_fix_script_endpoint.py`):
- happy path — 200, `ai_drafted_script` set, `state_version` bumped, `applied_at` untouched.
- 404 on wrong session.
- 409 on terminal status.
- 400 on empty body.
- **list/count filter changes** (extend `test_script_builder.py` or nearby):
- 5 `pilot_inline` sessions + subsequent `standalone` session creation succeeds (does not hit the 5-cap).
- `list_sessions` returns only `standalone` rows by default.
- **Apply lifecycle correction** (extend `test_fix_outcome_endpoint.py`):
- Banner Apply click that routes to a drafting/evaluation surface does NOT stamp `applied_at`.
- `one_off` decision from `NoTemplateDialog` DOES stamp `applied_at`.
- `draft_template` decision from `NoTemplateDialog` DOES stamp `applied_at` (it still runs the script).
- `build_template` decision from `NoTemplateDialog` does NOT stamp (no run).
- `TemplateMatchPanel` "I ran this" action DOES stamp `applied_at`; Generate / Copy alone do NOT.
- **Script Builder session create — inline semantics** (extend `test_script_builder.py` or equivalent):
- First `POST /script-builder/sessions` with `origin='pilot_inline', ai_session_id=X` creates and returns a row.
- Second `POST` with the same `(ai_session_id, user)` returns the SAME row (no duplicate created); DB row count confirms.
- `POST` with `origin='pilot_inline'` and `ai_session_id` pointing at another user's pilot session is rejected (403/404).
- Race: two concurrent `POST`s for the same `(user, ai_session_id)` resolve to the same row id (one winner, one returns the existing).
### Frontend
Manual verification (no component test harness in this codebase per CLAUDE.md):
- No-draft fix → Apply click opens Script Builder tab.
- AI path: chat with AI, submit, tab disappears, NoTemplateDialog becomes eligible.
- Manual path: ✎ Write myself → Monaco loads with scaffold → edit → submit → tab disappears.
- Drafted fix → Apply click opens NoTemplateDialog in chat region (three cards side-by-side).
- Tab indicator dot appears on first AI message / non-empty Monaco buffer; clears on submit.
- Session switch with open Script Builder tab → tab/mode state resets.
- EscalateInterceptDialog partial choice → applied_partial written with notes.
### Build discipline
- `tsc -b` clean
- `npm run build` clean
- `docker exec resolutionflow_backend pytest` — all pre-existing suites still pass, no regression from the new endpoint.
---
## Files to touch (rough inventory)
**Backend — new:**
- `backend/alembic/versions/<hash>_script_builder_origin.py`
- `backend/tests/test_fix_script_endpoint.py`
**Backend — modified:**
- `backend/app/models/script_builder_session.py` — add `origin` column only (`ai_session_id` already exists).
- `backend/app/schemas/session_suggested_fix.py` — add `SessionSuggestedFixScriptRequest`.
- `backend/app/schemas/script_builder.py` — extend `ScriptBuilderCreateRequest` with two new optional fields: `origin: Literal['standalone', 'pilot_inline'] = 'standalone'` and `ai_session_id: UUID | None = None`. Handler-side validation: when `origin='pilot_inline'`, `ai_session_id` is required (not null) AND must pass the current-user ownership check. Legacy callers pass neither and continue to create standalone sessions as before.
- `backend/app/api/endpoints/session_suggested_fixes.py` — add PATCH /script endpoint. Move the existing `applied_at` stamp out of the apply path and into `handleScriptDecision('one_off' | 'draft_template')` plus `TemplateMatchPanel`'s new "I ran this" handler (server side: no change to `/apply`; callers shift instead).
- `backend/app/api/endpoints/script_builder.py` — accept `origin` on session creation; enforce the `pilot_inline ⇒ ai_session_id` invariant at the handler level.
- `backend/app/services/script_builder_service.py` — persist `origin`; `list_sessions` + `count_user_sessions` filter to `origin='standalone'` by default.
- `backend/app/models/session_suggested_fix.py` — unchanged (schema already has `ai_drafted_script`).
**Frontend — new:**
- `frontend/src/components/pilot/ChatTabStrip.tsx` — renders the `[Chat] [Script Builder ●]` strip.
- `frontend/src/components/pilot/ScriptBuilderTab.tsx` — controller that owns session lifecycle, AI message state, Monaco buffer, mode toggle, and submit. Renders `ScriptBuilderChat` in AI mode and Monaco in editor mode.
- `frontend/src/components/pilot/NoTemplateDialogInline.tsx` (or reuse existing `NoTemplateDialog` with a new wrapper for chat-region styling).
**Frontend — modified:**
- `frontend/src/api/sessionSuggestedFixes.ts` — add `patchScript(sessionId, fixId, body, parameters)` method.
- `frontend/src/api/scriptBuilder.ts` (or equivalent) — `createSession` accepts optional `origin` and `ai_session_id` arguments (both required together when the caller is `ScriptBuilderTab`; both omitted for the legacy standalone caller).
- `frontend/src/components/script-builder/ScriptBuilderChat.tsx`**unchanged**. Stays a pure display component.
- `frontend/src/pages/ScriptBuilderPage.tsx`**unchanged on the session-creation path** (defaults to `origin='standalone'`).
- `frontend/src/pages/AssistantChatPage.tsx` — wire tab strip, mount `ScriptBuilderTab`, banner Apply routing (no `applied_at` stamp on click), NoTemplateDialog chat-region render. Move the `sessionSuggestedFixesApi.applyFix(...)` call from `handleApplyFix` to `handleScriptDecision('one_off' | 'draft_template')` and `TemplateMatchPanel`'s new "I ran this" handler.
- `frontend/src/components/pilot/EscalateInterceptDialog.tsx` — add fourth choice.
- `frontend/src/components/pilot/TaskLane.tsx` — remove `bottomSlot` usage of NoTemplateDialog (leave prop API stable).
**Frontend — deleted:**
- None (existing components get refactored, not deleted).
---
## Rollout
- Single branch, merged as part of the in-flight `feat/flowpilot-migration` PR (same as Phase 8).
- No feature flag — the new surface is strictly additive to the banner's Apply flow; old behavior for drafted-script fixes is preserved (just renders in a different location).
---
## Open deferrals (acknowledged, not in this phase)
- `window.prompt` → inline input migration for partial notes / failure reasons.
- Anti-parrot compliance check for the inline `ScriptBuilderTab` flow — verify it reuses the existing script-builder AI system prompt (no new prompt content introduced; the controller only changes what `onSaveScript` does, not what the AI sees).
- Telemetry events for tab opens / AI→editor toggles / script submissions from tab — add in the Phase 9 implementation plan if we want them.

View File

@@ -0,0 +1,88 @@
---
date: 2026-04-22
branch: feat/flowpilot-migration
remote: ssh://gitea.resolutionflow.com/chihlasm/resolutionflow.git
last_commit: faf1d8d fix(pilot): applied_at stamps on run-declaring actions, not Apply click
status: Sprint 9/9 phases complete and pushed; PR not yet opened. Open items #1 and #3 resolved by Phase 9.
---
# FlowPilot Migration — Session Handoff
## Where the work lives
- Branch: `feat/flowpilot-migration` (pushed to Gitea, mirrors to GitHub)
- Spec: [docs/FlowAssist_Migration/FLOWPILOT-MIGRATION.md](../FlowAssist_Migration/FLOWPILOT-MIGRATION.md)
- Mockups: [docs/FlowAssist_Migration/mockups/](../FlowAssist_Migration/mockups/) (PNG + HTML reference)
## What shipped
All nine migration phases are merged onto the branch and verified against the live dev stack (`resolutionflow_frontend` / `resolutionflow_backend` / `resolutionflow_postgres` containers).
| Phase | Commit | What landed |
|---|---|---|
| 0 — baseline telemetry | (pre-branch) | analytics events for funnel deltas |
| 1 — `/assistant``/pilot` rename | early commits | route redirects, sidebar updates |
| 2 — What we know (facts) | (mid) | `session_facts` table, `[PROMOTE]` marker, fact CRUD endpoints, `WhatWeKnow` section |
| 3 — Suggested fix + Resolve preview | `7ccf4c6` and prior | `session_suggested_fixes`, `[SUGGEST_FIX]` marker, `ResolutionNotePreview` popover |
| 4 — Escalate + PSA writeback | `8fd2c1b` | `psa_writeback_service` with status verification, kind-parameterized preview |
| 5 — inline Script Generator | `fa61376` | `TemplateMatchPanel`, `NoTemplateDialog` three-option dialog |
| 6 — post-resolve templatize | `4aaf57a` | `draft_templates` table, accept/reject endpoints, `TemplatizePrompt` modal, account preferences |
| 7 — polish | `8a242f5` | loading/empty states, keyboard shortcuts (`⌘↵`, `⌘G`, `?` overlay), responsive bottom-drawer <1200px |
| 8 — Fix Outcome Banner | `cdd8bb0`..`a47ce07` | Six outcome columns on `session_suggested_fixes` (`status`, `applied_at`, `verified_at`, `partial_notes`, `failure_reason`, `ai_outcome_proposal`) + `PATCH /api/v1/ai-sessions/{sid}/suggested-fixes/{fid}/outcome` endpoint + `[FIX_OUTCOME]` marker; replaces task-lane `SuggestedFix` card with a chat-composer-anchored `ProposalBanner` (5 states: proposed/verifying/partial/ai_confirming/nudge + collapsed); `EscalateInterceptDialog` captures outcome before handoff; Resolve-while-verifying auto-marks success; 17 new tests (8 endpoint + 7 marker + 2 anti-parrot) |
| 9 — Tabbed Script Builder | `5bcb7aa`..`faf1d8d` | Chat-region tab strip (`[Chat] [Script Builder ●]`) with `ChatTabStrip` + new `ScriptBuilderTab` controller wrapping the existing `ScriptBuilderChat` + Monaco editor (`ScriptBodyEditor`); `InlineNoTemplateDialog` relocates the existing `NoTemplateDialog` from the narrow task-lane `bottomSlot` to a chat-region placement wrapper; `EscalateInterceptDialog` gains a fourth "partial" choice; `PATCH /api/v1/ai-sessions/{sid}/suggested-fixes/{fid}/script` endpoint for engineer-drafted scripts (does not stamp `applied_at`); Alembic migration adds `origin VARCHAR(20)` to `script_builder_sessions` (reuses existing `ai_session_id` FK) + partial unique index on `(user_id, ai_session_id) WHERE origin='pilot_inline'` for idempotent get-or-create; `applied_at` semantics corrected to stamp only on run-declaring actions (`handleScriptDecision` for `one_off`/`draft_template`; new `onMarkRun` on `TemplateMatchPanel`) — not the Apply click |
Plus the structural fixes that came up along the way:
- `50215b9` + `d0ebdef` — full sweep removing literal payloads from AI system prompts; new `tests/test_prompt_anti_parrot.py` guardrail
- `ce7c8ac` + `ddae171` — task-lane state-leak across chats (centralized `resetSessionDerivedState()` helper)
- `8879f96` — dropped `sticky top-0` from all four lane section headers (they were orphaning over unrelated content on scroll)
## How to resume
1. `git checkout feat/flowpilot-migration`
2. `docker compose -f docker-compose.dev.yml up -d` (if the stack isn't running)
3. Verify: `docker exec resolutionflow_frontend sh -c "cd /app && npx tsc -b"` should be clean
4. Live URL: <http://localhost:5173/pilot> (or `<host-ip>:5173/pilot`)
5. Test users (password `TestPass123!`): `engineer@resolutionflow.example.com`
## Open work — pick one
Items #1 and #3 were discovered during Phase 6/7 verification. Item #2 was resolved by Phase 8. Items #1 and #3 are **resolved by Phase 9** (see below).
### 1. NoTemplateDialog narrow-lane bug
**Status: RESOLVED by Phase 9.**
Phase 9 relocated `InlineNoTemplateDialog` from the task-lane `bottomSlot` into a dedicated chat-region placement wrapper (`InlineNoTemplateDialog.tsx`). The dialog no longer renders inside the narrow 380px task lane, eliminating the `sm:grid-cols-3` viewport-breakpoint collision. The disabled-cards bug (when no `ai_drafted_script` is present) is also resolved: when no draft exists, the engineer is routed into the new `ScriptBuilderTab` inline chat instead of reaching the three-option dialog with disabled cards.
See [docs/FlowAssist_Migration/phase-9-implementation-plan.md](../FlowAssist_Migration/phase-9-implementation-plan.md) and [docs/FlowAssist_Migration/phase-9-script-builder-tab.md](../FlowAssist_Migration/phase-9-script-builder-tab.md) for full implementation details.
### 2. Task lane crowding / Suggested Fix discoverability
**Status: RESOLVED by Phase 8.** The `SuggestedFix` card no longer lives inside the scrollable task lane. Phase 8 replaced it with a chat-composer-anchored slide-up banner (`ProposalBanner`) that is always visible at the bottom of the conversation column regardless of how far the task lane has scrolled. The banner is the primary entry point for fix application; the task lane retains a compact read-only summary of the active fix for reference.
See [docs/FlowAssist_Migration/phase-8-fix-outcome-banner.md](../FlowAssist_Migration/phase-8-fix-outcome-banner.md) for the implementation plan and design rationale. Because the banner is now the primary entry point, the NoTemplateDialog narrow-lane bug (open item #1) is considerably less visible — the three-option dialog is only reached after the engineer opts in via the banner, at which point they have already acknowledged the fix.
### 3. Tabbed Script Builder inside the chat (Option A from the modal-vs-tab discussion)
**Status: RESOLVED by Phase 9.**
Phase 9 shipped the complete tabbed Script Builder integration. The chat region now has a `[Chat] [Script Builder ●]` tab strip (`ChatTabStrip`) powered by a new `ScriptBuilderTab` controller that wraps the existing (untouched) `ScriptBuilderChat` for AI mode and `ScriptBodyEditor` (Monaco) for a "Write it myself" editor mode. `display: none` toggling preserves chat scroll position, draft message, and editor buffer across tab switches.
The `PATCH /api/v1/ai-sessions/{sid}/suggested-fixes/{fid}/script` endpoint writes `ai_drafted_script` + `ai_drafted_parameters` back to the fix record without stamping `applied_at` — a draft is not an application. Bumps `state_version` so cached Resolve/Escalate previews regenerate.
The migration added `origin VARCHAR(20) NOT NULL DEFAULT 'standalone'` (with CHECK constraint on the two valid values + invariant that `origin='pilot_inline'` requires `ai_session_id IS NOT NULL`) to `script_builder_sessions`. It reuses the pre-existing `ai_session_id` FK rather than adding a new parent column. A partial unique index on `(user_id, ai_session_id) WHERE origin='pilot_inline'` backs get-or-create idempotency from the inline tab.
See [docs/FlowAssist_Migration/phase-9-implementation-plan.md](../FlowAssist_Migration/phase-9-implementation-plan.md) and [docs/FlowAssist_Migration/phase-9-script-builder-tab.md](../FlowAssist_Migration/phase-9-script-builder-tab.md) for full implementation details.
## Loose ends / things to verify on resume
- **PR not opened.** Branch is pushed but no Gitea PR yet. When ready: `gh pr create` works against the GitHub mirror, but the actual review happens in Gitea.
- **`/ultrareview` not run** on the final state of the branch (including Phase 9). Worth doing before PR creation.
- **Phase 9 browser QA not done.** The new tab strip, `ScriptBuilderTab` (AI + editor modes), `InlineNoTemplateDialog` chat-region placement, and `EscalateInterceptDialog` fourth-choice flow have not been exercised in a headless-browser session. Key states to cover: tab strip renders and toggles without unmounting chat or losing editor buffer; Script Builder tab Submit persists script via PATCH without stamping `applied_at`; `one_off`/`draft_template` decisions DO stamp; `build_template` does NOT stamp; `TemplateMatchPanel` "I ran this" stamps via `onMarkRun`; partial-attempt choice in `EscalateInterceptDialog` is recorded correctly.
- **Phase 8 browser QA not done.** The `ProposalBanner` and `EscalateInterceptDialog` (three-choice variant) have not been exercised in a headless-browser session. Key states: banner appears on `[FIX_OUTCOME]` marker; banner dismisses correctly; escalate mid-fix triggers dialog; banner auto-collapses after session resolved. Use `/qa` or `/design-review` against `mockups/06-slide-up-banner.html` and `mockups/07-verify-states.html`.
- **Phase 7 visual verification was structural only** — `tsc -b` and `npm run build` both clean, HMR applied each change without error, but no headless-browser screenshot comparison against the mockup PNGs. If you want pixel-level verification, `/qa` or `/design-review` would catch deltas.
- **Anti-parrot test runs as part of `pytest`** but is not enforced in any specific CI step yet — verify `tests/test_prompt_anti_parrot.py` is discovered by the existing pytest run, and consider failing CI explicitly on regression.
## Files most likely to need attention next
- [frontend/src/pages/AssistantChatPage.tsx](../../frontend/src/pages/AssistantChatPage.tsx) — 1500+ lines, the central pilot orchestrator. Most state-leak and rendering bugs surface here first. Search for `resetSessionDerivedState` to see the chat-switch reset pattern.
- [frontend/src/components/assistant/TaskLane.tsx](../../frontend/src/components/assistant/TaskLane.tsx) — accepts `whatWeKnowSlot` / `bottomSlot` from the parent, plus a `variant: 'side' | 'drawer'` for responsive. `bottomSlot` remains active (carries `TemplateMatchPanel` + resolve/escalate preview buttons in both side and drawer variants).
- [backend/app/services/unified_chat_service.py](../../backend/app/services/unified_chat_service.py) — owns marker parsing for `[PROMOTE]`, `[SUGGEST_FIX]`, `[QUESTIONS]`, `[ACTIONS]`, `[FORK]`, `[TREE_UPDATE]`. If markers stop firing in chat, this is the first place to check.
- [backend/app/services/assistant_chat_service.py](../../backend/app/services/assistant_chat_service.py) — `ASSISTANT_SYSTEM_PROMPT` constant. Anti-parrot test enforces no literal payloads here; use `<placeholder>` syntax only.

View File

@@ -7,9 +7,21 @@ import type {
} from '@/types'
import type { ScriptTemplateDetail } from '@/types'
export interface CreateSessionOptions {
origin?: 'standalone' | 'pilot_inline'
aiSessionId?: string
}
export const scriptBuilderApi = {
async createSession(language: string): Promise<ScriptBuilderSessionDetail> {
const { data } = await apiClient.post('/scripts/builder/sessions', { language })
async createSession(
language: string,
options?: CreateSessionOptions,
): Promise<ScriptBuilderSessionDetail> {
const { data } = await apiClient.post('/scripts/builder/sessions', {
language,
origin: options?.origin,
ai_session_id: options?.aiSessionId,
})
return data
},

View File

@@ -8,6 +8,25 @@ import apiClient from './client'
export type UserDecision = 'one_off' | 'draft_template' | 'build_template' | 'dismissed'
export type FixStatus =
| 'proposed'
| 'applied_success'
| 'applied_failed'
| 'applied_partial'
| 'dismissed'
export type FixOutcome =
| 'applied_success'
| 'applied_failed'
| 'applied_partial'
| 'dismissed'
export interface AIOutcomeProposal {
fix_id: string
outcome: 'success' | 'failure' | 'partial'
reason: string
}
export interface SessionSuggestedFix {
id: string
session_id: string
@@ -18,6 +37,12 @@ export interface SessionSuggestedFix {
ai_drafted_script: string | null
ai_drafted_parameters: Record<string, unknown> | null
user_decision: UserDecision | null
status: FixStatus
applied_at: string | null
verified_at: string | null
partial_notes: string | null
failure_reason: string | null
ai_outcome_proposal: AIOutcomeProposal | null
superseded_at: string | null
created_at: string
}
@@ -86,6 +111,40 @@ export const sessionSuggestedFixesApi = {
return r.data
},
/**
* Stamp applied_at when the engineer clicks Apply in the ProposalBanner.
* Does NOT change status (fix remains 'proposed'). Status flips only on
* a subsequent PATCH /outcome. Idempotent if applied_at is already set.
* Returns 409 if the fix is no longer in 'proposed' status.
*/
async applyFix(sessionId: string, fixId: string): Promise<SessionSuggestedFix> {
const r = await apiClient.post<SessionSuggestedFix>(
`/ai-sessions/${sessionId}/suggested-fixes/${fixId}/apply`,
)
return r.data
},
/**
* Record the outcome of applying a suggested fix. Transition rules:
* - from `proposed` or `applied_partial`: any outcome is valid (partial is
* parked, not terminal — engineer may update notes, abandon via dismiss,
* or advance to success/failed).
* - from a terminal status (`applied_success`, `applied_failed`, `dismissed`):
* server returns 409.
*/
async patchOutcome(
sessionId: string,
fixId: string,
outcome: FixOutcome,
notes?: string,
): Promise<SessionSuggestedFix> {
const r = await apiClient.patch<SessionSuggestedFix>(
`/ai-sessions/${sessionId}/suggested-fixes/${fixId}/outcome`,
{ outcome, notes },
)
return r.data
},
/**
* Fetch (or get cached) draft markdown for the Resolve note. Backend cache
* is keyed on state_version, so calling this back-to-back without intervening
@@ -137,6 +196,40 @@ export const sessionSuggestedFixesApi = {
)
return r.data
},
/**
* Attach an engineer-drafted script to a suggested fix (inline Script
* Builder Submit path). Does NOT stamp applied_at — the server treats
* a draft as non-terminal progress. Bumps state_version so the
* Resolve/Escalate preview regenerates.
*/
async patchScript(
sessionId: string,
fixId: string,
aiDraftedScript: string,
aiDraftedParameters?: Record<string, unknown>,
): Promise<SessionSuggestedFix> {
const r = await apiClient.patch<SessionSuggestedFix>(
`/ai-sessions/${sessionId}/suggested-fixes/${fixId}/script`,
{
ai_drafted_script: aiDraftedScript,
ai_drafted_parameters: aiDraftedParameters,
},
)
return r.data
},
/**
* Explicitly dismiss the AI-proposed outcome banner ("Not yet").
* Clears ai_outcome_proposal on the server without touching status or
* state_version. Idempotent: returns 200 even when the field is already null.
*/
async clearAIProposal(sessionId: string, fixId: string): Promise<SessionSuggestedFix> {
const r = await apiClient.delete<SessionSuggestedFix>(
`/ai-sessions/${sessionId}/suggested-fixes/${fixId}/ai-outcome-proposal`,
)
return r.data
},
}
export default sessionSuggestedFixesApi

View File

@@ -43,8 +43,6 @@ interface TaskLaneProps {
// shape lets the parent own fact-fetching and state-version polling without
// pulling that concern into TaskLane.
whatWeKnowSlot?: React.ReactNode
// Phase 3: Suggested fix card, rendered below Diagnostic Checks.
suggestedFixSlot?: React.ReactNode
// Phase 3: bottom-of-lane slot for the Resolve action bar + preview popover
// (parent owns state). Renders inside the scrollable body so the popover
// stays anchored as the lane scrolls.
@@ -79,7 +77,7 @@ export function clearTaskState(sessionId: string) {
// ── Component ──
export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loading, whatWeKnowSlot, suggestedFixSlot, bottomSlot, variant = 'side' }: TaskLaneProps) {
export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loading, whatWeKnowSlot, bottomSlot, variant = 'side' }: TaskLaneProps) {
const isDrawer = variant === 'drawer'
const [tasks, setTasks] = useState<TaskResponse[]>(() => {
// Try to restore saved state for this session (preserves user's in-progress answers)
@@ -535,15 +533,11 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
</section>
)}
{/* ── Suggested fix (Phase 3) ── */}
{suggestedFixSlot}
{/* Quiet-state hint: lane is open (facts exist), but AI hasn't
proposed a next step yet. Keeps the lane from feeling "finished"
when the engineer still expects a question / fix to arrive. */}
{questionTasks.length === 0
&& actionTasks.length === 0
&& !suggestedFixSlot
&& !loading && (
<div className="text-[0.6875rem] italic text-muted-foreground px-1 py-2">
No open questions send a message or add a note; the AI will follow up.

View File

@@ -0,0 +1,79 @@
/**
* ChatTabStrip — two-tab strip at the top of the chat region:
* [Chat] [Script Builder ●]
*
* Visibility is controlled by the parent (AssistantChatPage) — this
* component renders whenever it's mounted. The parent decides whether
* to mount it based on fix state.
*
* Tab switching uses onChange; the parent toggles display:none on the
* tab contents so state is preserved across switches.
*/
import { cn } from '@/lib/utils'
export type ChatTab = 'chat' | 'script_builder'
export interface ChatTabStripProps {
active: ChatTab
onChange: (tab: ChatTab) => void
/** When true, shows the amber indicator dot on the Script Builder tab. */
scriptBuilderHasProgress?: boolean
}
export function ChatTabStrip({
active, onChange, scriptBuilderHasProgress,
}: ChatTabStripProps) {
return (
<div
role="tablist"
className="flex gap-1 px-4 pt-2 border-b border-default bg-bg-sidebar"
>
<TabButton
label="Chat"
active={active === 'chat'}
onClick={() => onChange('chat')}
/>
<TabButton
label="Script Builder"
active={active === 'script_builder'}
onClick={() => onChange('script_builder')}
indicator={scriptBuilderHasProgress}
/>
</div>
)
}
function TabButton({
label, active, onClick, indicator,
}: {
label: string
active: boolean
onClick: () => void
indicator?: boolean
}) {
return (
<button
role="tab"
aria-selected={active}
onClick={onClick}
className={cn(
'relative px-3 py-[7px] text-[12.5px] font-medium rounded-t-md transition-colors',
'border-b-2 -mb-px',
active
? 'text-heading border-accent bg-bg-page'
: 'text-muted-foreground border-transparent hover:text-primary hover:bg-white/[0.08]',
)}
>
{label}
{indicator && (
<span
role="img"
aria-label="unsaved progress"
className="ml-1.5 inline-block w-1.5 h-1.5 rounded-full bg-warning align-middle"
/>
)}
</button>
)
}
export default ChatTabStrip

View File

@@ -0,0 +1,83 @@
/**
* EscalateInterceptDialog — popover anchored above the Escalate button.
*
* Fires when the engineer clicks Escalate while a fix is in Verifying or
* Partial state. Captures the fix outcome before the escalation so the
* handoff narrative is honest for whoever picks up the ticket.
*
* Visual reference: docs/FlowAssist_Migration/mockups/07-verify-states.html
* (panel C).
*/
import { X, AlertCircle, Check, Info } from 'lucide-react'
import type { FixOutcome } from '@/api/sessionSuggestedFixes'
export type InterceptChoice = FixOutcome | 'never_applied'
export interface EscalateInterceptDialogProps {
fixTitle: string
onChoose: (choice: InterceptChoice) => void
onClose: () => void
}
export function EscalateInterceptDialog({
fixTitle,
onChoose,
onClose,
}: EscalateInterceptDialogProps) {
return (
<>
<div
className="fixed inset-0 z-40"
onClick={onClose}
aria-hidden="true"
/>
<div
role="dialog"
aria-label="Capture fix outcome before escalating"
className="absolute bottom-full mb-2 left-0 z-50 w-[340px] rounded-lg border border-white/15 bg-card p-3.5 shadow-[0_18px_40px_rgba(0,0,0,0.55)]"
>
<div className="font-heading font-semibold text-[13px] text-heading mb-1">
Before escalating what happened with the fix?
</div>
<div className="text-[12px] text-muted-foreground leading-[1.5] mb-3">
&ldquo;{fixTitle}&rdquo; is still in the Verifying state. Tag its outcome so
the senior picking this up knows what&apos;s been tried.
</div>
<div className="flex flex-col gap-1.5">
<button
autoFocus
onClick={() => onChoose('applied_failed')}
className="flex items-center gap-2.5 px-3 py-2.5 rounded-lg border border-danger/30 bg-danger-dim text-[12.5px] text-primary hover:bg-danger-dim/80 hover:border-danger transition-colors text-left"
>
<X size={13} strokeWidth={2.5} className="text-danger" />
<span className="flex-1">The fix didn&apos;t work</span>
<span className="text-[10.5px] text-muted-foreground font-mono px-1.5 py-[2px] rounded bg-white/[0.05]"></span>
</button>
<button
onClick={() => onChoose('applied_partial')}
className="flex items-center gap-2.5 px-3 py-2.5 rounded-lg border border-white/10 bg-elevated text-[12.5px] text-primary hover:bg-sidebar transition-colors text-left"
>
<Info size={13} strokeWidth={2} />
<span className="flex-1">I applied some of it partial</span>
</button>
<button
onClick={() => onChoose('applied_success')}
className="flex items-center gap-2.5 px-3 py-2.5 rounded-lg border border-white/10 bg-elevated text-[12.5px] text-primary hover:bg-sidebar transition-colors text-left"
>
<Check size={13} strokeWidth={2} />
<span className="flex-1">It worked escalating for another reason</span>
</button>
<button
onClick={() => onChoose('never_applied')}
className="flex items-center gap-2.5 px-3 py-2.5 rounded-lg border border-white/10 bg-elevated text-[12.5px] text-primary hover:bg-sidebar transition-colors text-left"
>
<AlertCircle size={13} strokeWidth={2} />
<span className="flex-1">Never actually applied it</span>
</button>
</div>
</div>
</>
)
}
export default EscalateInterceptDialog

View File

@@ -0,0 +1,22 @@
/**
* InlineNoTemplateDialog — chat-region placement wrapper for
* NoTemplateDialog. Renders above the composer (slide-up animation
* matching ProposalBanner), using the full chat-region width so the
* three decision cards fit side-by-side.
*/
import { NoTemplateDialog } from '@/components/pilot/script/NoTemplateDialog'
import type { ComponentProps } from 'react'
type Props = ComponentProps<typeof NoTemplateDialog>
export function InlineNoTemplateDialog(props: Props) {
return (
<div className="border-t border-default bg-bg-page animate-slide-up">
<div className="px-5 py-3">
<NoTemplateDialog {...props} />
</div>
</div>
)
}
export default InlineNoTemplateDialog

View File

@@ -0,0 +1,362 @@
/**
* ProposalBanner — chat-composer-anchored banner that carries the lifecycle
* of a suggested fix from Proposed → Verifying → terminal outcome.
*
* Replaces the task-lane SuggestedFix card (Phase 8). The banner renders
* above the chat composer in AssistantChatPage. Parent owns the fix record
* and the outcome mutations; this component renders + dispatches callbacks.
*
* Visual reference: docs/FlowAssist_Migration/mockups/06-slide-up-banner.html
* + 07-verify-states.html.
*/
import { useState } from 'react'
import { Sparkles, Check, ChevronDown, X, MoreHorizontal, Info } from 'lucide-react'
import { cn } from '@/lib/utils'
import type {
SessionSuggestedFix,
FixOutcome,
} from '@/api/sessionSuggestedFixes'
export type BannerMode =
| 'proposed' // AI just proposed; engineer hasn't applied yet
| 'verifying' // Engineer clicked Apply; awaiting outcome
| 'partial' // Applied partially; awaiting finish or terminal outcome
| 'ai_confirming' // AI emitted [FIX_OUTCOME]; engineer confirms
| 'nudge' // Compact nudge shown after N post-apply messages
export interface ProposalBannerProps {
fix: SessionSuggestedFix
mode: BannerMode
onApply: () => void
onDismiss: () => void
onOutcome: (outcome: FixOutcome, notes?: string) => void
onAcceptAIProposal: () => void
onRejectAIProposal: () => void
/** Collapsed variant shown as a thin single-line strip. */
collapsed?: boolean
onToggleCollapsed?: () => void
/** Silence the nudge without collapsing it (Task 11 wires this). */
onSilenceNudge: () => void
}
export function ProposalBanner(props: ProposalBannerProps) {
if (props.collapsed) return <CollapsedBanner {...props} />
switch (props.mode) {
case 'proposed': return <ProposedBanner {...props} />
case 'verifying': return <VerifyingBanner {...props} />
case 'partial': return <PartialBanner {...props} />
case 'ai_confirming': return <AIConfirmingBanner {...props} />
case 'nudge': return <NudgeBanner {...props} />
}
}
function ProposedBanner({ fix, onApply, onDismiss, onToggleCollapsed }: ProposalBannerProps) {
return (
<div className="relative border-t border-warning/30 bg-gradient-to-b from-warning-dim/40 to-warning-dim/20 px-5 py-3 animate-slide-up">
<div className="absolute left-0 top-0 bottom-0 w-[3px] bg-warning" />
<div className="flex items-start gap-3">
<div className="shrink-0 mt-0.5 w-7 h-7 rounded-md border border-warning/30 bg-warning-dim flex items-center justify-center text-warning">
<Sparkles size={15} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 font-heading text-[10px] font-semibold uppercase tracking-[1.2px] text-warning">
<span>Suggested Fix</span>
<span className="tabular-nums px-2 py-[2px] rounded-full bg-warning/20 text-warning text-[10.5px] font-bold">
{fix.confidence_pct}% confidence
</span>
</div>
<div className="mt-0.5 text-[14px] font-semibold text-heading leading-snug">
{fix.title}
</div>
<div className="mt-1 text-[12.5px] text-muted-foreground leading-relaxed">
{fix.description}
</div>
{fix.script_template_id && (
<div className="mt-1.5 inline-flex items-center gap-1.5 text-[11.5px] text-success">
<Check size={11} />
Matches an existing Script Library template one-click apply
</div>
)}
</div>
<div className="flex items-center gap-2 shrink-0 pt-0.5">
{onToggleCollapsed && (
<button
onClick={onToggleCollapsed}
className="p-1.5 rounded text-muted-foreground hover:bg-white/[0.08] hover:text-primary"
aria-label="Collapse"
>
<ChevronDown size={14} />
</button>
)}
<button
onClick={onDismiss}
className="px-2.5 py-1.5 rounded text-[12.5px] text-muted-foreground hover:bg-white/[0.08] hover:text-primary"
>
Dismiss
</button>
<button
onClick={onApply}
className="px-3.5 py-[9px] rounded-lg bg-warning text-[#1a1200] font-semibold text-[12.5px] hover:brightness-110 inline-flex items-center gap-1.5"
>
Apply fix
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="9 18 15 12 9 6" /></svg>
</button>
</div>
</div>
</div>
)
}
function VerifyingBanner({ fix, onOutcome }: ProposalBannerProps) {
const [showOverflow, setShowOverflow] = useState(false)
const appliedLabel = fix.applied_at
? `Applied ${formatRelativeMinutes(fix.applied_at)}`
: 'Applied'
return (
<div className="relative border-t border-warning/30 bg-gradient-to-b from-warning-dim/40 to-warning-dim/20 px-5 py-3 animate-slide-up">
<div className="absolute left-0 top-0 bottom-0 w-[3px] bg-warning" />
<div className="flex items-start gap-3">
<div className="relative shrink-0 mt-0.5 w-7 h-7 rounded-md border border-warning/30 bg-warning-dim flex items-center justify-center text-warning">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
<span className="absolute inset-[-3px] rounded-lg animate-pulse-amber pointer-events-none" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 font-heading text-[10px] font-semibold uppercase tracking-[1.2px] text-warning">
<span>Verifying</span>
<span className="px-2 py-[2px] rounded-full bg-warning/20 text-warning text-[10.5px] font-bold normal-case tracking-normal">
{appliedLabel}
</span>
</div>
<div className="mt-0.5 text-[14px] font-semibold text-heading leading-snug">
Did "{fix.title}" work?
</div>
<div className="mt-1 text-[12.5px] text-muted-foreground leading-relaxed">
Mark the outcome so the AI can either close the session with this as the resolution, or propose something else.
</div>
</div>
<div className="flex items-center gap-2 shrink-0 pt-0.5 relative">
<button
onClick={() => setShowOverflow((v) => !v)}
className="p-1.5 rounded text-muted-foreground hover:bg-white/[0.08] hover:text-primary"
aria-label="More options"
>
<MoreHorizontal size={14} />
</button>
{showOverflow && (
<div className={cn(
'absolute top-full right-0 mt-1 w-48 rounded-lg',
'border border-white/10 bg-card shadow-xl py-1 z-10',
)}>
<button
onClick={() => {
setShowOverflow(false)
const notes = window.prompt('What did you run / skip?')
if (notes && notes.trim()) onOutcome('applied_partial', notes.trim())
}}
className="w-full text-left px-3 py-2 text-[12.5px] hover:bg-elevated text-primary"
>
Mark partial
</button>
</div>
)}
<button
onClick={() => {
const reason = window.prompt("Why didn't it work? (optional)")
onOutcome('applied_failed', reason?.trim() || undefined)
}}
className="px-3 py-[9px] rounded-lg border border-danger/30 text-danger text-[12.5px] font-medium hover:bg-danger-dim hover:border-danger inline-flex items-center gap-1.5"
>
<X size={12} strokeWidth={2.5} />
Didn't work
</button>
<button
onClick={() => onOutcome('applied_success')}
className="px-3 py-[9px] rounded-lg bg-success text-[#0a1a12] font-semibold text-[12.5px] hover:brightness-110 inline-flex items-center gap-1.5"
>
<Check size={12} strokeWidth={2.5} />
It worked
</button>
</div>
</div>
</div>
)
}
function formatRelativeMinutes(iso: string): string {
const then = new Date(iso).getTime()
const mins = Math.max(0, Math.round((Date.now() - then) / 60000))
if (mins === 0) return 'just now'
if (mins === 1) return '1m ago'
return `${mins}m ago`
}
function PartialBanner({ fix, onOutcome, onApply }: ProposalBannerProps) {
return (
<div className="relative border-t border-info/30 bg-gradient-to-b from-info-dim/40 to-info-dim/20 px-5 py-3 animate-slide-up">
<div className="absolute left-0 top-0 bottom-0 w-[3px] bg-info" />
<div className="flex items-start gap-3">
<div className="shrink-0 mt-0.5 w-7 h-7 rounded-md border border-info/30 bg-info-dim flex items-center justify-center text-info">
<Info size={15} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 font-heading text-[10px] font-semibold uppercase tracking-[1.2px] text-info">
<span>Partially applied</span>
<span className="px-2 py-[2px] rounded-full bg-info/20 text-info text-[10.5px] font-bold normal-case tracking-normal">
Parked
</span>
</div>
<div className="mt-0.5 text-[14px] font-semibold text-heading leading-snug">
{fix.title}
</div>
{fix.partial_notes && (
<div className="mt-1.5 flex items-center gap-2 px-2.5 py-1.5 rounded-md bg-info/[0.08] border border-info/30 text-[12px] italic text-primary">
<span className="not-italic font-bold text-info text-[10.5px] uppercase tracking-[0.6px]">Note</span>
<span>{fix.partial_notes}</span>
</div>
)}
</div>
<div className="flex items-center gap-2 shrink-0 pt-0.5">
<button
onClick={() => {
const reason = window.prompt("Why didn't it work? (optional)")
onOutcome('applied_failed', reason?.trim() || undefined)
}}
className="px-3 py-[9px] rounded-lg border border-danger/30 text-danger text-[12.5px] font-medium hover:bg-danger-dim hover:border-danger"
>
Didn't work
</button>
<button
onClick={onApply}
className="px-3 py-[9px] rounded-lg bg-card border border-white/10 text-primary text-[12.5px] font-medium hover:bg-elevated"
>
Finish it
</button>
<button
onClick={() => onOutcome('applied_success')}
className="px-3 py-[9px] rounded-lg bg-success text-[#0a1a12] font-semibold text-[12.5px] hover:brightness-110"
>
It worked
</button>
</div>
</div>
</div>
)
}
function AIConfirmingBanner({ fix, onAcceptAIProposal, onRejectAIProposal }: ProposalBannerProps) {
const proposal = fix.ai_outcome_proposal
if (!proposal) return null
const isSuccess = proposal.outcome === 'success'
const isFailure = proposal.outcome === 'failure'
const headlineVerb = isSuccess
? 'resolved the issue'
: isFailure
? "didn't work"
: 'was partially applied'
return (
<div className="relative border-t border-accent/30 bg-gradient-to-b from-accent-dim/40 to-accent-dim/20 px-5 py-3 animate-slide-up">
<div className="absolute left-0 top-0 bottom-0 w-[3px] bg-accent" />
<div className="flex items-start gap-3">
<div className="shrink-0 mt-0.5 w-7 h-7 rounded-md border border-accent/30 bg-accent-dim flex items-center justify-center text-accent">
<Sparkles size={15} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 font-heading text-[10px] font-semibold uppercase tracking-[1.2px] text-accent">
<span>AI detected outcome</span>
<span className="px-2 py-[2px] rounded-full bg-accent/20 text-accent text-[10.5px] font-bold normal-case tracking-normal">
{isSuccess ? 'Success' : isFailure ? 'Failure' : 'Partial'}
</span>
</div>
<div className="mt-0.5 text-[14px] font-semibold text-heading leading-snug">
AI thinks the fix {headlineVerb} — confirm?
</div>
<div className="mt-1 text-[12.5px] text-muted-foreground leading-relaxed">
{proposal.reason || 'Based on the recent chat. One click either confirms or corrects.'}
</div>
</div>
<div className="flex items-center gap-2 shrink-0 pt-0.5">
<button
onClick={onRejectAIProposal}
className="px-3 py-[9px] rounded-lg text-muted-foreground text-[12.5px] hover:bg-white/[0.08] hover:text-primary"
>
Not yet
</button>
<button
onClick={onAcceptAIProposal}
className={cn(
'px-3 py-[9px] rounded-lg font-semibold text-[12.5px] inline-flex items-center gap-1.5 hover:brightness-110',
isSuccess
? 'bg-success text-[#0a1a12]'
: 'bg-danger text-[#180808]',
)}
>
<Check size={12} strokeWidth={2.5} />
Confirm{isSuccess ? ' · Resolve' : ''}
</button>
</div>
</div>
</div>
)
}
function NudgeBanner({ fix, onOutcome, onSilenceNudge }: ProposalBannerProps) {
return (
<div className="relative border-t border-warning/30 bg-warning-dim/60 px-5 py-2 flex items-center gap-3">
<div className="absolute left-0 top-0 bottom-0 w-[3px] bg-warning" />
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-warning shrink-0">
<circle cx="12" cy="12" r="10" />
<path d="M12 8v4" />
<path d="M12 16h.01" />
</svg>
<span className="flex-1 text-[12.5px] text-primary">
Did <strong className="text-heading">"{fix.title}"</strong> work?
</span>
<button
onClick={onSilenceNudge}
className="px-2.5 py-1 rounded text-[12px] text-muted-foreground hover:bg-white/[0.08] hover:text-primary"
>
Still checking
</button>
<button
onClick={() => {
const reason = window.prompt("Why didn't it work? (optional)")
onOutcome('applied_failed', reason?.trim() || undefined)
}}
className="px-2.5 py-1 rounded border border-danger/30 text-danger text-[12px] hover:bg-danger-dim"
>
No
</button>
<button
onClick={() => onOutcome('applied_success')}
className="px-2.5 py-1 rounded bg-success text-[#0a1a12] font-semibold text-[12px] hover:brightness-110"
>
Yes
</button>
</div>
)
}
function CollapsedBanner({ fix, onToggleCollapsed }: ProposalBannerProps) {
return (
<button
onClick={onToggleCollapsed}
className="relative w-full border-t border-warning/30 bg-warning-dim/40 px-5 py-2 flex items-center gap-2.5 hover:bg-warning-dim/60 transition-colors text-left"
>
<div className="absolute left-0 top-0 bottom-0 w-[3px] bg-warning" />
<Sparkles size={12} className="text-warning shrink-0" />
<span className="flex-1 text-[12px] font-medium text-heading truncate">{fix.title}</span>
<span className="px-1.5 py-[1px] rounded-full bg-warning/20 text-warning text-[10.5px] font-bold tabular-nums">
{fix.confidence_pct}%
</span>
<span className="text-muted-foreground text-[11px]"> expand</span>
</button>
)
}
export default ProposalBanner

View File

@@ -0,0 +1,264 @@
/**
* ScriptBuilderTab — inline Script Builder controller mounted in the
* FlowPilot chat region when a fix needs a script drafted.
*
* Owns:
* - The inline `script_builder_sessions` row (get-or-create via the
* POST endpoint with origin='pilot_inline' + ai_session_id).
* - AI message state (reuses existing ScriptBuilderChat + ScriptBuilderInput).
* - Monaco buffer for the "Write it myself" mode (via ScriptBodyEditor).
* - Submit → PATCH /suggested-fixes/:id/script (no applied_at stamp).
*
* ScriptBuilderChat stays a pure display component — this controller
* wires its onSaveScript to the inline submit path.
*
* NOTE: CodeModeEditor is NOT used here — it's tightly coupled to
* treeEditorStore. ScriptBodyEditor is the generic Monaco wrapper.
*
* NOTE: `onProgressChange` is expected to be wrapped in useCallback at
* the call site so the dependency array in the relay effect is stable.
*
* Language is hardcoded to 'powershell' for Phase 9 v1. Future: derive
* from fix metadata or let the engineer pick.
*/
import { useEffect, useState } from 'react'
import { Sparkles, Pencil } from 'lucide-react'
import { cn } from '@/lib/utils'
import { ScriptBuilderChat } from '@/components/script-builder/ScriptBuilderChat'
import { ScriptBuilderInput } from '@/components/script-builder/ScriptBuilderInput'
import { ScriptBodyEditor } from '@/components/script-editor/ScriptBodyEditor'
import { sessionSuggestedFixesApi } from '@/api/sessionSuggestedFixes'
import { scriptBuilderApi } from '@/api'
import type { SessionSuggestedFix } from '@/api/sessionSuggestedFixes'
import type { ScriptBuilderMessage } from '@/types'
export interface ScriptBuilderTabProps {
fix: SessionSuggestedFix
pilotSessionId: string
/** Fires whenever in-progress state changes (for the ChatTabStrip dot).
* Wrap in useCallback at the call site to keep the relay effect stable. */
onProgressChange: (hasProgress: boolean) => void
/** Fires on successful submit; parent uses this to refresh the fix and hide the tab. */
onScriptDrafted: (updated: SessionSuggestedFix) => void
}
type Mode = 'ai' | 'editor'
const LANGUAGE = 'powershell'
export function ScriptBuilderTab({
fix,
pilotSessionId,
onProgressChange,
onScriptDrafted,
}: ScriptBuilderTabProps) {
const [builderSessionId, setBuilderSessionId] = useState<string | null>(null)
const [mode, setMode] = useState<Mode>('ai')
const [messages, setMessages] = useState<ScriptBuilderMessage[]>([])
const [editorBuffer, setEditorBuffer] = useState<string>(
() => scaffoldForLanguage(LANGUAGE, fix.description),
)
const [aiLoading, setAiLoading] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [latestScript, setLatestScript] = useState<string | null>(null)
// Relay in-progress state to parent. Stable as long as onProgressChange
// is wrapped in useCallback at the call site.
useEffect(() => {
const initialScaffold = scaffoldForLanguage(LANGUAGE, fix.description).trim()
const hasProgress =
messages.length > 0 ||
editorBuffer.trim() !== initialScaffold
onProgressChange(hasProgress)
}, [messages.length, editorBuffer, fix.description, onProgressChange])
// Get-or-create the inline session on mount (keyed to pilotSessionId so
// a new pilot session doesn't reuse a stale builder session).
useEffect(() => {
let cancelled = false
;(async () => {
try {
const s = await scriptBuilderApi.createSession(LANGUAGE, {
origin: 'pilot_inline',
aiSessionId: pilotSessionId,
})
if (cancelled) return
setBuilderSessionId(s.id)
// Resume existing messages if the session was already started
// (e.g. page refresh). getSession() returns the detail with messages[].
// listMessages() does NOT exist in the API client — use getSession instead.
if (s.messages && s.messages.length > 0) {
setMessages(s.messages)
}
if (s.latest_script) setLatestScript(s.latest_script)
} catch {
if (!cancelled) setError('Failed to start Script Builder session.')
}
})()
return () => {
cancelled = true
}
}, [pilotSessionId])
const handleSendMessage = async (content: string) => {
if (!builderSessionId) return
setAiLoading(true)
setError(null)
// Optimistically add the user message to the list immediately.
const userMsg: ScriptBuilderMessage = {
role: 'user',
content,
created_at: new Date().toISOString(),
}
setMessages((prev) => [...prev, userMsg])
try {
// sendMessage returns ScriptBuilderMessageResponse (single assistant reply).
const reply = await scriptBuilderApi.sendMessage(builderSessionId, content)
const assistantMsg: ScriptBuilderMessage = {
role: 'assistant',
content: reply.content,
script: reply.script,
script_filename: reply.script_filename,
line_count: reply.line_count,
created_at: reply.timestamp,
}
setMessages((prev) => [...prev, assistantMsg])
if (reply.script) setLatestScript(reply.script)
} catch {
// Replace optimistic user message with an error reply so the user sees it.
const errMsg: ScriptBuilderMessage = {
role: 'assistant',
content: 'Something went wrong generating the script. Please try again.',
created_at: new Date().toISOString(),
}
setMessages((prev) => [...prev, errMsg])
} finally {
setAiLoading(false)
}
}
const handleSubmit = async (script: string) => {
if (!script.trim() || submitting) return
setSubmitting(true)
setError(null)
try {
const updated = await sessionSuggestedFixesApi.patchScript(
pilotSessionId,
fix.id,
script,
)
onScriptDrafted(updated)
} catch {
setError('Failed to save the drafted script.')
} finally {
setSubmitting(false)
}
}
// AI mode: save the latest script produced by the AI.
const handleAiSave = () => {
if (latestScript) void handleSubmit(latestScript)
}
// onViewScript is required by ScriptBuilderChat — provide a no-op for now
// (inline preview is a future extension).
const handleViewScript = (_script: string, _filename: string | null) => {
// Future: open inline preview panel
}
return (
<div className="flex flex-col h-full bg-bg-page">
{/* Mode switcher header */}
<div className="flex items-center justify-between px-4 py-2 border-b border-default shrink-0">
<div className="text-[12.5px] text-heading font-medium truncate pr-2">
Script Builder · {fix.title}
</div>
<div className="flex items-center gap-2 shrink-0">
{mode === 'ai' ? (
<button
onClick={() => setMode('editor')}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded text-[11.5px] text-muted-foreground border border-default hover:text-primary hover:border-hover transition-colors"
>
<Pencil size={11} />
Write it myself
</button>
) : (
<button
onClick={() => setMode('ai')}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded text-[11.5px] text-muted-foreground border border-default hover:text-primary hover:border-hover transition-colors"
>
<Sparkles size={11} />
Back to AI
</button>
)}
</div>
</div>
{error && (
<div className="mx-4 mt-2 text-[11.5px] text-danger bg-danger-dim border border-danger/30 rounded px-2 py-1 shrink-0">
{error}
</div>
)}
{/* Content area — display:none on inactive mode so state persists */}
<div className="flex-1 min-h-0">
{/* AI chat mode */}
<div className={cn('flex flex-col h-full', mode !== 'ai' && 'hidden')}>
<div className="flex-1 min-h-0 overflow-y-auto flex flex-col">
<ScriptBuilderChat
messages={messages}
language={LANGUAGE}
onViewScript={handleViewScript}
onSaveScript={handleAiSave}
isLoading={aiLoading}
/>
</div>
<div className="shrink-0">
<ScriptBuilderInput
onSend={handleSendMessage}
disabled={aiLoading || !builderSessionId}
placeholder="Describe the script you need…"
showSuggestions={messages.length === 0}
/>
</div>
</div>
{/* Editor (Monaco) mode */}
<div className={cn('flex flex-col h-full', mode !== 'editor' && 'hidden')}>
<div className="flex-1 min-h-0 p-4">
<ScriptBodyEditor
value={editorBuffer}
onChange={setEditorBuffer}
disabled={submitting}
/>
</div>
<div className="px-4 py-2 border-t border-default flex justify-end shrink-0">
<button
onClick={() => void handleSubmit(editorBuffer)}
disabled={submitting || !editorBuffer.trim()}
className={cn(
'px-3.5 py-[7px] rounded text-[12.5px] font-semibold transition-colors',
'bg-accent text-[#0a0d14] hover:brightness-110 disabled:opacity-50 disabled:cursor-not-allowed',
)}
>
{submitting ? 'Saving…' : 'Submit script'}
</button>
</div>
</div>
</div>
</div>
)
}
function scaffoldForLanguage(language: string, fixDescription: string): string {
if (language === 'bash' || language === 'python') {
return `# ${fixDescription}\n\n`
}
// PowerShell uses CRLF line endings by convention
return `# ${fixDescription}\r\n\r\n`
}
export default ScriptBuilderTab

View File

@@ -28,6 +28,9 @@ interface TemplateMatchPanelProps {
fix: SessionSuggestedFix
sessionId: string
onClose: () => void
/** Fires when the engineer declares the script was run. Parent calls
* applyFix() to stamp applied_at. */
onMarkRun?: () => void
}
interface ParamSchemaEntry {
@@ -39,7 +42,7 @@ interface ParamSchemaEntry {
options?: Array<{ label: string; value: string }>
}
export function TemplateMatchPanel({ fix, sessionId, onClose }: TemplateMatchPanelProps) {
export function TemplateMatchPanel({ fix, sessionId, onClose, onMarkRun }: TemplateMatchPanelProps) {
const [template, setTemplate] = useState<ScriptTemplateDetail | null>(null)
const [templateLoading, setTemplateLoading] = useState(true)
const [templateError, setTemplateError] = useState<string | null>(null)
@@ -243,6 +246,16 @@ export function TemplateMatchPanel({ fix, sessionId, onClose }: TemplateMatchPan
>
Edit parameters
</button>
{onMarkRun && (
<button
onClick={onMarkRun}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded text-[0.75rem] font-semibold bg-accent text-[#0a0d14] hover:brightness-110 transition-colors"
aria-label="Mark the script as applied to start verifying the fix"
>
<Check size={12} aria-hidden="true" />
I ran this
</button>
)}
</div>
</>
)}

View File

@@ -1,97 +0,0 @@
/**
* SuggestedFix card — Phase 3 task-lane section.
*
* Renders the active AI-proposed resolution path for the session
* (per FLOWPILOT-MIGRATION.md Section 3.1, "Suggested fix"). Amber-accented
* to match the mockup; clicking opens the Script Generator flow in Phase 5.
*
* For Phase 3, the card is informational + a Dismiss action. The three-option
* dialog (one_off / draft_template / build_template) is wired in Phase 5
* via a separate component.
*/
import { useState } from 'react'
import { Sparkles, X } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { SessionSuggestedFix } from '@/api/sessionSuggestedFixes'
interface SuggestedFixProps {
fix: SessionSuggestedFix
onDismiss: () => Promise<void> | void
// Phase 5: clicking the card body opens the inline Script Generator panel
// (TemplateMatchPanel for template-matched fixes, NoTemplateDialog otherwise).
onActivate?: () => void
// Whether the script panel is currently open for THIS fix — controls the
// "Open" / "Close" affordance label on the card.
panelOpen?: boolean
}
function confidenceBucket(pct: number): { label: string; tone: string } {
if (pct >= 80) return { label: 'high', tone: 'text-success' }
if (pct >= 50) return { label: 'medium', tone: 'text-warning' }
return { label: 'low', tone: 'text-muted-foreground' }
}
export function SuggestedFix({ fix, onDismiss, onActivate, panelOpen }: SuggestedFixProps) {
const [busy, setBusy] = useState(false)
const conf = confidenceBucket(fix.confidence_pct)
const handleDismiss = async (e: React.MouseEvent) => {
e.stopPropagation() // don't trigger the card-body activation
setBusy(true)
try { await onDismiss() } finally { setBusy(false) }
}
return (
<section>
<div className="pb-2">
<div className="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-[1.2px] text-muted-foreground pl-0.5">
<span className="w-1.5 h-1.5 rounded-full bg-warning" />
Suggested fix
<span className="text-muted-foreground">·</span>
<span className={`tabular-nums ${conf.tone}`}>{fix.confidence_pct}% confidence</span>
</div>
</div>
<div
onClick={onActivate}
className={cn(
'rounded-lg border-l-[3px] border-l-warning border border-warning/25 bg-warning-dim/15 p-3 mb-2 transition-colors',
onActivate && 'cursor-pointer hover:border-warning/50 hover:bg-warning-dim/25',
panelOpen && 'border-warning/60 bg-warning-dim/30',
)}
>
<div className="flex items-start gap-2">
<Sparkles size={14} className="text-warning shrink-0 mt-0.5" />
<div className="min-w-0 flex-1">
<div className="text-[0.8125rem] font-medium text-heading leading-snug">
{fix.title}
</div>
<div className="mt-1 text-[0.75rem] text-muted-foreground leading-relaxed">
{fix.description}
</div>
{fix.script_template_id && (
<div className="mt-1.5 text-[0.6875rem] text-success">
Matches an existing Script Library template click to use
</div>
)}
{!fix.script_template_id && fix.ai_drafted_script && (
<div className="mt-1.5 text-[0.6875rem] text-accent-text">
Custom script drafted click to review options
</div>
)}
</div>
<button
onClick={handleDismiss}
disabled={busy}
className="p-1 rounded text-muted-foreground hover:text-danger hover:bg-elevated/40 transition-colors"
title="Dismiss this suggestion"
>
<X size={11} />
</button>
</div>
</div>
</section>
)
}
export default SuggestedFix

View File

@@ -86,7 +86,18 @@
--animate-slide-in-bottom: slide-in-from-bottom 200ms ease-out both;
--animate-scale-in: scale-in 150ms ease-out both;
--animate-fade: fadeIn 300ms ease both;
--animate-slide-up: slide-up 320ms cubic-bezier(.22,.9,.28,1) both;
--animate-pulse-amber: pulse-amber 1.6s infinite;
@keyframes slide-up {
from { transform: translateY(14px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes pulse-amber {
0% { box-shadow: 0 0 0 0 rgba(251,191,36,0.45); }
70% { box-shadow: 0 0 0 10px rgba(251,191,36,0); }
100% { box-shadow: 0 0 0 0 rgba(251,191,36,0); }
}
@keyframes fade-in {
from { opacity: 0; } to { opacity: 1; }
}

View File

@@ -14,11 +14,16 @@ import { ChatSidebar, ChatSidebarCollapsedBar } from '@/components/assistant/Cha
import { ChatMessage } from '@/components/assistant/ChatMessage'
import { TaskLane, clearTaskState } from '@/components/assistant/TaskLane'
import { WhatWeKnow } from '@/components/pilot/sections/WhatWeKnow'
import { SuggestedFix } from '@/components/pilot/sections/SuggestedFix'
import { ResolutionNotePreview as ResolutionNotePreviewPopover } from '@/components/pilot/ResolutionNotePreview'
import { ProposalBanner } from '@/components/pilot/ProposalBanner'
import type { BannerMode } from '@/components/pilot/ProposalBanner'
import { EscalateInterceptDialog } from '@/components/pilot/EscalateInterceptDialog'
import type { InterceptChoice } from '@/components/pilot/EscalateInterceptDialog'
import { TemplateMatchPanel } from '@/components/pilot/script/TemplateMatchPanel'
import { NoTemplateDialog } from '@/components/pilot/script/NoTemplateDialog'
import { TemplatizePrompt } from '@/components/pilot/script/TemplatizePrompt'
import { ChatTabStrip, type ChatTab } from '@/components/pilot/ChatTabStrip'
import { ScriptBuilderTab } from '@/components/pilot/ScriptBuilderTab'
import { InlineNoTemplateDialog } from '@/components/pilot/InlineNoTemplateDialog'
import { ShortcutsHelpOverlay } from '@/components/pilot/ShortcutsHelpOverlay'
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts'
import { useMediaQuery } from '@/hooks/useMediaQuery'
@@ -34,6 +39,7 @@ import {
type SessionSuggestedFix,
type ResolutionNotePreview as ResolutionNotePreviewData,
type UserDecision,
type FixOutcome,
} from '@/api/sessionSuggestedFixes'
import { ConcludeSessionModal } from '@/components/assistant/ConcludeSessionModal'
import { StatusUpdateModal } from '@/components/flowpilot/StatusUpdateModal'
@@ -129,6 +135,46 @@ export default function AssistantChatPage() {
// Phase 7: below 1200px the task lane collapses to a bottom drawer per the
// migration spec. Above, it's the standard right-side panel.
const isNarrow = useMediaQuery('(max-width: 1199px)')
// Phase 8: ProposalBanner + EscalateInterceptDialog state.
const [bannerCollapsed, setBannerCollapsed] = useState(false)
const [postApplyMsgCount, setPostApplyMsgCount] = useState(0)
const [nudgeSilenced, setNudgeSilenced] = useState(false)
const [escalateIntercept, setEscalateIntercept] = useState<{ fixId: string; fixTitle: string } | null>(null)
// Phase 9: ChatTabStrip + ScriptBuilderTab state.
const [chatTab, setChatTab] = useState<ChatTab>('chat')
const [scriptBuilderHasProgress, setScriptBuilderHasProgress] = useState(false)
// Phase 8: compute the current banner mode from activeFix.
// applied_at is now persisted on the server (stamped by POST /apply),
// so bannerMode is derived entirely from server state — no client-side flag.
const bannerMode: BannerMode | null = (() => {
if (!activeFix) return null
if (activeFix.status === 'dismissed') return null
if (activeFix.ai_outcome_proposal) return 'ai_confirming'
if (activeFix.status === 'applied_partial') return 'partial'
if (activeFix.status === 'applied_success' || activeFix.status === 'applied_failed') return null
if (activeFix.applied_at) {
if (postApplyMsgCount >= 3 && !nudgeSilenced) return 'nudge'
return 'verifying'
}
return 'proposed'
})()
// Phase 9: show the tab strip when the fix needs a script drafted (no template,
// no drafted script yet, and still in a live state).
const showTabStrip =
activeFix != null
&& activeFix.status !== 'dismissed'
&& activeFix.status !== 'applied_success'
&& activeFix.status !== 'applied_failed'
&& !activeFix.script_template_id
&& !activeFix.ai_drafted_script
// Defensive: if the strip hides (fix resolved/dismissed/script-drafted),
// snap back to the Chat tab so the user doesn't land on a blank panel.
useEffect(() => {
if (!showTabStrip && chatTab === 'script_builder') setChatTab('chat')
}, [showTabStrip, chatTab])
const toggleSidebarCollapse = () => {
const next = !sidebarCollapsed
setSidebarCollapsed(next)
@@ -313,6 +359,14 @@ export default function AssistantChatPage() {
setPreviewError(null)
setPreviewPosting(false)
setScriptPanelOpen(false)
// Phase 8: banner state reset
setBannerCollapsed(false)
setPostApplyMsgCount(0)
setNudgeSilenced(false)
setEscalateIntercept(null)
// Phase 9: tab strip reset
setChatTab('chat')
setScriptBuilderHasProgress(false)
}, [])
// Phase 2 facts — fetch + handlers. `refreshFacts` is called from selectChat
@@ -434,19 +488,6 @@ export default function AssistantChatPage() {
}
}
const handleDismissFix = async () => {
if (!activeChatId || !activeFix) return
try {
await sessionSuggestedFixesApi.recordDecision(activeChatId, activeFix.id, 'dismissed')
setActiveFix(null)
setScriptPanelOpen(false)
// Dismissal bumps state_version on the server; reflect in preview.
schedulePreviewRefresh(activeChatId)
} catch {
toast.error('Failed to dismiss suggestion')
}
}
// Phase 5: handle a path choice from NoTemplateDialog. one_off and
// draft_template just record the decision (returning the rendered script
// for display); build_template returns a redirect_path to the Script
@@ -474,6 +515,17 @@ export default function AssistantChatPage() {
} else if (decision === 'draft_template') {
toast.success('Draft template queued — review after Resolve')
}
// Phase 9 §5: one_off and draft_template declare a run ("Run now, …").
// Stamp applied_at to transition the fix into Verifying.
// build_template does NOT run — no stamp.
if (decision === 'one_off' || decision === 'draft_template') {
try {
const updated = await sessionSuggestedFixesApi.applyFix(
activeChatId, activeFix.id,
)
setActiveFix(updated)
} catch { /* non-fatal: engineer can still mark outcome later */ }
}
// Keep the panel open so the engineer can copy the rendered script.
} catch {
toast.error('Failed to record decision')
@@ -482,7 +534,9 @@ export default function AssistantChatPage() {
}
}
const handleOpenPreview = (kind: 'resolve' | 'escalate') => {
// handleOpenPreview is declared before handleSetOutcome so it can be listed
// as a useCallback dep without a temporal dead zone.
const handleOpenPreview = useCallback((kind: 'resolve' | 'escalate') => {
if (!activeChatId) return
// Opening a different kind clobbers the cached markdown so the popover
// doesn't flash stale content while the new kind fetches.
@@ -490,7 +544,146 @@ export default function AssistantChatPage() {
setPreviewKind(kind)
setPreviewError(null)
refreshPreview(activeChatId, kind)
}
}, [activeChatId, previewKind, refreshPreview])
// Phase 9: handleApplyFix — routes to the appropriate surface based on
// fix state. applyFix() call site moves to Task 13 (handleScriptDecision
// and TemplateMatchPanel.onMarkRun).
const handleApplyFix = useCallback(() => {
if (!activeFix) return
if (activeFix.script_template_id) {
setScriptPanelOpen(true) // existing TemplateMatchPanel flow in task lane
return
}
if (activeFix.ai_drafted_script) {
setScriptPanelOpen(true) // InlineNoTemplateDialog, now in chat region (Step 5)
return
}
// No draft, no template — route to the Script Builder tab.
setChatTab('script_builder')
}, [activeFix])
// Phase 9 Task 13: TemplateMatchPanel "I ran this" — stamps applied_at so the
// ProposalBanner transitions from Proposed to Verifying. Shared useCallback so
// both render sites (narrow-drawer + side-panel) are identical.
const handleMarkRun = useCallback(async () => {
if (!activeFix || !activeChatId) return
try {
const updated = await sessionSuggestedFixesApi.applyFix(
activeChatId, activeFix.id,
)
setActiveFix(updated)
setScriptPanelOpen(false)
} catch { /* non-fatal: engineer can still mark outcome later */ }
}, [activeFix, activeChatId])
// Phase 8: record a terminal outcome for the active fix. Updates local state
// on success. For applied_success also opens the Resolve preview.
const handleSetOutcome = useCallback(async (outcome: FixOutcome, notes?: string) => {
if (!activeChatId || !activeFix) return
try {
const updated = await sessionSuggestedFixesApi.patchOutcome(activeChatId, activeFix.id, outcome, notes)
setActiveFix(updated)
// Reset apply tracking state since we now have a terminal outcome.
setPostApplyMsgCount(0)
setNudgeSilenced(false)
if (outcome === 'applied_success') {
// Open the Resolve note preview so the engineer can post to PSA.
handleOpenPreview('resolve')
}
} catch (err: unknown) {
const status = (err as { response?: { status?: number; data?: { detail?: string } } })?.response?.status
if (status === 409) {
toast.warning('Outcome already recorded — session may already be in a terminal state.')
} else {
toast.error('Failed to record outcome')
}
}
}, [activeChatId, activeFix, handleOpenPreview])
// Phase 8: accept the AI-proposed outcome. Translates AI proposal outcome
// names to FixOutcome values, then delegates to handleSetOutcome.
// For partial, a non-empty notes string is required by the backend (400 on
// empty). Fall back to a generic note if the AI's reason is blank.
const handleAcceptAIProposal = useCallback(async () => {
if (!activeFix?.ai_outcome_proposal) return
const { outcome, reason } = activeFix.ai_outcome_proposal
const fixOutcome: FixOutcome =
outcome === 'success' ? 'applied_success'
: outcome === 'failure' ? 'applied_failed'
: 'applied_partial'
const notes = fixOutcome === 'applied_partial'
? (reason?.trim() || 'Partially applied per AI detection')
: fixOutcome === 'applied_failed'
? reason?.trim() || undefined
: undefined
await handleSetOutcome(fixOutcome, notes)
}, [activeFix, handleSetOutcome])
// Phase 8: reject the AI proposal — persist the rejection to the server so
// the banner does not re-surface on the next refreshSessionDerived call.
// Falls back to a local-state clear on error (non-fatal: banner may re-arm
// on the next refetch, matching the previous behaviour).
const handleRejectAIProposal = useCallback(async () => {
if (!activeFix || !activeChatId) return
try {
const updated = await sessionSuggestedFixesApi.clearAIProposal(activeChatId, activeFix.id)
setActiveFix(updated)
} catch {
// Non-fatal fallback: clear locally so the banner disappears immediately.
setActiveFix({ ...activeFix, ai_outcome_proposal: null })
}
}, [activeFix, activeChatId])
// Phase 8: silence the nudge banner without recording an outcome.
const handleSilenceNudge = useCallback(() => {
setNudgeSilenced(true)
setPostApplyMsgCount(0)
}, [])
// Phase 8: Escalate intercept — capture fix outcome before proceeding.
// Wraps the existing Escalate click (which opens ConcludeSessionModal).
const handleEscalateClick = useCallback(() => {
const inVerifyState =
activeFix && (
(!!activeFix.applied_at && activeFix.status === 'proposed') ||
activeFix.status === 'applied_partial'
)
if (inVerifyState && activeFix) {
setEscalateIntercept({ fixId: activeFix.id, fixTitle: activeFix.title })
return
}
setShowConclude(true)
}, [activeFix])
const handleInterceptChoice = useCallback(async (choice: InterceptChoice) => {
const stored = escalateIntercept
setEscalateIntercept(null)
if (!stored || !activeChatId) return
const outcomeToSend: FixOutcome =
choice === 'never_applied' ? 'dismissed' : choice
try {
const updated = await sessionSuggestedFixesApi.patchOutcome(
activeChatId, stored.fixId, outcomeToSend,
)
setActiveFix(updated)
} catch { /* non-fatal — engineer can still escalate */ }
setShowConclude(true)
}, [activeChatId, escalateIntercept])
// Phase 8: Resolve click — auto-mark applied_success if in verifying state
// before opening the resolution note preview.
const handleResolveClick = useCallback(async () => {
if (activeFix && activeFix.applied_at && activeFix.status === 'proposed' && activeChatId) {
try {
const updated = await sessionSuggestedFixesApi.patchOutcome(activeChatId, activeFix.id, 'applied_success')
setActiveFix(updated)
} catch {
// Non-fatal; user can still resolve.
}
}
setShowConclude(true)
}, [activeChatId, activeFix])
const handleClosePreview = () => {
setPreviewKind(null)
@@ -644,8 +837,8 @@ export default function AssistantChatPage() {
await aiSessionsApi.deleteSession(chatId)
setChats(prev => prev.filter(c => c.id !== chatId))
if (activeChatId === chatId) {
resetSessionDerivedState()
setActiveChatId(null)
setMessages([])
}
} catch {
toast.error('Failed to delete chat')
@@ -704,6 +897,13 @@ export default function AssistantChatPage() {
setActiveActions(response.actions || [])
setShowTaskLane(true)
}
// Phase 8: increment post-apply message counter for nudge logic.
// Only increments when fix is still in 'proposed' (verifying) state —
// partial/dismissed/terminal states don't render the nudge, and a
// partial→verifying transition could inherit an already-saturated counter.
if (activeFix && activeFix.applied_at && activeFix.status === 'proposed') {
setPostApplyMsgCount(c => c + 1)
}
// Refetch facts + active fix; preview refreshes if open.
refreshSessionDerived(sentForChatId)
} catch (err: unknown) {
@@ -774,6 +974,11 @@ export default function AssistantChatPage() {
setActiveQuestions([])
setActiveActions([])
}
// Phase 8: increment post-apply message counter for nudge logic (mirrors handleSend).
// Only increments in 'proposed' (verifying) state — same rationale as handleSend.
if (activeFix && activeFix.applied_at && activeFix.status === 'proposed') {
setPostApplyMsgCount(c => c + 1)
}
// Refetch facts + active fix; answering tasks is the primary trigger.
refreshSessionDerived(sentForChatId)
} catch (err: unknown) {
@@ -1080,7 +1285,7 @@ export default function AssistantChatPage() {
{isActive && (
<>
<button
onClick={() => setShowConclude(true)}
onClick={handleResolveClick}
disabled={!canAct}
data-conclude-outcome="resolved"
className="flex items-center gap-1.5 rounded-lg bg-success-dim border border-success/20 px-3 py-1.5 text-xs font-medium text-success hover:bg-success/20 disabled:opacity-40 disabled:pointer-events-none transition-colors"
@@ -1088,8 +1293,9 @@ export default function AssistantChatPage() {
<CheckCircle2 size={13} />
Resolve
</button>
<div className="relative">
<button
onClick={() => setShowConclude(true)}
onClick={handleEscalateClick}
disabled={!canAct}
data-conclude-outcome="escalated"
className="flex items-center gap-1.5 rounded-lg bg-warning-dim border border-warning/20 px-3 py-1.5 text-xs font-medium text-warning hover:bg-warning/20 disabled:opacity-40 disabled:pointer-events-none transition-colors"
@@ -1097,6 +1303,14 @@ export default function AssistantChatPage() {
<ArrowUpRight size={13} />
Escalate
</button>
{escalateIntercept && (
<EscalateInterceptDialog
fixTitle={escalateIntercept.fixTitle}
onChoose={handleInterceptChoice}
onClose={() => setEscalateIntercept(null)}
/>
)}
</div>
</>
)}
{messages.length >= 2 && (
@@ -1152,21 +1366,32 @@ export default function AssistantChatPage() {
{isActive && (
<>
<button
onClick={() => { setShowOverflow(false); setShowConclude(true) }}
onClick={() => { setShowOverflow(false); handleResolveClick() }}
disabled={!canAct}
className="flex w-full items-center gap-2 px-3 py-2.5 text-xs text-success hover:bg-success-dim transition-colors disabled:opacity-40"
>
<CheckCircle2 size={14} />
Resolve
</button>
<button
onClick={() => { setShowOverflow(false); setShowConclude(true) }}
disabled={!canAct}
className="flex w-full items-center gap-2 px-3 py-2.5 text-xs text-warning hover:bg-warning-dim transition-colors disabled:opacity-40"
>
<ArrowUpRight size={14} />
Escalate
</button>
{/* Mobile Escalate: wrapped in relative so EscalateInterceptDialog anchors here */}
<div className="relative">
<button
onClick={() => { setShowOverflow(false); handleEscalateClick() }}
disabled={!canAct}
className="flex w-full items-center gap-2 px-3 py-2.5 text-xs text-warning hover:bg-warning-dim transition-colors disabled:opacity-40"
>
<ArrowUpRight size={14} />
Escalate
</button>
{/* Mobile intercept dialog — mirrors desktop; only one is visible at a time */}
{escalateIntercept && (
<EscalateInterceptDialog
fixTitle={escalateIntercept.fixTitle}
onChoose={handleInterceptChoice}
onClose={() => setEscalateIntercept(null)}
/>
)}
</div>
</>
)}
<button
@@ -1195,6 +1420,19 @@ export default function AssistantChatPage() {
)
})()}
{/* Phase 9: ChatTabStrip — shown when the fix needs a script drafted */}
{showTabStrip && (
<ChatTabStrip
active={chatTab}
onChange={setChatTab}
scriptBuilderHasProgress={scriptBuilderHasProgress}
/>
)}
{/* Chat tab content — messages + banner + composer.
Hidden (not unmounted) when Script Builder tab is active so
scroll position and input state are preserved. */}
<div className={cn('flex-1 min-h-0 flex flex-col', chatTab !== 'chat' && 'hidden')}>
{/* Messages */}
<div className="flex-1 overflow-y-auto px-4 sm:px-6 py-4 space-y-4">
{messages.length === 0 && !loading && (
@@ -1233,6 +1471,34 @@ export default function AssistantChatPage() {
<div ref={messagesEndRef} />
</div>
{/* Phase 8: ProposalBanner — mounted above the composer */}
{activeFix && bannerMode && (
<ProposalBanner
fix={activeFix}
mode={bannerMode}
collapsed={bannerCollapsed && bannerMode !== 'nudge' && bannerMode !== 'ai_confirming'}
onToggleCollapsed={() => setBannerCollapsed(v => !v)}
onApply={handleApplyFix}
onDismiss={() => handleSetOutcome('dismissed')}
onOutcome={handleSetOutcome}
onAcceptAIProposal={handleAcceptAIProposal}
onRejectAIProposal={handleRejectAIProposal}
onSilenceNudge={handleSilenceNudge}
/>
)}
{/* Phase 9: InlineNoTemplateDialog — drafted-script evaluation case,
rendered in the chat region above the composer so all three
option cards fit side-by-side without the TaskLane's narrow width. */}
{scriptPanelOpen && activeFix && activeChatId && !activeFix.script_template_id && activeFix.ai_drafted_script && (
<InlineNoTemplateDialog
fix={activeFix}
onClose={() => setScriptPanelOpen(false)}
onDecide={handleScriptDecision}
busy={scriptDecisionBusy}
/>
)}
{/* Rich Input */}
<div className="px-3 sm:px-6 py-3 shrink-0">
<div
@@ -1357,6 +1623,24 @@ export default function AssistantChatPage() {
</div>
</div>
</div>
</div>{/* end chat-tab content wrapper */}
{/* Phase 9: Script Builder tab — mounted alongside chat via display:none
so both scroll positions and state are preserved across tab switches. */}
{showTabStrip && activeFix && activeChatId && (
<div className={cn('flex-1 min-h-0 flex flex-col', chatTab !== 'script_builder' && 'hidden')}>
<ScriptBuilderTab
fix={activeFix}
pilotSessionId={activeChatId}
onProgressChange={setScriptBuilderHasProgress}
onScriptDrafted={(updated) => {
setActiveFix(updated)
setChatTab('chat')
setScriptBuilderHasProgress(false)
}}
/>
</div>
)}
</>
) : (
<div className="flex flex-col items-center justify-center h-full text-center">
@@ -1431,33 +1715,15 @@ export default function AssistantChatPage() {
loading={loading}
/>
}
suggestedFixSlot={
activeFix && (
<SuggestedFix
fix={activeFix}
onDismiss={handleDismissFix}
onActivate={() => setScriptPanelOpen((prev) => !prev)}
panelOpen={scriptPanelOpen}
/>
)
}
bottomSlot={
<>
{scriptPanelOpen && activeFix && activeChatId && (
activeFix.script_template_id ? (
<TemplateMatchPanel
fix={activeFix}
sessionId={activeChatId}
onClose={() => setScriptPanelOpen(false)}
/>
) : (
<NoTemplateDialog
fix={activeFix}
onClose={() => setScriptPanelOpen(false)}
onDecide={handleScriptDecision}
busy={scriptDecisionBusy}
/>
)
{scriptPanelOpen && activeFix && activeChatId && activeFix.script_template_id && (
<TemplateMatchPanel
fix={activeFix}
sessionId={activeChatId}
onClose={() => setScriptPanelOpen(false)}
onMarkRun={handleMarkRun}
/>
)}
<div className="flex items-center gap-3 px-3 mt-1">
<button
@@ -1520,33 +1786,15 @@ export default function AssistantChatPage() {
loading={loading}
/>
}
suggestedFixSlot={
activeFix && (
<SuggestedFix
fix={activeFix}
onDismiss={handleDismissFix}
onActivate={() => setScriptPanelOpen((prev) => !prev)}
panelOpen={scriptPanelOpen}
/>
)
}
bottomSlot={
<>
{scriptPanelOpen && activeFix && activeChatId && (
activeFix.script_template_id ? (
<TemplateMatchPanel
fix={activeFix}
sessionId={activeChatId}
onClose={() => setScriptPanelOpen(false)}
/>
) : (
<NoTemplateDialog
fix={activeFix}
onClose={() => setScriptPanelOpen(false)}
onDecide={handleScriptDecision}
busy={scriptDecisionBusy}
/>
)
{scriptPanelOpen && activeFix && activeChatId && activeFix.script_template_id && (
<TemplateMatchPanel
fix={activeFix}
sessionId={activeChatId}
onClose={() => setScriptPanelOpen(false)}
onMarkRun={handleMarkRun}
/>
)}
<div className="flex items-center gap-3 px-3 mt-1">
<button