chore: archive old plan docs + add survey foundation files
Move completed plan docs to docs/plans/archive/. Add survey migration 046 and reference HTML/plan files. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
458
docs/plans/archive/2026-02-13-EXPORT-IMPROVEMENTS-SPEC.md
Normal file
458
docs/plans/archive/2026-02-13-EXPORT-IMPROVEMENTS-SPEC.md
Normal file
@@ -0,0 +1,458 @@
|
||||
# Export & Ticket Note Improvements — Merged Specification
|
||||
|
||||
> **Date:** February 13, 2026
|
||||
> **Status:** Draft — Pending Implementation
|
||||
> **Source:** Merged from two independent improvement proposals
|
||||
> **Scope:** Backend export generators, frontend export UX, session model changes
|
||||
> **Dependencies:** Existing session export system (`sessions.py`), `SessionExport` schema, `TreeNavigationPage`, `SessionDetailPage`
|
||||
|
||||
---
|
||||
|
||||
## Problem Statement
|
||||
|
||||
ResolutionFlow's export system currently treats sessions as all-or-nothing: either a session is complete and exports fully, or it's incomplete and exports with no indication of status, missing context, or next steps. This creates several gaps for MSP engineers:
|
||||
|
||||
1. **No mid-session export** — Engineers pulled away mid-troubleshooting can't grab progress notes from the active navigation page without leaving the flow.
|
||||
2. **Outcome notes silently dropped** — Engineers write outcome notes in the completion modal, but these never appear in exported ticket notes.
|
||||
3. **No follow-up / next steps section** — MSP tickets almost always need follow-up actions documented, but the export has no dedicated field for this.
|
||||
4. **No export control granularity** — No way to export a subset of steps, control verbosity, or review/redact content before copying to a ticket system.
|
||||
5. **Custom steps not differentiated** — Steps added by the engineer during a session (via Step Library) look identical to tree-authored steps in the export.
|
||||
|
||||
---
|
||||
|
||||
## Feature Summary
|
||||
|
||||
| # | Feature | Priority | Effort | Source |
|
||||
|---|---------|----------|--------|--------|
|
||||
| 1 | Mid-session export from TreeNavigationPage | High | Medium | Proposal 2 |
|
||||
| 2 | Partial export with step cutoff | High | Small | Proposal 1 |
|
||||
| 3 | Include outcome_notes in export | High | Small | Both |
|
||||
| 4 | Next Steps / Follow-Up field + export section | High | Medium | Proposal 2 + new DB field |
|
||||
| 5 | Structured Ticket Summary block | Medium | Medium | Proposal 1 |
|
||||
| 6 | Custom step differentiation in export | Medium | Small | Proposal 2 |
|
||||
| 7 | Verbosity / detail level controls | Medium | Medium | Proposal 1 |
|
||||
| 8 | Editable preview before copy | Medium | Medium | Both |
|
||||
| 9 | Sensitive data review / redaction | Low | Large | Proposal 1 |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase A: Core Export Gaps (High Priority)
|
||||
|
||||
These address missing data in exports — things that should be there today but aren't.
|
||||
|
||||
#### A1. Include Outcome Notes in Export
|
||||
|
||||
**Problem:** `outcome_notes` is captured during session completion but not rendered in any export format.
|
||||
|
||||
**Current state:** The session model already has `outcome_notes` as a `Text` column, and `SessionComplete` already accepts `outcome_notes: Optional[str]`. However, none of the four export generators (`generate_markdown_export`, `generate_text_export`, `generate_html_export`, `generate_psa_export`) in `backend/app/services/export_service.py` reference it.
|
||||
|
||||
> **VERIFIED:** `outcome_notes` exists on the Session model and SessionComplete schema. No migration needed for this field — only export generator changes.
|
||||
|
||||
**Changes required:**
|
||||
|
||||
- **Backend:** Add an "Outcome / Resolution Notes" section to all four export generators (markdown, text, HTML, PSA), rendered after the Troubleshooting Steps section and before any Next Steps section. Only render if `outcome_notes` is non-empty. For PSA format, update the existing `--- RESOLUTION ---` section to use `outcome_notes` instead of the last decision's answer.
|
||||
- **Schema:** Add `include_outcome_notes: bool = True` to `SessionExport`.
|
||||
|
||||
**Markdown output example:**
|
||||
```markdown
|
||||
## Resolution
|
||||
|
||||
Replaced failed DIMM in slot A2. Memtest passed 3 cycles post-replacement.
|
||||
Server returned to production at 14:45.
|
||||
```
|
||||
|
||||
#### A2. Next Steps / Follow-Up Field
|
||||
|
||||
**Problem:** MSP tickets almost always need follow-up actions documented ("Monitor for 24 hours", "Schedule firmware update for maintenance window", "Escalate to vendor if recurs"), but there is no field for this in the session model or the export.
|
||||
|
||||
**Changes required:**
|
||||
|
||||
- **Database:** Add `next_steps` column to the `sessions` table as a new `Text` field (nullable, `server_default=sa.text("''")`), following the same pattern as the `scratchpad` column.
|
||||
- **Migration:** Alembic migration to add the column with backfill of existing rows.
|
||||
- **Schema:** Add `next_steps: Optional[str] = None` to `SessionUpdate`. Add `next_steps: str = ""` with normalizing validator to `SessionResponse`.
|
||||
- **Frontend — Completion modal:** Add a "Next Steps / Follow-Up" text area to the session completion flow, so engineers can capture follow-up actions at the same time they write outcome notes.
|
||||
- **Frontend — Session detail:** Display next steps in the session detail view.
|
||||
- **Backend — Export:** Add a "Next Steps" section to all four export generators (markdown, text, HTML, PSA), rendered after the Resolution section. Only render if non-empty.
|
||||
|
||||
**Markdown output example:**
|
||||
```markdown
|
||||
## Next Steps
|
||||
|
||||
- Monitor Event Log for Event ID 41 recurrence over next 48 hours
|
||||
- Schedule firmware update for next maintenance window (Feb 20)
|
||||
- If issue recurs, escalate to Dell ProSupport case #SR-4482991
|
||||
```
|
||||
|
||||
#### A3. Mid-Session Export from TreeNavigationPage
|
||||
|
||||
**Problem:** Export currently only works from `SessionDetailPage` after navigating away from the tree. If an engineer gets pulled away mid-troubleshooting, they can't grab what they've done so far without leaving the flow.
|
||||
|
||||
**Changes required:**
|
||||
|
||||
- **Frontend — TreeNavigationPage:** Add a "Copy for Ticket" button (same pattern as the existing one on `SessionDetailPage`) that exports steps completed up to the current point.
|
||||
- **Export behavior:** Uses the existing export endpoint but the session is still in-progress. The export should:
|
||||
- Include a `**Status:** In Progress` indicator in the header metadata.
|
||||
- Show "Session still in progress — exported at step N of path" note.
|
||||
- Include all decisions recorded so far.
|
||||
- Include scratchpad content captured so far.
|
||||
- NOT mark the session as `exported = True` (since it's still active).
|
||||
- **Backend:** The export endpoint currently sets `session.exported = True` unconditionally. Add logic: only set `exported = True` if the session has a `completed_at` timestamp. Alternatively, add an `include_in_progress_header: bool = False` option to `SessionExport` that the TreeNavigationPage sets to `True`.
|
||||
|
||||
**UX detail:** The button should be accessible but not prominent — engineers shouldn't accidentally think it ends their session. A secondary/outline-style button with a clipboard icon in the navigation page toolbar is appropriate.
|
||||
|
||||
#### A4. Partial Export with Step Cutoff
|
||||
|
||||
**Problem:** When reviewing a completed (or abandoned) session, there's no way to export only the first N steps — useful for escalation handoff snapshots.
|
||||
|
||||
**Changes required:**
|
||||
|
||||
- **Schema:** Add `max_step_index: Optional[int] = None` to `SessionExport`. This is 1-based and inclusive (e.g., `max_step_index=3` exports steps 1, 2, and 3).
|
||||
- **Backend:** All four export generators slice the `session.decisions` list by `max_step_index` before iterating.
|
||||
- **Validation:** If `max_step_index` is provided, it must be >= 1. Values greater than the actual decision count are clamped to the full list (no error). Zero or negative values return a 422 validation error.
|
||||
- **Frontend — SessionDetailPage:** Add an "Export through step N" dropdown/slider control in the export options area. Default: all steps (no cutoff).
|
||||
|
||||
---
|
||||
|
||||
### Phase B: Export Quality & Readability (Medium Priority)
|
||||
|
||||
These improve the usefulness and polish of exports for their audience (dispatchers, managers, ticket reviewers).
|
||||
|
||||
#### B1. Structured Ticket Summary Block
|
||||
|
||||
**Problem:** Exports dive straight into step-by-step detail, which is great for engineers reviewing their own work but overwhelming for dispatchers and managers who need a quick overview.
|
||||
|
||||
**Changes required:**
|
||||
|
||||
- **Schema:** Add `include_summary: bool = False` to `SessionExport`.
|
||||
- **Backend:** When enabled, generate a "Summary" section at the top of the export (after metadata, before Evidence/Steps) with these fields:
|
||||
- **Issue:** Auto-populated from tree name/description.
|
||||
- **Impact:** Blank by default (user-editable in preview).
|
||||
- **Current Status:** "Resolved" if `completed_at` is set, otherwise "In Progress — paused at step N".
|
||||
- **Resolution:** Auto-populated from `outcome_notes` if available.
|
||||
- **Next Steps:** Auto-populated from `next_steps` field if available.
|
||||
- **Frontend:** When summary is enabled, show an editable preview of these fields before export, allowing the engineer to fill in blanks or adjust auto-populated values.
|
||||
|
||||
**Markdown output example:**
|
||||
```markdown
|
||||
## Summary
|
||||
|
||||
| Field | Detail |
|
||||
|-------|--------|
|
||||
| **Issue** | VPN Connection Failure |
|
||||
| **Impact** | User unable to access internal resources remotely |
|
||||
| **Status** | Resolved |
|
||||
| **Resolution** | DNS misconfiguration on VPN adapter — updated DNS servers |
|
||||
| **Next Steps** | Monitor for recurrence over 48 hours |
|
||||
```
|
||||
|
||||
#### B2. Custom Step Differentiation in Export
|
||||
|
||||
**Problem:** When engineers add custom steps during a session (via the Step Library), those steps are stored in `custom_steps` but the export treats all decisions identically. Ticket reviewers have no visibility into where the engineer deviated from the standard path.
|
||||
|
||||
**Changes required:**
|
||||
|
||||
- **Backend:** In all four export generators, detect custom steps by checking if `node_id` starts with `custom-` (this is the canonical marker — custom steps always use `custom-{uuid}` as their node_id).
|
||||
- **Markdown format:** Prefix custom steps with `[CUSTOM]` in the heading and add an italic note.
|
||||
- **HTML format:** Add a visual badge/tag (styled like the frontend's purple custom step badge).
|
||||
- **Text format:** Prefix with `[CUSTOM]` label.
|
||||
|
||||
**Markdown output example:**
|
||||
```markdown
|
||||
### Step 5: [CUSTOM] Check Additional Event Logs
|
||||
*Custom step added by engineer*
|
||||
|
||||
**Action:** Reviewed Application log for correlated errors
|
||||
**Notes:** Found repeated .NET runtime errors starting 2 hours before reported issue
|
||||
```
|
||||
|
||||
#### B3. Verbosity / Detail Level Controls
|
||||
|
||||
**Problem:** Exports can become extremely long when sessions include command outputs, detailed scratchpad notes, and many steps. Pasting a wall of text into a ConnectWise ticket isn't practical.
|
||||
|
||||
**Changes required:**
|
||||
|
||||
- **Schema:** Add `detail_level: Literal["summary", "standard", "full"] = "standard"` to `SessionExport`.
|
||||
- **Backend behavior by level:**
|
||||
- **summary:** Header metadata + Summary block (auto-enabled) + Resolution + Next Steps. No individual step details. Designed for management/dispatch visibility.
|
||||
- **standard:** Everything in summary, plus all troubleshooting steps with notes. Command outputs longer than 5 lines are truncated with "*(full output omitted)*". This is the default and matches current behavior plus new sections.
|
||||
- **full:** Everything included with no truncation. Command outputs, scratchpad, all notes rendered in full. Designed for detailed review or archival.
|
||||
- **Frontend:** Add a detail level selector (3-option toggle or dropdown) in the export options area on both `SessionDetailPage` and `TreeNavigationPage`.
|
||||
|
||||
#### B4. Editable Preview Before Copy
|
||||
|
||||
**Problem:** The "Copy for Ticket" button generates content and copies it to the clipboard immediately with no chance to review. Engineers often need to clean up notes, add context, or remove sensitive info before pasting into a PSA.
|
||||
|
||||
**Changes required:**
|
||||
|
||||
- **Frontend — ExportPreviewModal enhancement:** Instead of the current read-only preview modal, make the preview content editable. The flow becomes:
|
||||
1. Engineer clicks "Copy for Ticket" or "Preview".
|
||||
2. Modal opens with generated export content in an editable text area.
|
||||
3. Engineer reviews and optionally edits the content.
|
||||
4. Engineer clicks "Copy" (copies the edited version) or "Download" (saves the edited version).
|
||||
- **Important:** Edits in the preview are NOT saved back to the session. This is a one-way "edit before copy" flow.
|
||||
- **The existing "Copy" button** (direct copy without preview) should remain available for engineers who want the quick path. The preview/edit step is opt-in.
|
||||
|
||||
---
|
||||
|
||||
### Phase C: Advanced Features (Lower Priority)
|
||||
|
||||
#### C1. Sensitive Data Review / Redaction
|
||||
|
||||
**Problem:** Scratchpad and command outputs encourage capturing detailed data (IPs, hostnames, tokens, account IDs), but ticket systems often need sanitized notes. Engineers currently have to manually scan and redact before pasting.
|
||||
|
||||
**Changes required:**
|
||||
|
||||
- **Schema:** Add `redaction_mode: Literal["none", "mask"] = "none"` to `SessionExport`.
|
||||
- **Backend — Redaction pipeline:** When `redaction_mode="mask"`, apply regex-based detection and masking of common sensitive patterns before returning export content:
|
||||
- IPv4/IPv6 addresses → `[IP REDACTED]`
|
||||
- Email addresses → `[EMAIL REDACTED]`
|
||||
- Common token/key patterns (API keys, bearer tokens) → `[TOKEN REDACTED]`
|
||||
- UNC paths and hostnames → optionally masked (configurable)
|
||||
- **Frontend — Preview integration:** When the editable preview modal is open, add a toggle for "Show sensitive data highlights." When enabled, likely sensitive values are visually highlighted (yellow background or similar). A "Mask All" button applies redaction. Individual items can be toggled on/off.
|
||||
- **Copy actions:** "Copy" copies current content as-is. "Copy Redacted" applies the masking pipeline to whatever is currently in the editor.
|
||||
|
||||
**Implementation note:** Start with a conservative set of regex patterns. False positives (masking things that aren't sensitive) are annoying but safe. False negatives (missing real sensitive data) are the actual risk. The editable preview (B4) gives engineers a safety net regardless.
|
||||
|
||||
---
|
||||
|
||||
## Data Model Changes
|
||||
|
||||
### New Database Columns
|
||||
|
||||
| Column | Table | Type | Default | Migration Required |
|
||||
|--------|-------|------|---------|--------------------|
|
||||
| `outcome_notes` | sessions | Text, nullable | `''` | No — already exists |
|
||||
| `next_steps` | sessions | Text, nullable | `''` | Yes |
|
||||
|
||||
Both columns follow the same pattern as the existing `scratchpad` column: `Text` type, nullable, `server_default=sa.text("''")`, with backfill of existing rows in the migration.
|
||||
|
||||
### Schema Changes — `SessionExport`
|
||||
|
||||
Current:
|
||||
```python
|
||||
class SessionExport(BaseModel):
|
||||
format: str = Field(default="markdown", pattern="^(text|markdown|html|psa)$")
|
||||
include_timestamps: bool = True
|
||||
include_tree_info: bool = True
|
||||
```
|
||||
|
||||
Updated:
|
||||
```python
|
||||
class SessionExport(BaseModel):
|
||||
format: str = Field(default="markdown", pattern="^(text|markdown|html|psa)$")
|
||||
include_timestamps: bool = True
|
||||
include_tree_info: bool = True
|
||||
|
||||
# Phase A additions
|
||||
include_outcome_notes: bool = True
|
||||
max_step_index: Optional[int] = Field(None, ge=1, description="1-based inclusive step cutoff")
|
||||
|
||||
# Phase B additions
|
||||
include_summary: bool = False
|
||||
detail_level: Literal["summary", "standard", "full"] = "standard"
|
||||
|
||||
# Phase C additions
|
||||
redaction_mode: Literal["none", "mask"] = "none"
|
||||
```
|
||||
|
||||
### Schema Changes — `SessionUpdate` and `SessionResponse`
|
||||
|
||||
Add to `SessionUpdate`:
|
||||
```python
|
||||
outcome_notes: Optional[str] = None
|
||||
next_steps: Optional[str] = None
|
||||
```
|
||||
|
||||
Add to `SessionResponse`:
|
||||
```python
|
||||
outcome_notes: str = ""
|
||||
next_steps: str = ""
|
||||
|
||||
@validator('outcome_notes', 'next_steps', pre=True, always=True)
|
||||
def normalize_text_fields(cls, v):
|
||||
return v or ""
|
||||
```
|
||||
|
||||
### Session Completion Endpoint
|
||||
|
||||
Update `POST /sessions/{id}/complete` to accept:
|
||||
```python
|
||||
class SessionComplete(BaseModel):
|
||||
outcome_notes: Optional[str] = None
|
||||
next_steps: Optional[str] = None
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Export Output Structure (All Formats)
|
||||
|
||||
After all phases, the full export structure in order:
|
||||
|
||||
```
|
||||
1. Header Metadata (tree name, ticket #, client, timestamps, status)
|
||||
2. Summary Block (Phase B1, optional — enabled by include_summary or detail_level=summary)
|
||||
3. Evidence / Reference (existing scratchpad section)
|
||||
4. Troubleshooting Steps (existing, enhanced with custom step markers and step cutoff)
|
||||
5. Resolution / Outcome Notes (Phase A1)
|
||||
6. Next Steps / Follow-Up (Phase A2)
|
||||
7. Session Duration (existing timestamp-derived, shown when include_timestamps=true)
|
||||
```
|
||||
|
||||
### Markdown Example — Complete Session, Standard Detail
|
||||
|
||||
```markdown
|
||||
# VPN Connection Failure
|
||||
|
||||
**Ticket:** SR-2847
|
||||
**Client:** Contoso Ltd
|
||||
**Started:** 2026-02-13 09:15
|
||||
**Completed:** 2026-02-13 09:42
|
||||
**Status:** Resolved
|
||||
|
||||
---
|
||||
|
||||
## Evidence / Reference
|
||||
|
||||
- Server IP: 10.0.1.50
|
||||
- VPN Client: GlobalProtect 6.2.1
|
||||
- Affected user: jsmith@contoso.com
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting Steps
|
||||
|
||||
### Step 1: Is the VPN client installed and up to date?
|
||||
**Answer:** Yes
|
||||
**Notes:** GlobalProtect 6.2.1 confirmed
|
||||
|
||||
### Step 2: Can the user reach the VPN gateway?
|
||||
**Answer:** Yes
|
||||
**Notes:** Ping to vpn.contoso.com successful
|
||||
|
||||
### Step 3: Check DNS configuration on VPN adapter
|
||||
**Answer:** DNS servers incorrect
|
||||
**Notes:** VPN adapter had 8.8.8.8 instead of internal DC
|
||||
|
||||
### Step 4: [CUSTOM] Verify DNS propagation after fix
|
||||
*Custom step added by engineer*
|
||||
**Action:** Ran nslookup against internal resources
|
||||
**Notes:** All internal names resolving correctly after DNS update
|
||||
|
||||
---
|
||||
|
||||
## Resolution
|
||||
|
||||
Updated DNS configuration on VPN adapter to point to internal DCs (10.0.1.10, 10.0.1.11).
|
||||
Flushed DNS cache. Verified internal name resolution working. User confirmed full access restored.
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Monitor for recurrence over next 48 hours
|
||||
- Check if GPO is failing to push correct DNS settings to this machine
|
||||
- Schedule follow-up with user on Friday if no recurrence
|
||||
```
|
||||
|
||||
### Markdown Example — In-Progress Session (Mid-Session Export)
|
||||
|
||||
```markdown
|
||||
# VPN Connection Failure
|
||||
|
||||
**Ticket:** SR-2847
|
||||
**Client:** Contoso Ltd
|
||||
**Started:** 2026-02-13 09:15
|
||||
**Status:** In Progress (exported at step 3)
|
||||
|
||||
---
|
||||
|
||||
## Evidence / Reference
|
||||
|
||||
- Server IP: 10.0.1.50
|
||||
- VPN Client: GlobalProtect 6.2.1
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting Steps
|
||||
|
||||
### Step 1: Is the VPN client installed and up to date?
|
||||
**Answer:** Yes
|
||||
|
||||
### Step 2: Can the user reach the VPN gateway?
|
||||
**Answer:** Yes
|
||||
|
||||
### Step 3: Check DNS configuration on VPN adapter
|
||||
**Answer:** DNS servers incorrect
|
||||
**Notes:** VPN adapter had 8.8.8.8 instead of internal DC — investigating
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Frontend Changes Summary
|
||||
|
||||
| Page | Change | Phase |
|
||||
|------|--------|-------|
|
||||
| `TreeNavigationPage` | Add "Copy for Ticket" button with in-progress export | A |
|
||||
| `SessionDetailPage` | Add step cutoff control to export options | A |
|
||||
| `SessionDetailPage` | Add detail level selector | B |
|
||||
| `ExportPreviewModal` | Make preview content editable before copy | B |
|
||||
| `ExportPreviewModal` | Add sensitive data highlighting and mask toggle | C |
|
||||
| Session completion modal | Add "Next Steps" text area | A |
|
||||
| Session completion modal | Ensure outcome_notes are saved (verify current behavior) | A |
|
||||
|
||||
---
|
||||
|
||||
## Test Cases
|
||||
|
||||
### Phase A Tests
|
||||
|
||||
1. **Outcome notes in export** — Completed session with outcome_notes includes "Resolution" section in markdown, text, and HTML formats. Session without outcome_notes omits the section cleanly.
|
||||
2. **Next steps in export** — Session with next_steps includes "Next Steps" section in all formats. Empty next_steps omits the section.
|
||||
3. **Mid-session export** — Exporting an in-progress session includes "In Progress" status, completed steps only, and does NOT set `exported = True`.
|
||||
4. **Partial export** — `max_step_index=3` returns only first 3 decisions. `max_step_index` greater than decision count returns all decisions (no error). `max_step_index=0` or negative returns 422.
|
||||
5. **Backward compatibility** — Existing export calls with no new parameters produce identical output to current behavior. All existing export tests continue to pass.
|
||||
|
||||
### Phase B Tests
|
||||
|
||||
6. **Summary block** — `include_summary=True` adds Summary table at top with auto-populated fields.
|
||||
7. **Custom step marking** — Decisions from custom steps are prefixed with `[CUSTOM]` in markdown/text and styled with a badge in HTML.
|
||||
8. **Detail levels** — `summary` excludes step details. `standard` truncates long outputs. `full` includes everything.
|
||||
9. **Editable preview** — Preview modal allows text editing. Copied content reflects edits. Original session data is unchanged.
|
||||
|
||||
### Phase C Tests
|
||||
|
||||
10. **Redaction** — `redaction_mode=mask` replaces IP addresses, emails, and token patterns with `[REDACTED]` placeholders. `redaction_mode=none` returns original content unchanged.
|
||||
|
||||
---
|
||||
|
||||
## Assumptions and Defaults
|
||||
|
||||
- All new `SessionExport` fields have backward-compatible defaults — existing integrations are unaffected.
|
||||
- Mid-session export from `TreeNavigationPage` uses the existing export API endpoint but requires a backend change: only set `exported=True` when session has `completed_at`.
|
||||
- The `next_steps` field is a new dedicated column on the session model (not piggyback on scratchpad or outcome_notes).
|
||||
- Initial scope covers the session detail export flow and the active navigation page. Bulk export or scheduled reports are out of scope.
|
||||
- Redaction (Phase C) starts with conservative regex patterns; false positives are preferred over false negatives.
|
||||
- Export logic lives in `backend/app/services/export_service.py` with four generators (markdown, text, HTML, PSA). The export endpoint in `backend/app/api/endpoints/sessions.py` calls these generators.
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. ~~**Outcome notes storage:**~~ **RESOLVED** — `outcome_notes` already exists on the Session model and `SessionComplete` schema. No migration needed.
|
||||
2. ~~**PSA export format:**~~ **RESOLVED** — `generate_psa_export` exists in `export_service.py` and is fully functional. All four formats (markdown, text, HTML, PSA) must receive the same improvements.
|
||||
3. **Step cutoff UX:** Should the step cutoff be a simple number input, a dropdown of step numbers with labels, or a clickable timeline in the session detail view?
|
||||
4. **Redaction scope:** Should hostname redaction be on by default in mask mode, or opt-in? MSP ticket notes often legitimately need hostnames for context.
|
||||
|
||||
---
|
||||
|
||||
## Locked Decisions
|
||||
|
||||
1. **`next_steps` is frozen after completion.** The `update_session` endpoint blocks updates to completed sessions (`sessions.py:190`). `next_steps` follows the same lifecycle — engineers set it during or at completion, not after. If post-completion editing is needed later, a dedicated PATCH endpoint for completion fields can be added as a separate feature.
|
||||
2. **Custom step detection:** The canonical marker is `node_id` starting with `custom-`. No ambiguity — this is what the frontend generates for all custom steps.
|
||||
3. **PSA format included everywhere.** All four generators (markdown, text, HTML, PSA) receive every improvement. The PSA `--- RESOLUTION ---` section uses `outcome_notes` when available, falling back to last decision answer for backward compatibility.
|
||||
4. **Redaction (Phase C) is server-side only.** The editable preview (Phase B4) is a client-side text area — redaction applies to the generated content before it reaches the preview. Edits in the preview are not re-processed.
|
||||
5. **Export audit trail is out of scope.** The boolean `exported` field is sufficient for Phase A. Richer audit (who/when/options) can be a Phase D feature if needed.
|
||||
6. **Selective step inclusion (checkbox/range) is out of scope.** `max_step_index` covers the escalation handoff use case. Non-linear step selection adds significant UX complexity for marginal value.
|
||||
7. **Export presets by PSA destination are out of scope.** The PSA format already targets ConnectWise-style tools. Destination-specific presets can be added when PSA integrations ship (Phase 4 roadmap).
|
||||
369
docs/plans/archive/2026-02-13-export-phase-a-frontend.md
Normal file
369
docs/plans/archive/2026-02-13-export-phase-a-frontend.md
Normal file
@@ -0,0 +1,369 @@
|
||||
# Export Improvements Phase A — Frontend Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Add `next_steps` to types/UI, add "Copy for Ticket" to TreeNavigationPage, add step cutoff control to SessionDetailPage, display next_steps in session detail + completion modal.
|
||||
|
||||
**Architecture:** Update TypeScript types to match backend schema changes. Add `next_steps` textarea to SessionOutcomeModal. Display next_steps on SessionDetailPage. Add mid-session "Copy for Ticket" button to TreeNavigationPage. Add step cutoff dropdown to SessionDetailPage export controls.
|
||||
|
||||
**Tech Stack:** React 19, TypeScript, Tailwind CSS, Zustand, Vite
|
||||
|
||||
**Backend dependency:** Phase A backend is complete on `feat/export-phase-a` branch.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Update TypeScript Types
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/types/session.ts`
|
||||
|
||||
**Step 1: Add `next_steps` to Session interface** (line 59, after `scratchpad`)
|
||||
|
||||
```typescript
|
||||
next_steps: string
|
||||
```
|
||||
|
||||
**Step 2: Add `next_steps` to SessionUpdate** (line 68, after `scratchpad`)
|
||||
|
||||
```typescript
|
||||
next_steps?: string
|
||||
```
|
||||
|
||||
**Step 3: Add `next_steps` to SessionComplete** (line 83)
|
||||
|
||||
```typescript
|
||||
export interface SessionComplete {
|
||||
outcome: SessionOutcome
|
||||
outcome_notes?: string
|
||||
next_steps?: string
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Update SessionExport** (line 77)
|
||||
|
||||
```typescript
|
||||
export interface SessionExport {
|
||||
format: 'text' | 'markdown' | 'html' | 'psa'
|
||||
include_timestamps?: boolean
|
||||
include_tree_info?: boolean
|
||||
include_outcome_notes?: boolean
|
||||
include_next_steps?: boolean
|
||||
max_step_index?: number
|
||||
}
|
||||
```
|
||||
|
||||
**Step 5: Build to verify no type errors**
|
||||
|
||||
Run: `cd /c/Dev/Projects/patherly/frontend && npx tsc --noEmit`
|
||||
|
||||
**Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/types/session.ts
|
||||
git commit -m "feat(frontend): add next_steps and export options to session types"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Add Next Steps to Session Completion Modal
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/session/SessionOutcomeModal.tsx`
|
||||
- Modify: `frontend/src/pages/TreeNavigationPage.tsx` (consumer callback)
|
||||
|
||||
**Step 1: Update the `onSubmit` prop type** (line 9)
|
||||
|
||||
Change the data shape to include `next_steps`:
|
||||
|
||||
```typescript
|
||||
onSubmit: (data: { outcome: SessionOutcome; outcome_notes?: string; next_steps?: string }) => Promise<void>
|
||||
```
|
||||
|
||||
**Step 2: Update `handleSubmit`** (line 28)
|
||||
|
||||
Add next_steps extraction from formData:
|
||||
|
||||
```typescript
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.current) return
|
||||
const formData = new FormData(formRef.current)
|
||||
const outcome = (formData.get('session-outcome') as SessionOutcome | null) ?? 'resolved'
|
||||
const outcomeNotes = ((formData.get('outcome-notes') as string | null) ?? '').trim()
|
||||
const nextSteps = ((formData.get('next-steps') as string | null) ?? '').trim()
|
||||
|
||||
await onSubmit({
|
||||
outcome,
|
||||
outcome_notes: outcomeNotes || undefined,
|
||||
next_steps: nextSteps || undefined,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Add Next Steps textarea** after the outcome notes textarea (after line 115)
|
||||
|
||||
Add a second textarea block right before the closing `</form>`:
|
||||
|
||||
```tsx
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white">Next Steps / Follow-Up (optional)</label>
|
||||
<textarea
|
||||
name="next-steps"
|
||||
defaultValue=""
|
||||
rows={3}
|
||||
placeholder="Actions to take after this session..."
|
||||
className={cn(
|
||||
'mt-1 block w-full rounded-md border border-white/10 bg-black/50 px-3 py-2',
|
||||
'text-sm text-white placeholder:text-white/40',
|
||||
'focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Step 4: Update the consumer callback in TreeNavigationPage.tsx** (line 341)
|
||||
|
||||
Change `handleSubmitOutcome` signature to match:
|
||||
|
||||
```typescript
|
||||
const handleSubmitOutcome = async (data: { outcome: SessionOutcome; outcome_notes?: string; next_steps?: string }) => {
|
||||
```
|
||||
|
||||
The body already passes `data` directly to `sessionsApi.complete(session.id, data)` so the `next_steps` field will propagate automatically.
|
||||
|
||||
**Step 5: Build to verify**
|
||||
|
||||
Run: `cd /c/Dev/Projects/patherly/frontend && npx tsc --noEmit`
|
||||
|
||||
**Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/components/session/SessionOutcomeModal.tsx frontend/src/pages/TreeNavigationPage.tsx
|
||||
git commit -m "feat(frontend): add next_steps field to session completion modal"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Display Next Steps on SessionDetailPage
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/pages/SessionDetailPage.tsx`
|
||||
|
||||
**Step 1: Display next_steps after outcome_notes** (after line 334)
|
||||
|
||||
Find the existing outcome_notes display:
|
||||
```tsx
|
||||
{session.outcome_notes && (
|
||||
<p className="mt-2 text-sm text-white/60">Outcome Notes: {session.outcome_notes}</p>
|
||||
)}
|
||||
```
|
||||
|
||||
Add next_steps display right after it. Use `whitespace-pre-wrap` to preserve line breaks (engineers often enter bullet lists):
|
||||
|
||||
```tsx
|
||||
{session.next_steps && (
|
||||
<div className="mt-2">
|
||||
<span className="text-sm text-white/40">Next Steps:</span>
|
||||
<p className="mt-0.5 text-sm text-white/60 whitespace-pre-wrap">{session.next_steps}</p>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
**Step 2: Build to verify**
|
||||
|
||||
Run: `cd /c/Dev/Projects/patherly/frontend && npx tsc --noEmit`
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/pages/SessionDetailPage.tsx
|
||||
git commit -m "feat(frontend): display next_steps on session detail page"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Add "Copy for Ticket" Button to TreeNavigationPage
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/pages/TreeNavigationPage.tsx`
|
||||
|
||||
This adds a mid-session export button. It should be secondary/outline style — accessible but not prominent (engineers shouldn't think it ends their session).
|
||||
|
||||
**Step 1: Import necessary icons and API**
|
||||
|
||||
Check what's already imported. You'll need `Copy` and `Check` from lucide-react, and `sessionsApi` from `@/api`. Also need `toast` if not already imported.
|
||||
|
||||
**Step 2: Add state for copy feedback and loading** (near other state declarations)
|
||||
|
||||
```typescript
|
||||
const [copiedForTicket, setCopiedForTicket] = useState(false)
|
||||
const [isCopyingForTicket, setIsCopyingForTicket] = useState(false)
|
||||
```
|
||||
|
||||
**Step 3: Add the copy handler** (with loading guard to prevent double-clicks)
|
||||
|
||||
```typescript
|
||||
const handleCopyForTicket = async () => {
|
||||
if (!session || isCopyingForTicket) return
|
||||
setIsCopyingForTicket(true)
|
||||
try {
|
||||
const content = await sessionsApi.export(session.id, {
|
||||
format: 'psa',
|
||||
include_timestamps: true,
|
||||
include_tree_info: true,
|
||||
})
|
||||
if (content) {
|
||||
await navigator.clipboard.writeText(content)
|
||||
setCopiedForTicket(true)
|
||||
setTimeout(() => setCopiedForTicket(false), 2000)
|
||||
toast.success('Copied progress notes to clipboard')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Copy for ticket failed:', err)
|
||||
toast.error('Failed to copy notes')
|
||||
} finally {
|
||||
setIsCopyingForTicket(false)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Add the button to the page toolbar**
|
||||
|
||||
Find the toolbar/header area of TreeNavigationPage. Look for where other action buttons are (like scratchpad toggle, session timer, etc.). Add a secondary-style button with disabled state:
|
||||
|
||||
```tsx
|
||||
<button
|
||||
onClick={handleCopyForTicket}
|
||||
disabled={isCopyingForTicket}
|
||||
title="Copy progress notes for ticket"
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded-md border border-white/10 px-3 py-1.5 text-xs font-medium text-white/60',
|
||||
'hover:bg-white/10 hover:text-white transition-colors disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
{copiedForTicket ? <Check className="h-3.5 w-3.5 text-emerald-400" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
{copiedForTicket ? 'Copied!' : 'Copy for Ticket'}
|
||||
</button>
|
||||
```
|
||||
|
||||
Place it near other toolbar actions but not in a position where it could be confused with session completion buttons.
|
||||
|
||||
**Step 5: Build to verify**
|
||||
|
||||
Run: `cd /c/Dev/Projects/patherly/frontend && npx tsc --noEmit`
|
||||
|
||||
**Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/pages/TreeNavigationPage.tsx
|
||||
git commit -m "feat(frontend): add mid-session Copy for Ticket button to navigation page"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Add Step Cutoff Control to SessionDetailPage Export
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/pages/SessionDetailPage.tsx`
|
||||
|
||||
**Step 1: Add state for step cutoff** (near other export state)
|
||||
|
||||
```typescript
|
||||
const [maxStepIndex, setMaxStepIndex] = useState<number | null>(null)
|
||||
```
|
||||
|
||||
**Step 2: Update `fetchExportContent` to include new options** (line 91)
|
||||
|
||||
```typescript
|
||||
const fetchExportContent = async () => {
|
||||
if (!session) return null
|
||||
const options: SessionExport = {
|
||||
format: exportFormat,
|
||||
include_timestamps: true,
|
||||
include_tree_info: true,
|
||||
...(maxStepIndex !== null && { max_step_index: maxStepIndex }),
|
||||
}
|
||||
return await sessionsApi.export(session.id, options)
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Update `handleCopyForTicket` similarly** (line 135)
|
||||
|
||||
Add `max_step_index` to PSA export options too:
|
||||
|
||||
```typescript
|
||||
const options: SessionExport = {
|
||||
format: 'psa',
|
||||
include_timestamps: true,
|
||||
include_tree_info: true,
|
||||
...(maxStepIndex !== null && { max_step_index: maxStepIndex }),
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Add step cutoff dropdown** in the export controls area
|
||||
|
||||
Find the export format `<select>` element (line 368). Add a step cutoff dropdown right after it, but only show it when the session has decisions:
|
||||
|
||||
```tsx
|
||||
{session.decisions.length > 1 && (
|
||||
<select
|
||||
value={maxStepIndex ?? ''}
|
||||
onChange={(e) => setMaxStepIndex(e.target.value ? Number(e.target.value) : null)}
|
||||
aria-label="Export through step"
|
||||
className={cn(
|
||||
'rounded-md border border-white/10 bg-black/50 px-3 py-2 text-sm text-white',
|
||||
'focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20'
|
||||
)}
|
||||
>
|
||||
<option value="">All steps</option>
|
||||
{session.decisions.map((_, idx) => (
|
||||
<option key={idx + 1} value={idx + 1}>
|
||||
Through step {idx + 1}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
```
|
||||
|
||||
**Step 5: Build to verify**
|
||||
|
||||
Run: `cd /c/Dev/Projects/patherly/frontend && npx tsc --noEmit`
|
||||
|
||||
**Step 6: Full build**
|
||||
|
||||
Run: `cd /c/Dev/Projects/patherly/frontend && npm run build`
|
||||
|
||||
**Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/pages/SessionDetailPage.tsx
|
||||
git commit -m "feat(frontend): add step cutoff control to export options"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Final Verification
|
||||
|
||||
**Step 1: Full build**
|
||||
|
||||
Run: `cd /c/Dev/Projects/patherly/frontend && npm run build`
|
||||
|
||||
Expected: Build succeeds with no errors.
|
||||
|
||||
**Step 2: Verify git status is clean**
|
||||
|
||||
```bash
|
||||
git status
|
||||
git log --oneline feat/export-phase-a --not main
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Frontend Acceptance Checklist (Manual QA)
|
||||
|
||||
After all tasks are complete, verify these scenarios with the running app:
|
||||
|
||||
1. **Completion with next_steps:** Complete a session with both outcome_notes and next_steps filled in. Verify both appear on SessionDetailPage with line breaks preserved.
|
||||
2. **Completion without next_steps:** Complete a session with only outcome. Verify no empty "Next Steps" section appears on detail page or in exports.
|
||||
3. **Mid-session copy:** While navigating a tree, click "Copy for Ticket". Paste the result — it should show "In progress" duration and steps completed so far. Verify the session is NOT marked as exported.
|
||||
4. **Step cutoff:** On a completed session with 5+ steps, use the "Through step 3" dropdown, then Preview. Verify only steps 1-3 appear. Verify "Copy for Ticket" also respects the cutoff.
|
||||
5. **Double-click protection:** Click "Copy for Ticket" rapidly on TreeNavigationPage. Verify only one API call fires (button should disable during loading).
|
||||
753
docs/plans/archive/2026-02-13-export-phase-a.md
Normal file
753
docs/plans/archive/2026-02-13-export-phase-a.md
Normal file
@@ -0,0 +1,753 @@
|
||||
# Export Improvements Phase A — Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Add outcome_notes, next_steps, mid-session export awareness, and partial step cutoff to all four export formats.
|
||||
|
||||
**Architecture:** Add `next_steps` column via migration. Extend `SessionExport` schema with new options. Update all four generators in `export_service.py` to render Resolution + Next Steps sections and support step slicing. Guard `exported=True` behind completion check.
|
||||
|
||||
**Tech Stack:** Python, FastAPI, SQLAlchemy, Alembic, pytest
|
||||
|
||||
**Spec:** [2026-02-13-EXPORT-IMPROVEMENTS-SPEC.md](2026-02-13-EXPORT-IMPROVEMENTS-SPEC.md) — Phase A only
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Add `next_steps` column — Migration + Model
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/models/session.py:56` (after scratchpad)
|
||||
- Create: `backend/alembic/versions/034_add_next_steps_to_sessions.py`
|
||||
|
||||
**Step 1: Add column to Session model**
|
||||
|
||||
In `backend/app/models/session.py`, add after the `scratchpad` column (line 58):
|
||||
|
||||
```python
|
||||
next_steps: Mapped[Optional[str]] = mapped_column(
|
||||
Text, nullable=True, server_default=sa.text("''")
|
||||
)
|
||||
```
|
||||
|
||||
**Step 2: Generate migration**
|
||||
|
||||
Run: `cd /c/Dev/Projects/patherly/backend && python -m alembic revision --autogenerate -m "add next_steps to sessions"`
|
||||
|
||||
Review the generated migration — it should add a single `next_steps` TEXT column with server_default `''`.
|
||||
|
||||
Rename the file to `034_add_next_steps_to_sessions.py` and update the revision ID comment if needed for clarity.
|
||||
|
||||
**Step 3: Run migration**
|
||||
|
||||
Run: `cd /c/Dev/Projects/patherly/backend && python -m alembic upgrade head`
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/models/session.py backend/alembic/versions/*next_steps*
|
||||
git commit -m "feat: add next_steps column to sessions table"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Update Schemas — SessionExport, SessionUpdate, SessionResponse, SessionComplete
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/schemas/session.py`
|
||||
|
||||
**Step 1: Update SessionExport** (line 82)
|
||||
|
||||
Replace the current `SessionExport` class with:
|
||||
|
||||
```python
|
||||
class SessionExport(BaseModel):
|
||||
format: str = Field(default="markdown", pattern="^(text|markdown|html|psa)$")
|
||||
include_timestamps: bool = True
|
||||
include_tree_info: bool = True
|
||||
# Phase A
|
||||
include_outcome_notes: bool = True
|
||||
include_next_steps: bool = True
|
||||
max_step_index: Optional[int] = Field(None, ge=1, description="1-based inclusive step cutoff")
|
||||
```
|
||||
|
||||
**Step 2: Add next_steps to SessionUpdate** (line 46)
|
||||
|
||||
Add to `SessionUpdate`:
|
||||
|
||||
```python
|
||||
next_steps: Optional[str] = None
|
||||
```
|
||||
|
||||
**Step 3: Add next_steps to SessionResponse** (line 56)
|
||||
|
||||
Add after `outcome_notes`:
|
||||
|
||||
```python
|
||||
next_steps: str = ""
|
||||
```
|
||||
|
||||
Update the validator to normalize both fields:
|
||||
|
||||
```python
|
||||
@validator('scratchpad', 'next_steps', pre=True, always=True)
|
||||
def normalize_text_fields(cls, v):
|
||||
return v or ""
|
||||
```
|
||||
|
||||
Remove the old `normalize_scratchpad` validator.
|
||||
|
||||
**Step 4: Add next_steps to SessionComplete** (line 88)
|
||||
|
||||
```python
|
||||
class SessionComplete(BaseModel):
|
||||
outcome: SessionOutcome
|
||||
outcome_notes: Optional[str] = None
|
||||
next_steps: Optional[str] = None
|
||||
```
|
||||
|
||||
**Step 5: Run tests to verify no regressions**
|
||||
|
||||
Run: `cd /c/Dev/Projects/patherly/backend && pytest --override-ini="addopts=" -x -q`
|
||||
|
||||
Expected: All existing tests pass (new fields have defaults).
|
||||
|
||||
**Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/schemas/session.py
|
||||
git commit -m "feat: add next_steps and export options to session schemas"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Update Completion Endpoint to Save next_steps
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/api/endpoints/sessions.py:236-238`
|
||||
|
||||
**Step 1: Write failing test**
|
||||
|
||||
In `backend/tests/test_sessions.py`, add after the existing completion tests:
|
||||
|
||||
```python
|
||||
@pytest.mark.asyncio
|
||||
async def test_complete_session_with_next_steps(
|
||||
self, client: AsyncClient, auth_headers: dict, test_tree: dict
|
||||
):
|
||||
"""Test completing session saves next_steps."""
|
||||
create_response = await client.post(
|
||||
"/api/v1/sessions",
|
||||
json={"tree_id": test_tree["id"]},
|
||||
headers=auth_headers
|
||||
)
|
||||
session_id = create_response.json()["id"]
|
||||
|
||||
response = await client.post(
|
||||
f"/api/v1/sessions/{session_id}/complete",
|
||||
json={
|
||||
"outcome": "resolved",
|
||||
"outcome_notes": "Fixed the issue",
|
||||
"next_steps": "Monitor for 48 hours"
|
||||
},
|
||||
headers=auth_headers
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["next_steps"] == "Monitor for 48 hours"
|
||||
assert data["outcome_notes"] == "Fixed the issue"
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `cd /c/Dev/Projects/patherly/backend && pytest tests/test_sessions.py::TestSessions::test_complete_session_with_next_steps -v`
|
||||
|
||||
Expected: FAIL — `next_steps` not saved (returns `""` because endpoint doesn't set it).
|
||||
|
||||
**Step 3: Update completion endpoint**
|
||||
|
||||
In `backend/app/api/endpoints/sessions.py`, line 238, add after `session.outcome_notes = completion_data.outcome_notes`:
|
||||
|
||||
```python
|
||||
session.next_steps = completion_data.next_steps
|
||||
```
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `cd /c/Dev/Projects/patherly/backend && pytest tests/test_sessions.py::TestSessions::test_complete_session_with_next_steps -v`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/api/endpoints/sessions.py backend/tests/test_sessions.py
|
||||
git commit -m "feat: save next_steps on session completion"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Update SessionUpdate Endpoint to Save next_steps
|
||||
|
||||
**Note:** `update_session` blocks updates to completed sessions (returns 400). This is intentional — `next_steps` is set during active sessions or at completion time, not after. No changes needed to that guard.
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/api/endpoints/sessions.py` (the `update_session` handler)
|
||||
|
||||
**Step 1: Write failing test**
|
||||
|
||||
```python
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_session_next_steps(
|
||||
self, client: AsyncClient, auth_headers: dict, test_tree: dict
|
||||
):
|
||||
"""Test updating next_steps via session update."""
|
||||
create_response = await client.post(
|
||||
"/api/v1/sessions",
|
||||
json={"tree_id": test_tree["id"]},
|
||||
headers=auth_headers
|
||||
)
|
||||
session_id = create_response.json()["id"]
|
||||
|
||||
response = await client.put(
|
||||
f"/api/v1/sessions/{session_id}",
|
||||
json={"next_steps": "Schedule follow-up call"},
|
||||
headers=auth_headers
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["next_steps"] == "Schedule follow-up call"
|
||||
```
|
||||
|
||||
**Step 2: Run test — verify it fails**
|
||||
|
||||
Run: `cd /c/Dev/Projects/patherly/backend && pytest tests/test_sessions.py::TestSessions::test_update_session_next_steps -v`
|
||||
|
||||
**Step 3: Update the update_session endpoint**
|
||||
|
||||
Find the `update_session` handler in `sessions.py`. Look for where it applies `SessionUpdate` fields to the session object. Add `next_steps` to that block, following the same pattern as `scratchpad`:
|
||||
|
||||
```python
|
||||
if update_data.next_steps is not None:
|
||||
session.next_steps = update_data.next_steps
|
||||
```
|
||||
|
||||
**Step 4: Run test — verify it passes**
|
||||
|
||||
Run: `cd /c/Dev/Projects/patherly/backend && pytest tests/test_sessions.py::TestSessions::test_update_session_next_steps -v`
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/api/endpoints/sessions.py backend/tests/test_sessions.py
|
||||
git commit -m "feat: allow next_steps update via session update endpoint"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Add outcome_notes + next_steps to Export Generators
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/services/export_service.py`
|
||||
- Test: `backend/tests/test_sessions.py`
|
||||
|
||||
This is the core export change. All four generators need Resolution and Next Steps sections after the steps.
|
||||
|
||||
**Step 1: Write failing tests**
|
||||
|
||||
Add to `test_sessions.py`:
|
||||
|
||||
```python
|
||||
@pytest.mark.asyncio
|
||||
async def test_export_includes_outcome_notes_in_resolution(
|
||||
self, client: AsyncClient, auth_headers: dict, test_tree: dict
|
||||
):
|
||||
"""Test that outcome_notes appear as Resolution section in exports."""
|
||||
create_response = await client.post(
|
||||
"/api/v1/sessions",
|
||||
json={"tree_id": test_tree["id"]},
|
||||
headers=auth_headers
|
||||
)
|
||||
session_id = create_response.json()["id"]
|
||||
|
||||
await client.post(
|
||||
f"/api/v1/sessions/{session_id}/complete",
|
||||
json={
|
||||
"outcome": "resolved",
|
||||
"outcome_notes": "Replaced failed DIMM in slot A2",
|
||||
"next_steps": "Monitor for 24 hours"
|
||||
},
|
||||
headers=auth_headers
|
||||
)
|
||||
|
||||
# Test markdown
|
||||
response = await client.post(
|
||||
f"/api/v1/sessions/{session_id}/export",
|
||||
json={"format": "markdown"},
|
||||
headers=auth_headers
|
||||
)
|
||||
assert response.status_code == 200
|
||||
content = response.text
|
||||
assert "## Resolution" in content
|
||||
assert "Replaced failed DIMM in slot A2" in content
|
||||
assert "## Next Steps" in content
|
||||
assert "Monitor for 24 hours" in content
|
||||
|
||||
# Test text
|
||||
response = await client.post(
|
||||
f"/api/v1/sessions/{session_id}/export",
|
||||
json={"format": "text"},
|
||||
headers=auth_headers
|
||||
)
|
||||
content = response.text
|
||||
assert "RESOLUTION" in content
|
||||
assert "Replaced failed DIMM in slot A2" in content
|
||||
assert "NEXT STEPS" in content
|
||||
assert "Monitor for 24 hours" in content
|
||||
|
||||
# Test HTML
|
||||
response = await client.post(
|
||||
f"/api/v1/sessions/{session_id}/export",
|
||||
json={"format": "html"},
|
||||
headers=auth_headers
|
||||
)
|
||||
content = response.text
|
||||
assert "Resolution" in content
|
||||
assert "Replaced failed DIMM in slot A2" in content
|
||||
assert "Next Steps" in content
|
||||
assert "Monitor for 24 hours" in content
|
||||
|
||||
# Test PSA
|
||||
response = await client.post(
|
||||
f"/api/v1/sessions/{session_id}/export",
|
||||
json={"format": "psa"},
|
||||
headers=auth_headers
|
||||
)
|
||||
content = response.text
|
||||
assert "Replaced failed DIMM in slot A2" in content
|
||||
assert "Monitor for 24 hours" in content
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_export_omits_empty_resolution_and_next_steps(
|
||||
self, client: AsyncClient, auth_headers: dict, test_tree: dict
|
||||
):
|
||||
"""Test that empty outcome_notes/next_steps don't create empty sections."""
|
||||
create_response = await client.post(
|
||||
"/api/v1/sessions",
|
||||
json={"tree_id": test_tree["id"]},
|
||||
headers=auth_headers
|
||||
)
|
||||
session_id = create_response.json()["id"]
|
||||
|
||||
await client.post(
|
||||
f"/api/v1/sessions/{session_id}/complete",
|
||||
json={"outcome": "resolved"},
|
||||
headers=auth_headers
|
||||
)
|
||||
|
||||
response = await client.post(
|
||||
f"/api/v1/sessions/{session_id}/export",
|
||||
json={"format": "markdown"},
|
||||
headers=auth_headers
|
||||
)
|
||||
content = response.text
|
||||
assert "## Resolution" not in content
|
||||
assert "## Next Steps" not in content
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_export_exclude_outcome_notes_flag(
|
||||
self, client: AsyncClient, auth_headers: dict, test_tree: dict
|
||||
):
|
||||
"""Test include_outcome_notes=False suppresses resolution section."""
|
||||
create_response = await client.post(
|
||||
"/api/v1/sessions",
|
||||
json={"tree_id": test_tree["id"]},
|
||||
headers=auth_headers
|
||||
)
|
||||
session_id = create_response.json()["id"]
|
||||
|
||||
await client.post(
|
||||
f"/api/v1/sessions/{session_id}/complete",
|
||||
json={
|
||||
"outcome": "resolved",
|
||||
"outcome_notes": "Should not appear"
|
||||
},
|
||||
headers=auth_headers
|
||||
)
|
||||
|
||||
response = await client.post(
|
||||
f"/api/v1/sessions/{session_id}/export",
|
||||
json={"format": "markdown", "include_outcome_notes": False},
|
||||
headers=auth_headers
|
||||
)
|
||||
content = response.text
|
||||
assert "## Resolution" not in content
|
||||
assert "Should not appear" not in content
|
||||
```
|
||||
|
||||
**Step 2: Run tests — verify they fail**
|
||||
|
||||
Run: `cd /c/Dev/Projects/patherly/backend && pytest tests/test_sessions.py -k "outcome_notes_in_resolution or omits_empty_resolution or exclude_outcome_notes_flag" -v`
|
||||
|
||||
**Step 3: Update all four generators in export_service.py**
|
||||
|
||||
Add Resolution and Next Steps sections after the Troubleshooting Steps in each generator. The generators receive `options: SessionExport` — use `options.include_outcome_notes` and `options.include_next_steps`.
|
||||
|
||||
**Markdown generator** — add before `return "\n".join(lines)` (line 178):
|
||||
|
||||
```python
|
||||
# Resolution / Outcome Notes
|
||||
outcome_notes = getattr(session, 'outcome_notes', '') or ''
|
||||
if outcome_notes.strip() and options.include_outcome_notes:
|
||||
lines.append("---")
|
||||
lines.append("")
|
||||
lines.append("## Resolution")
|
||||
lines.append("")
|
||||
lines.append(outcome_notes.strip())
|
||||
lines.append("")
|
||||
|
||||
# Next Steps
|
||||
next_steps = getattr(session, 'next_steps', '') or ''
|
||||
if next_steps.strip() and options.include_next_steps:
|
||||
lines.append("---")
|
||||
lines.append("")
|
||||
lines.append("## Next Steps")
|
||||
lines.append("")
|
||||
lines.append(next_steps.strip())
|
||||
lines.append("")
|
||||
```
|
||||
|
||||
**Text generator** — add before `return "\n".join(lines)` (line 235):
|
||||
|
||||
```python
|
||||
# Resolution
|
||||
outcome_notes = getattr(session, 'outcome_notes', '') or ''
|
||||
if outcome_notes.strip() and options.include_outcome_notes:
|
||||
lines.append("")
|
||||
lines.append("RESOLUTION")
|
||||
lines.append("-" * 20)
|
||||
lines.append(outcome_notes.strip())
|
||||
|
||||
# Next Steps
|
||||
next_steps = getattr(session, 'next_steps', '') or ''
|
||||
if next_steps.strip() and options.include_next_steps:
|
||||
lines.append("")
|
||||
lines.append("NEXT STEPS")
|
||||
lines.append("-" * 20)
|
||||
lines.append(next_steps.strip())
|
||||
```
|
||||
|
||||
**HTML generator** — add before `html_parts.extend(['</body>', '</html>'])` (line 307):
|
||||
|
||||
```python
|
||||
# Resolution
|
||||
outcome_notes = getattr(session, 'outcome_notes', '') or ''
|
||||
if outcome_notes.strip() and options.include_outcome_notes:
|
||||
html_parts.append('<h2>Resolution</h2>')
|
||||
html_parts.append(f'<div style="white-space: pre-wrap; margin-bottom: 20px;">{html.escape(outcome_notes.strip())}</div>')
|
||||
|
||||
# Next Steps
|
||||
next_steps = getattr(session, 'next_steps', '') or ''
|
||||
if next_steps.strip() and options.include_next_steps:
|
||||
html_parts.append('<h2>Next Steps</h2>')
|
||||
html_parts.append(f'<div style="white-space: pre-wrap; margin-bottom: 20px;">{html.escape(next_steps.strip())}</div>')
|
||||
```
|
||||
|
||||
**PSA generator** — update the existing `--- RESOLUTION ---` section (lines 363-373) to use `outcome_notes` when available, and add a next steps section:
|
||||
|
||||
Replace the resolution section with:
|
||||
|
||||
```python
|
||||
# Resolution
|
||||
lines.append("--- RESOLUTION ---")
|
||||
outcome_notes = getattr(session, 'outcome_notes', '') or ''
|
||||
if outcome_notes.strip() and options.include_outcome_notes:
|
||||
lines.append(outcome_notes.strip())
|
||||
elif session.decisions:
|
||||
last_decision = session.decisions[-1]
|
||||
resolution = last_decision.get("answer") or last_decision.get("question", "No resolution recorded")
|
||||
lines.append(resolution)
|
||||
else:
|
||||
lines.append("No resolution recorded.")
|
||||
if outcome_label:
|
||||
lines.append(f"Outcome: {outcome_label}")
|
||||
lines.append("")
|
||||
|
||||
# Next Steps
|
||||
next_steps = getattr(session, 'next_steps', '') or ''
|
||||
if next_steps.strip() and options.include_next_steps:
|
||||
lines.append("--- NEXT STEPS ---")
|
||||
lines.append(next_steps.strip())
|
||||
lines.append("")
|
||||
```
|
||||
|
||||
**Step 4: Run tests — verify they pass**
|
||||
|
||||
Run: `cd /c/Dev/Projects/patherly/backend && pytest tests/test_sessions.py -k "outcome_notes_in_resolution or omits_empty_resolution or exclude_outcome_notes_flag" -v`
|
||||
|
||||
**Step 5: Run full test suite**
|
||||
|
||||
Run: `cd /c/Dev/Projects/patherly/backend && pytest --override-ini="addopts=" -x -q`
|
||||
|
||||
**Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/services/export_service.py backend/tests/test_sessions.py
|
||||
git commit -m "feat: add Resolution and Next Steps sections to all export formats"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Partial Export with Step Cutoff (max_step_index)
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/services/export_service.py`
|
||||
- Test: `backend/tests/test_sessions.py`
|
||||
|
||||
**Step 1: Write failing tests**
|
||||
|
||||
```python
|
||||
@pytest.mark.asyncio
|
||||
async def test_export_max_step_index(
|
||||
self, client: AsyncClient, auth_headers: dict, test_tree: dict
|
||||
):
|
||||
"""Test max_step_index limits exported steps."""
|
||||
create_response = await client.post(
|
||||
"/api/v1/sessions",
|
||||
json={"tree_id": test_tree["id"]},
|
||||
headers=auth_headers
|
||||
)
|
||||
session_id = create_response.json()["id"]
|
||||
|
||||
# Add 3 decisions
|
||||
decisions = [
|
||||
{"node_id": "n1", "question": "Step one?", "answer": "Yes", "timestamp": "2026-02-13T10:00:00Z", "attachments": []},
|
||||
{"node_id": "n2", "question": "Step two?", "answer": "No", "timestamp": "2026-02-13T10:01:00Z", "attachments": []},
|
||||
{"node_id": "n3", "question": "Step three?", "answer": "Maybe", "timestamp": "2026-02-13T10:02:00Z", "attachments": []},
|
||||
]
|
||||
await client.put(
|
||||
f"/api/v1/sessions/{session_id}",
|
||||
json={"decisions": decisions},
|
||||
headers=auth_headers
|
||||
)
|
||||
|
||||
# Export with cutoff at step 2
|
||||
response = await client.post(
|
||||
f"/api/v1/sessions/{session_id}/export",
|
||||
json={"format": "markdown", "max_step_index": 2},
|
||||
headers=auth_headers
|
||||
)
|
||||
content = response.text
|
||||
assert "Step one?" in content
|
||||
assert "Step two?" in content
|
||||
assert "Step three?" not in content
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_export_max_step_index_exceeds_count(
|
||||
self, client: AsyncClient, auth_headers: dict, test_tree: dict
|
||||
):
|
||||
"""Test max_step_index larger than decision count returns all steps."""
|
||||
create_response = await client.post(
|
||||
"/api/v1/sessions",
|
||||
json={"tree_id": test_tree["id"]},
|
||||
headers=auth_headers
|
||||
)
|
||||
session_id = create_response.json()["id"]
|
||||
|
||||
decisions = [
|
||||
{"node_id": "n1", "question": "Only step", "answer": "Done", "timestamp": "2026-02-13T10:00:00Z", "attachments": []},
|
||||
]
|
||||
await client.put(
|
||||
f"/api/v1/sessions/{session_id}",
|
||||
json={"decisions": decisions},
|
||||
headers=auth_headers
|
||||
)
|
||||
|
||||
response = await client.post(
|
||||
f"/api/v1/sessions/{session_id}/export",
|
||||
json={"format": "markdown", "max_step_index": 100},
|
||||
headers=auth_headers
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "Only step" in response.text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_export_max_step_index_zero_returns_422(
|
||||
self, client: AsyncClient, auth_headers: dict, test_tree: dict
|
||||
):
|
||||
"""Test max_step_index=0 returns validation error."""
|
||||
create_response = await client.post(
|
||||
"/api/v1/sessions",
|
||||
json={"tree_id": test_tree["id"]},
|
||||
headers=auth_headers
|
||||
)
|
||||
session_id = create_response.json()["id"]
|
||||
|
||||
response = await client.post(
|
||||
f"/api/v1/sessions/{session_id}/export",
|
||||
json={"format": "markdown", "max_step_index": 0},
|
||||
headers=auth_headers
|
||||
)
|
||||
assert response.status_code == 422
|
||||
```
|
||||
|
||||
**Step 2: Run tests — verify they fail** (the 422 test should already pass due to `ge=1` on schema)
|
||||
|
||||
Run: `cd /c/Dev/Projects/patherly/backend && pytest tests/test_sessions.py -k "max_step_index" -v`
|
||||
|
||||
**Step 3: Apply step slicing in all four generators**
|
||||
|
||||
In each generator, right before the `for i, decision in enumerate(session.decisions, 1):` loop, add:
|
||||
|
||||
```python
|
||||
decisions = session.decisions
|
||||
if options.max_step_index is not None:
|
||||
decisions = decisions[:options.max_step_index]
|
||||
```
|
||||
|
||||
Then change the loop to iterate over `decisions` instead of `session.decisions`.
|
||||
|
||||
Apply this in:
|
||||
- `generate_markdown_export` (line ~153)
|
||||
- `generate_text_export` (line ~214)
|
||||
- `generate_html_export` (line ~283)
|
||||
- `generate_psa_export` (line ~338)
|
||||
|
||||
**Step 4: Run tests — verify they pass**
|
||||
|
||||
Run: `cd /c/Dev/Projects/patherly/backend && pytest tests/test_sessions.py -k "max_step_index" -v`
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/services/export_service.py backend/tests/test_sessions.py
|
||||
git commit -m "feat: add max_step_index for partial exports"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Mid-Session Export — Don't Set exported=True
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/api/endpoints/sessions.py:316-318`
|
||||
- Test: `backend/tests/test_sessions.py`
|
||||
|
||||
**Step 1: Write failing test**
|
||||
|
||||
```python
|
||||
@pytest.mark.asyncio
|
||||
async def test_export_in_progress_session_does_not_mark_exported(
|
||||
self, client: AsyncClient, auth_headers: dict, test_tree: dict
|
||||
):
|
||||
"""Test that exporting an in-progress session does NOT set exported=True."""
|
||||
create_response = await client.post(
|
||||
"/api/v1/sessions",
|
||||
json={"tree_id": test_tree["id"]},
|
||||
headers=auth_headers
|
||||
)
|
||||
session_id = create_response.json()["id"]
|
||||
|
||||
# Export without completing
|
||||
await client.post(
|
||||
f"/api/v1/sessions/{session_id}/export",
|
||||
json={"format": "markdown"},
|
||||
headers=auth_headers
|
||||
)
|
||||
|
||||
# Check session - should NOT be marked exported
|
||||
response = await client.get(
|
||||
f"/api/v1/sessions/{session_id}",
|
||||
headers=auth_headers
|
||||
)
|
||||
assert response.json()["exported"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_export_completed_session_marks_exported(
|
||||
self, client: AsyncClient, auth_headers: dict, test_tree: dict
|
||||
):
|
||||
"""Test that exporting a completed session sets exported=True."""
|
||||
create_response = await client.post(
|
||||
"/api/v1/sessions",
|
||||
json={"tree_id": test_tree["id"]},
|
||||
headers=auth_headers
|
||||
)
|
||||
session_id = create_response.json()["id"]
|
||||
|
||||
# Complete first
|
||||
await client.post(
|
||||
f"/api/v1/sessions/{session_id}/complete",
|
||||
json={"outcome": "resolved"},
|
||||
headers=auth_headers
|
||||
)
|
||||
|
||||
# Export
|
||||
await client.post(
|
||||
f"/api/v1/sessions/{session_id}/export",
|
||||
json={"format": "markdown"},
|
||||
headers=auth_headers
|
||||
)
|
||||
|
||||
# Check - should be marked exported
|
||||
response = await client.get(
|
||||
f"/api/v1/sessions/{session_id}",
|
||||
headers=auth_headers
|
||||
)
|
||||
assert response.json()["exported"] is True
|
||||
```
|
||||
|
||||
**Step 2: Run tests — verify the first one fails**
|
||||
|
||||
Run: `cd /c/Dev/Projects/patherly/backend && pytest tests/test_sessions.py -k "in_progress_session_does_not_mark_exported or completed_session_marks_exported" -v`
|
||||
|
||||
**Step 3: Update export endpoint**
|
||||
|
||||
In `backend/app/api/endpoints/sessions.py`, replace lines 316-318:
|
||||
|
||||
```python
|
||||
# Mark as exported
|
||||
session.exported = True
|
||||
await db.commit()
|
||||
```
|
||||
|
||||
With:
|
||||
|
||||
```python
|
||||
# Only mark as exported if session is completed
|
||||
if session.completed_at:
|
||||
session.exported = True
|
||||
await db.commit()
|
||||
```
|
||||
|
||||
**Step 4: Run tests — verify they pass**
|
||||
|
||||
Run: `cd /c/Dev/Projects/patherly/backend && pytest tests/test_sessions.py -k "in_progress_session_does_not_mark_exported or completed_session_marks_exported" -v`
|
||||
|
||||
**Step 5: Run full test suite**
|
||||
|
||||
Run: `cd /c/Dev/Projects/patherly/backend && pytest --override-ini="addopts=" -x -q`
|
||||
|
||||
**Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/api/endpoints/sessions.py backend/tests/test_sessions.py
|
||||
git commit -m "feat: only mark session exported when completed"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Final Verification
|
||||
|
||||
**Step 1: Run full test suite**
|
||||
|
||||
Run: `cd /c/Dev/Projects/patherly/backend && pytest --override-ini="addopts=" -v`
|
||||
|
||||
Expected: All tests pass, including all new tests.
|
||||
|
||||
**Step 2: Verify backward compatibility**
|
||||
|
||||
Confirm that the existing export tests (the ones from before our changes) still pass with no modifications — the new schema fields all have defaults.
|
||||
|
||||
**Step 3: Commit any remaining changes and verify git status is clean**
|
||||
|
||||
```bash
|
||||
git status
|
||||
```
|
||||
294
docs/plans/archive/2026-02-15-analytics-feedback-design.md
Normal file
294
docs/plans/archive/2026-02-15-analytics-feedback-design.md
Normal file
@@ -0,0 +1,294 @@
|
||||
# Analytics & User Feedback — Design Document
|
||||
|
||||
> **Date:** February 15, 2026
|
||||
> **Status:** Approved
|
||||
> **Audience:** Team admins + individual engineers (both equally)
|
||||
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Add analytics dashboards and user feedback systems to ResolutionFlow so MSP managers can measure team productivity and flow effectiveness, engineers can track their own performance, and flow authors get actionable feedback to improve their troubleshooting trees.
|
||||
|
||||
## Architecture
|
||||
|
||||
Live queries against existing PostgreSQL tables (sessions, trees, step_library) with one new table (session_ratings). No materialized views or external analytics services — direct aggregation queries with proper indexes. Time-series data returned as daily-bucketed arrays for Recharts visualization on the frontend.
|
||||
|
||||
## Tech Stack Additions
|
||||
|
||||
- **Backend:** New endpoint modules (`analytics.py`, `ratings.py`), one Alembic migration
|
||||
- **Frontend:** Recharts (charting), two new pages, one new modal, inline step feedback components
|
||||
- **No new infrastructure** — runs on existing PostgreSQL + Railway deployment
|
||||
|
||||
---
|
||||
|
||||
## 1. Feedback System Design
|
||||
|
||||
### Step-Level Feedback: Thumbs Up / Thumbs Down
|
||||
|
||||
**When:** Inline during session navigation, always visible on each step.
|
||||
|
||||
**UX:**
|
||||
- Small thumb-up and thumb-down icons displayed on each step in TreeNavigationPage and ProceduralNavigationPage
|
||||
- Non-intrusive: muted icons that highlight on selection
|
||||
- First-time tooltip: "Rate this step to help improve flows" (dismissible, shown once)
|
||||
- After rating: selected thumb highlights (green for up, red for down), other thumb dims
|
||||
- Tapping the same thumb again un-rates (toggle behavior)
|
||||
|
||||
**Data model:**
|
||||
- Uses existing `step_ratings` table with `was_helpful` boolean
|
||||
- One rating per user per step per session (unique constraint on step_id + user_id + session_id)
|
||||
- Updates `step_library.helpful_yes` / `helpful_no` aggregate counts
|
||||
|
||||
**Endpoint:**
|
||||
- `POST /steps/{step_id}/feedback` — body: `{ session_id, was_helpful: true|false }`
|
||||
- `DELETE /steps/{step_id}/feedback/{session_id}` — un-rate
|
||||
|
||||
### Flow-Level Feedback: CSAT 1-5 + Optional Comment
|
||||
|
||||
**When:** After session completion, prompted after the SessionOutcomeModal.
|
||||
|
||||
**UX:**
|
||||
- Modal with 1-5 star selector (or numbered buttons)
|
||||
- Optional comment textarea (500 char max)
|
||||
- "Skip" button to dismiss without rating
|
||||
- Shown once per completed session
|
||||
|
||||
**Data model:**
|
||||
- New `session_ratings` table:
|
||||
- `id` UUID PK
|
||||
- `session_id` FK unique (one rating per session)
|
||||
- `user_id` FK
|
||||
- `tree_id` FK (denormalized for aggregation)
|
||||
- `account_id` FK (denormalized for team scoping)
|
||||
- `rating` Integer 1-5 (CHECK constraint)
|
||||
- `comment` String(500), nullable
|
||||
- `created_at` DateTime(timezone=True)
|
||||
|
||||
**Endpoint:**
|
||||
- `POST /sessions/{session_id}/rate` — body: `{ rating: 1-5, comment?: string }`
|
||||
- `GET /trees/{tree_id}/ratings` — paginated list of ratings/comments for flow authors
|
||||
|
||||
---
|
||||
|
||||
## 2. Analytics Endpoints
|
||||
|
||||
All analytics endpoints accept `?period=7d|30d|90d` (default 30d) and return data scoped to the user's account.
|
||||
|
||||
### Team Analytics — `GET /analytics/team`
|
||||
|
||||
**Access:** team_admin or super_admin only.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"summary": {
|
||||
"total_sessions": 247,
|
||||
"completed_sessions": 198,
|
||||
"completion_rate": 0.801,
|
||||
"avg_duration_minutes": 12.4,
|
||||
"active_engineers": 8,
|
||||
"outcome_breakdown": {
|
||||
"resolved": 142,
|
||||
"escalated": 31,
|
||||
"workaround": 18,
|
||||
"unresolved": 7
|
||||
}
|
||||
},
|
||||
"time_series": [
|
||||
{ "date": "2026-02-01", "sessions": 12, "resolved": 8, "escalated": 2, "workaround": 1, "unresolved": 1 },
|
||||
...
|
||||
],
|
||||
"top_flows": [
|
||||
{ "tree_id": "...", "name": "DNS Resolution", "sessions": 42, "completion_rate": 0.88, "avg_duration_minutes": 8.2, "avg_csat": 4.1 },
|
||||
...
|
||||
],
|
||||
"top_engineers": [
|
||||
{ "user_id": "...", "name": "Jane Smith", "sessions": 34, "completion_rate": 0.91, "avg_duration_minutes": 10.1 },
|
||||
...
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Optional filter:** `?engineer_id=<uuid>` to scope to one engineer.
|
||||
|
||||
### Personal Analytics — `GET /analytics/me`
|
||||
|
||||
**Access:** Any authenticated user.
|
||||
|
||||
**Response:** Same shape as team analytics but scoped to the requesting user only. No engineer leaderboard — replaced with "my top flows" list.
|
||||
|
||||
### Flow Analytics — `GET /analytics/flows/{tree_id}`
|
||||
|
||||
**Access:** Anyone who can view the flow.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"summary": {
|
||||
"total_sessions": 42,
|
||||
"completion_rate": 0.88,
|
||||
"avg_duration_minutes": 8.2,
|
||||
"avg_csat": 4.1,
|
||||
"total_ratings": 28,
|
||||
"outcome_breakdown": { ... }
|
||||
},
|
||||
"time_series": [
|
||||
{ "date": "2026-02-01", "sessions": 3, "avg_duration_minutes": 7.5 },
|
||||
...
|
||||
],
|
||||
"step_feedback": [
|
||||
{ "node_id": "abc", "node_title": "Check DNS Settings", "helpful_yes": 18, "helpful_no": 2, "helpful_rate": 0.9 },
|
||||
...
|
||||
],
|
||||
"recent_comments": [
|
||||
{ "rating": 5, "comment": "Very helpful flow", "user_name": "John", "created_at": "..." },
|
||||
...
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Frontend Pages
|
||||
|
||||
### Team Analytics Page — `/analytics`
|
||||
|
||||
**Access:** team_admin+ (hidden from engineers/viewers in nav).
|
||||
|
||||
**Layout:**
|
||||
- Page header: "Team Analytics" + period dropdown (7d / 30d / 90d)
|
||||
- Row 1: Stat cards — Total Sessions, Completion Rate, Avg Duration, Active Engineers
|
||||
- Row 2: Time-series chart (sessions per day, stacked by outcome) — Recharts AreaChart
|
||||
- Row 3: Two columns:
|
||||
- Left: Flow Leaderboard table (top flows by usage, with completion rate + avg duration + CSAT)
|
||||
- Right: Engineer Leaderboard table (top engineers by session count, with success rate + avg duration)
|
||||
|
||||
### My Analytics Page — `/analytics/me`
|
||||
|
||||
**Access:** Any authenticated user.
|
||||
|
||||
**Layout:**
|
||||
- Page header: "My Analytics" + period dropdown
|
||||
- Row 1: Stat cards — My Sessions, My Completion Rate, My Avg Duration, My Outcome Split
|
||||
- Row 2: Sessions-per-day line chart
|
||||
- Row 3: Two columns:
|
||||
- Left: My Top Flows table (most-used flows with personal stats)
|
||||
- Right: Outcome distribution donut chart (Recharts PieChart)
|
||||
|
||||
### Flow Analytics Panel
|
||||
|
||||
**Location:** New tab or expandable section on tree detail/editor views.
|
||||
|
||||
**Layout:**
|
||||
- Summary stat cards: Usage, Completion Rate, Avg Duration, CSAT
|
||||
- Session trend mini-chart (sparkline or small area chart)
|
||||
- Step feedback table: each step with helpful rate bar + thumbs count
|
||||
- Recent CSAT comments list (latest 5-10)
|
||||
|
||||
### Navigation
|
||||
|
||||
- Sidebar: Add "Analytics" nav item with BarChart3 icon between "Sessions" and "Exports"
|
||||
- Team admins see `/analytics` (team view) as default
|
||||
- Engineers see `/analytics/me` as default
|
||||
- Both can navigate between views if they have permission
|
||||
|
||||
---
|
||||
|
||||
## 4. Database Migration
|
||||
|
||||
**New table: `session_ratings`**
|
||||
|
||||
```sql
|
||||
CREATE TABLE session_ratings (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
tree_id UUID NOT NULL REFERENCES trees(id) ON DELETE CASCADE,
|
||||
account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
rating INTEGER NOT NULL CHECK (rating >= 1 AND rating <= 5),
|
||||
comment VARCHAR(500),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (session_id)
|
||||
);
|
||||
```
|
||||
|
||||
**New indexes for analytics queries:**
|
||||
|
||||
```sql
|
||||
CREATE INDEX ix_session_ratings_tree_created ON session_ratings(tree_id, created_at);
|
||||
CREATE INDEX ix_session_ratings_account_created ON session_ratings(account_id, created_at);
|
||||
CREATE INDEX ix_sessions_account_completed ON sessions(account_id, completed_at);
|
||||
CREATE INDEX ix_sessions_account_tree_completed ON sessions(account_id, tree_id, completed_at);
|
||||
CREATE INDEX ix_step_ratings_step_helpful ON step_ratings(step_id, was_helpful);
|
||||
```
|
||||
|
||||
**Modify `step_ratings` usage:**
|
||||
- Existing `rating` (1-5) and `review_text` columns remain in DB but are no longer populated
|
||||
- Only `was_helpful` boolean is used going forward
|
||||
- Add `session_id` to unique constraint if not already present: `UNIQUE(step_id, user_id, session_id)`
|
||||
|
||||
---
|
||||
|
||||
## 5. Charting Library
|
||||
|
||||
**Recharts** (`recharts` npm package)
|
||||
- React-native, composable components
|
||||
- Supports: AreaChart (time-series), BarChart (comparisons), PieChart (outcome donut), LineChart (trends)
|
||||
- Lightweight (~45KB gzipped)
|
||||
- Dark theme compatible via custom colors matching our design tokens
|
||||
|
||||
**Chart color palette** (matching design system):
|
||||
- Primary: `hsl(243, 75%, 59%)` (purple — matches `--primary`)
|
||||
- Resolved: `#34d399` (emerald-400)
|
||||
- Escalated: `#f87171` (red-400)
|
||||
- Workaround: `#fbbf24` (yellow-400)
|
||||
- Unresolved: `#94a3b8` (slate-400)
|
||||
|
||||
---
|
||||
|
||||
## 6. Step Feedback UX Detail
|
||||
|
||||
**Inline thumbs placement:**
|
||||
- Positioned at the bottom of each step card, right-aligned
|
||||
- Two icons: ThumbsUp and ThumbsDown from Lucide
|
||||
- Default state: `text-muted-foreground` (subtle, not distracting)
|
||||
- Hover: icon scales slightly, tooltip appears
|
||||
- Selected up: `text-emerald-400` with subtle fill
|
||||
- Selected down: `text-red-400` with subtle fill
|
||||
- Toggle: clicking selected thumb un-selects it
|
||||
|
||||
**First-time hint:**
|
||||
- On the first session where thumbs are available, show a subtle inline note below the first step: "New: Rate steps with thumbs to help improve flows"
|
||||
- Store dismissal in localStorage
|
||||
- Auto-dismisses after first thumb interaction
|
||||
|
||||
**CSAT Modal (post-completion):**
|
||||
- Appears after SessionOutcomeModal closes
|
||||
- Five numbered buttons (1-5) or star icons in a row
|
||||
- Label: "How would you rate this flow?"
|
||||
- Sublabel: "Your feedback helps flow authors improve"
|
||||
- Optional textarea: "Any comments? (optional)"
|
||||
- Buttons: "Submit" (primary) + "Skip" (text link)
|
||||
- localStorage tracks which sessions have been rated to prevent re-prompting
|
||||
|
||||
---
|
||||
|
||||
## 7. Scope & Non-Goals
|
||||
|
||||
**In scope:**
|
||||
- Team analytics dashboard with time-series charts
|
||||
- Personal analytics dashboard
|
||||
- Flow-level analytics panel
|
||||
- Step thumbs up/down inline feedback
|
||||
- Flow CSAT 1-5 + comment at session end
|
||||
- Period filtering (7d/30d/90d)
|
||||
|
||||
**Not in scope (future):**
|
||||
- Real-time streaming analytics
|
||||
- Export analytics to CSV/PDF
|
||||
- Custom date range picker (just preset periods for v1)
|
||||
- Comparison mode (this period vs. last period)
|
||||
- Automation adoption metrics
|
||||
- Client/ticket correlation analytics
|
||||
- Email digest/reports
|
||||
1626
docs/plans/archive/2026-02-15-analytics-feedback-implementation.md
Normal file
1626
docs/plans/archive/2026-02-15-analytics-feedback-implementation.md
Normal file
File diff suppressed because it is too large
Load Diff
374
docs/plans/archive/2026-02-15-visual-qa-design-migration.md
Normal file
374
docs/plans/archive/2026-02-15-visual-qa-design-migration.md
Normal file
@@ -0,0 +1,374 @@
|
||||
# Visual QA & Design System Migration — Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Fix the collapsed sidebar scaling issue and migrate all pages from old monochrome design patterns to the new purple gradient accent design system.
|
||||
|
||||
**Architecture:** Systematic find-and-replace across 15 frontend files, replacing old monochrome CSS utilities (`glass-card`, `text-white/N`, `border-white/N`, `bg-white text-black` buttons) with new design tokens (`bg-card border-border`, `text-foreground`, `border-border`, `bg-gradient-brand`). Sidebar collapsed state gets a layout fix.
|
||||
|
||||
**Tech Stack:** React, Tailwind CSS v3, Lucide React
|
||||
|
||||
---
|
||||
|
||||
## Pattern Replacement Reference
|
||||
|
||||
Every task below uses this table. Keep it open.
|
||||
|
||||
| OLD Pattern | NEW Pattern | Context |
|
||||
|---|---|---|
|
||||
| `glass-card rounded-2xl` | `bg-card border border-border rounded-xl` | Card containers |
|
||||
| `glass-card rounded-xl` | `bg-card border border-border rounded-xl` | Card containers |
|
||||
| `bg-black` or `bg-black/50` | `bg-card` | Input/section backgrounds |
|
||||
| `text-white` (on headings) | `text-foreground` + add `font-heading` | Primary heading text |
|
||||
| `text-white` (on body text) | `text-foreground` | Primary body text |
|
||||
| `text-white/70` | `text-muted-foreground` | Secondary text |
|
||||
| `text-white/40` | `text-muted-foreground` | Muted text |
|
||||
| `text-white/60` | `text-muted-foreground` | Button secondary text |
|
||||
| `border-white/10` | `border-border` | Standard borders |
|
||||
| `border-white/[0.06]` | `border-border` | Subtle borders |
|
||||
| `border-white/20` | `border-border` | Toggle/active borders |
|
||||
| `bg-white/10` | `bg-accent` | Hover/active backgrounds |
|
||||
| `hover:bg-white/10` | `hover:bg-accent` | Hover states |
|
||||
| `hover:text-white` | `hover:text-foreground` | Hover text |
|
||||
| `bg-white text-black hover:bg-white/90` | `bg-gradient-brand text-white shadow-lg shadow-primary/20 hover:opacity-90` | Primary buttons |
|
||||
| `bg-white px-N py-N text-black` ... `hover:bg-white/90` | `bg-gradient-brand text-white shadow-lg shadow-primary/20 hover:opacity-90` | Primary buttons (multi-class) |
|
||||
| `border-white/10 ... text-white/60 ... hover:bg-white/10` | `border-border text-muted-foreground hover:bg-accent hover:text-foreground` | Secondary buttons |
|
||||
| `focus:border-white/30 focus:ring-white/20` | `focus:border-primary focus:ring-1 focus:ring-primary/20` | Input focus |
|
||||
| `placeholder:text-white/40` | `placeholder:text-muted-foreground` | Input placeholders |
|
||||
| `bg-white/10 px-2.5 py-0.5` (badges) | `bg-accent rounded-full px-2 text-[0.6875rem] font-label` | Badge/chip styling |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Fix Collapsed Sidebar Layout
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/layout/Sidebar.tsx`
|
||||
- Modify: `frontend/src/index.css`
|
||||
|
||||
**Problem:** When the sidebar is collapsed, the navigation divider doesn't span the full height (top to bottom), and the expand icon is extremely small — suggests the entire collapsed column isn't sizing correctly.
|
||||
|
||||
**Step 1: Fix collapsed sidebar to be a proper full-height flex column**
|
||||
|
||||
In `Sidebar.tsx`, the collapsed state renders a `<div className="px-2 py-3 space-y-1">` with nav icons but doesn't fill the column. The `<nav>` wrapper has `flex flex-col` but the collapsed branch lacks structure to fill it.
|
||||
|
||||
Replace the collapsed branch (lines 115-127) to add full-height structure with proper icon sizing and dividers:
|
||||
|
||||
```tsx
|
||||
{sidebarCollapsed ? (
|
||||
<>
|
||||
{/* Collapsed: icon-only nav */}
|
||||
<div className="flex flex-col items-center px-1.5 py-3 space-y-1">
|
||||
<NavItem href="/" icon={LayoutGrid} label="Dashboard" collapsed />
|
||||
<NavItem href="/trees" icon={Box} label="All Flows" matchPaths={['/trees', '/flows']} collapsed />
|
||||
<NavItem href="/my-trees" icon={PenLine} label="Flow Editor" collapsed />
|
||||
<NavItem href="/sessions" icon={Clock} label="Sessions" badge={activeSessionCount || undefined} collapsed />
|
||||
<NavItem href="/shares" icon={FileText} label="Exports" collapsed />
|
||||
<NavItem href="/step-library" icon={Bookmark} label="Step Library" collapsed />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
```
|
||||
|
||||
In the footer section (lines 173-189), ensure the toggle button has adequate sizing in collapsed mode:
|
||||
|
||||
```tsx
|
||||
{/* Footer */}
|
||||
<div className={cn(
|
||||
'border-t border-[hsl(var(--border-subtle))]',
|
||||
sidebarCollapsed ? 'px-1.5 py-2' : 'px-3 py-2 space-y-0.5'
|
||||
)}>
|
||||
{!sidebarCollapsed && (
|
||||
<>
|
||||
<NavItem href="/account" icon={Users} label="Team" />
|
||||
<NavItem href="/account" icon={Settings} label="Settings" />
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
className={cn(
|
||||
'flex items-center justify-center rounded-lg text-muted-foreground hover:bg-[hsl(var(--sidebar-hover))] hover:text-foreground transition-colors',
|
||||
sidebarCollapsed ? 'w-full p-2.5' : 'w-full gap-3 px-3 py-2 text-[0.8125rem] font-medium'
|
||||
)}
|
||||
title={sidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
>
|
||||
{sidebarCollapsed ? <PanelLeftOpen size={20} /> : <PanelLeftClose size={18} />}
|
||||
{!sidebarCollapsed && <span>Collapse</span>}
|
||||
</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Step 2: Verify build passes**
|
||||
|
||||
Run: `cd frontend && npm run build`
|
||||
Expected: Build succeeds with no errors.
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/components/layout/Sidebar.tsx
|
||||
git commit -m "fix: collapsed sidebar layout scaling and toggle button size"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Migrate Auth Pages (Login, Register, ChangePassword, ResetPassword)
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/pages/LoginPage.tsx`
|
||||
- Modify: `frontend/src/pages/RegisterPage.tsx`
|
||||
- Modify: `frontend/src/pages/ChangePasswordPage.tsx`
|
||||
- Modify: `frontend/src/pages/ResetPasswordPage.tsx`
|
||||
|
||||
**Step 1: Apply replacements to all four files**
|
||||
|
||||
Each file has the same three patterns:
|
||||
1. `glass-card rounded-2xl` → `bg-card border border-border rounded-xl`
|
||||
2. `border-white/10 bg-black/50` → `border-border bg-card`
|
||||
3. `bg-white ... text-black ... hover:bg-white/90` → `bg-gradient-brand text-white shadow-lg shadow-primary/20 hover:opacity-90`
|
||||
|
||||
Also check for:
|
||||
- Missing `font-heading` on page titles
|
||||
- `text-white` → `text-foreground`
|
||||
- Input focus states: `focus:border-white/30 focus:ring-white/20` → `focus:border-primary focus:ring-1 focus:ring-primary/20`
|
||||
- Placeholder colors: `placeholder:text-white/40` → `placeholder:text-muted-foreground`
|
||||
|
||||
**Step 2: Verify build passes**
|
||||
|
||||
Run: `cd frontend && npm run build`
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/pages/LoginPage.tsx frontend/src/pages/RegisterPage.tsx frontend/src/pages/ChangePasswordPage.tsx frontend/src/pages/ResetPasswordPage.tsx
|
||||
git commit -m "refactor: migrate auth pages to new design system"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Migrate TreeLibraryPage
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/pages/TreeLibraryPage.tsx`
|
||||
|
||||
**Step 1: Apply replacements**
|
||||
|
||||
This file has extensive old patterns:
|
||||
- Primary buttons: `bg-white ... text-black hover:bg-white/90` → `bg-gradient-brand text-white shadow-lg shadow-primary/20 hover:opacity-90`
|
||||
- Cards: `glass-card` → `bg-card border border-border rounded-xl`
|
||||
- Inputs: `border-white/10 bg-black/50 text-white placeholder:text-white/40` → `border-border bg-card text-foreground placeholder:text-muted-foreground`
|
||||
- Focus states: `focus:border-white/30 focus:ring-white/20` → `focus:border-primary focus:ring-1 focus:ring-primary/20`
|
||||
- Text: `text-white` → `text-foreground`, `text-white/40` → `text-muted-foreground`
|
||||
- Borders: `border-white/10` → `border-border`
|
||||
- Badges: `bg-white/10` → `bg-accent`
|
||||
- Select dropdowns: `border-white/10 bg-black/50` → `border-border bg-card`
|
||||
- Add `font-heading` to page title heading
|
||||
|
||||
**Step 2: Verify build passes**
|
||||
|
||||
Run: `cd frontend && npm run build`
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/pages/TreeLibraryPage.tsx
|
||||
git commit -m "refactor: migrate TreeLibraryPage to new design system"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Migrate SessionHistoryPage and SessionDetailPage
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/pages/SessionHistoryPage.tsx`
|
||||
- Modify: `frontend/src/pages/SessionDetailPage.tsx`
|
||||
|
||||
**Step 1: Apply replacements**
|
||||
|
||||
SessionHistoryPage:
|
||||
- `text-white` heading → `text-foreground` + `font-heading`
|
||||
- `text-white/40` → `text-muted-foreground`
|
||||
- `border-white/[0.06]` → `border-border`
|
||||
- `glass-card rounded-2xl` → `bg-card border border-border rounded-xl`
|
||||
- Primary buttons: `bg-white ... text-black hover:bg-white/90` → gradient
|
||||
- Secondary buttons: `border-white/10 ... text-white/60 ... hover:bg-white/10` → `border-border text-muted-foreground hover:bg-accent`
|
||||
|
||||
SessionDetailPage:
|
||||
- `text-white font-bold` → `text-foreground font-heading font-bold`
|
||||
- `text-white/40` → `text-muted-foreground`
|
||||
- `bg-white/10` badges → `bg-accent`
|
||||
- Primary buttons: old → gradient
|
||||
- `border-white/10 bg-black/50` → `border-border bg-card`
|
||||
- `border-white/10 bg-transparent` → `border-border bg-card`
|
||||
|
||||
**Step 2: Verify build passes**
|
||||
|
||||
Run: `cd frontend && npm run build`
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/pages/SessionHistoryPage.tsx frontend/src/pages/SessionDetailPage.tsx
|
||||
git commit -m "refactor: migrate session pages to new design system"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Migrate TreeEditorPage
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/pages/TreeEditorPage.tsx`
|
||||
|
||||
**Step 1: Apply replacements**
|
||||
|
||||
- `glass-card rounded-2xl` → `bg-card border border-border rounded-xl`
|
||||
- Primary buttons: `bg-white ... text-black hover:bg-white/90` → gradient
|
||||
- `border-white/[0.06]` → `border-border`
|
||||
- `text-lg font-semibold text-white` → `text-lg font-heading font-semibold text-foreground`
|
||||
- Dark mode mixed patterns: `bg-yellow-100 ... dark:bg-yellow-900/30 dark:text-yellow-400` → just `bg-yellow-900/30 text-yellow-400 border border-yellow-500/20` (we're dark-only)
|
||||
- `bg-white/10 text-white` toggle states → `bg-accent text-foreground`
|
||||
- `border-white/10 bg-black/50` → `border-border bg-card`
|
||||
|
||||
**Step 2: Verify build passes**
|
||||
|
||||
Run: `cd frontend && npm run build`
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/pages/TreeEditorPage.tsx
|
||||
git commit -m "refactor: migrate TreeEditorPage to new design system"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Migrate TreeNavigationPage
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/pages/TreeNavigationPage.tsx`
|
||||
|
||||
This is the largest file with the most old patterns.
|
||||
|
||||
**Step 1: Apply replacements**
|
||||
|
||||
- `text-white` headings → `text-foreground` + `font-heading`
|
||||
- `text-white/40` → `text-muted-foreground`
|
||||
- `glass-card rounded-2xl` → `bg-card border border-border rounded-xl`
|
||||
- `glass-card` → `bg-card border border-border`
|
||||
- `border-white/10 bg-black/50` → `border-border bg-card`
|
||||
- `border-white/10` → `border-border`
|
||||
- `border-white/[0.06]` → `border-border`
|
||||
- `bg-white/10` → `bg-accent`
|
||||
- `text-white/70 hover:bg-white/10 hover:text-white` → `text-muted-foreground hover:bg-accent hover:text-foreground`
|
||||
- Primary buttons: `bg-white ... text-black hover:bg-white/90` → gradient
|
||||
- `border-purple-700 bg-purple-900/20` → `border-primary/30 bg-primary/10` (use design tokens, not hardcoded colors)
|
||||
- `bg-white/10 px-2.5 py-0.5` badges → `bg-accent rounded-full px-2 text-[0.6875rem] font-label`
|
||||
|
||||
**Step 2: Verify build passes**
|
||||
|
||||
Run: `cd frontend && npm run build`
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/pages/TreeNavigationPage.tsx
|
||||
git commit -m "refactor: migrate TreeNavigationPage to new design system"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Migrate Session Sharing Components
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/pages/MySharesPage.tsx`
|
||||
- Modify: `frontend/src/pages/SharedSessionPage.tsx`
|
||||
- Modify: `frontend/src/components/session/ShareSessionModal.tsx`
|
||||
|
||||
**Step 1: Apply replacements**
|
||||
|
||||
MySharesPage:
|
||||
- `text-white` heading → `text-foreground` + `font-heading`
|
||||
- `text-white/40` → `text-muted-foreground`
|
||||
- `glass-card rounded-xl` → `bg-card border border-border rounded-xl`
|
||||
- Primary buttons: old → gradient
|
||||
|
||||
SharedSessionPage:
|
||||
- `glass-card w-full max-w-md rounded-2xl` → `bg-card border border-border w-full max-w-md rounded-xl`
|
||||
- `bg-white ... text-black hover:bg-white/90` → gradient
|
||||
- `border-white/[0.06]` → `border-border`
|
||||
- `text-lg font-semibold text-white` → `text-lg font-heading font-semibold text-foreground`
|
||||
- `text-lg text-white/70` → `text-lg text-muted-foreground`
|
||||
|
||||
ShareSessionModal:
|
||||
- `glass-card rounded-2xl` → `bg-card border border-border rounded-xl`
|
||||
- `border-white/[0.06]` → `border-border`
|
||||
- `border-white/20 bg-white/10` toggle → `border-border bg-accent`
|
||||
- `border-white/10 bg-black/50` → `border-border bg-card`
|
||||
- Primary buttons: old → gradient
|
||||
|
||||
**Step 2: Verify build passes**
|
||||
|
||||
Run: `cd frontend && npm run build`
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/pages/MySharesPage.tsx frontend/src/pages/SharedSessionPage.tsx frontend/src/components/session/ShareSessionModal.tsx
|
||||
git commit -m "refactor: migrate session sharing components to new design system"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Clean Up Legacy CSS
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/index.css`
|
||||
|
||||
**Step 1: Remove workspace dropdown animation (dead code)**
|
||||
|
||||
Remove the comment and `@keyframes dropIn` block (lines 115-119) — it was for the workspace switcher dropdown that no longer exists.
|
||||
|
||||
**Step 2: Verify build passes**
|
||||
|
||||
Run: `cd frontend && npm run build`
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/index.css
|
||||
git commit -m "chore: remove workspace dropdown animation (dead code)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Final Grep Audit
|
||||
|
||||
**Step 1: Grep for remaining old patterns**
|
||||
|
||||
Search for all remaining instances of old patterns across the entire frontend:
|
||||
|
||||
```bash
|
||||
grep -rn "glass-card\|text-white/\|border-white/\|bg-white text-black\|bg-black/50\|hover:bg-white/" frontend/src/ --include="*.tsx" --include="*.ts"
|
||||
```
|
||||
|
||||
Any hits are stragglers to fix.
|
||||
|
||||
**Step 2: Grep for dark mode toggles**
|
||||
|
||||
```bash
|
||||
grep -rn "dark:" frontend/src/ --include="*.tsx" --include="*.ts"
|
||||
```
|
||||
|
||||
We're dark-only — `dark:` prefixes are dead code and should be cleaned to just the dark variant.
|
||||
|
||||
**Step 3: Fix any stragglers found**
|
||||
|
||||
**Step 4: Verify build passes**
|
||||
|
||||
Run: `cd frontend && npm run build`
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "refactor: clean up remaining old monochrome patterns"
|
||||
```
|
||||
202
docs/plans/archive/2026-02-17-maintenance-flows-design.md
Normal file
202
docs/plans/archive/2026-02-17-maintenance-flows-design.md
Normal file
@@ -0,0 +1,202 @@
|
||||
# Maintenance Flows — Design Document
|
||||
|
||||
> **Date:** 2026-02-17
|
||||
> **Status:** Approved
|
||||
> **Phase:** Design (pre-implementation)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Add `maintenance` as a first-class flow type in ResolutionFlow, alongside `troubleshooting` and `procedural`. Maintenance flows are designed for MSP scheduled/repeatable infrastructure tasks (e.g., patching Citrix servers, updating FSLogix, updating RDS software). They share the procedural execution engine but add scheduling, multi-target batch launching, and saved target lists.
|
||||
|
||||
---
|
||||
|
||||
## Goals
|
||||
|
||||
- Visual separation of maintenance flows from troubleshooting and project flows
|
||||
- Batch launch: one flow run against N servers/targets simultaneously, each tracked as an independent session
|
||||
- Saved target lists per team, with ad-hoc entry and future PSA/RMM import
|
||||
- Scheduled auto-session creation with in-app notifications
|
||||
- Re-use target lists from previous batch runs
|
||||
|
||||
---
|
||||
|
||||
## Data Model
|
||||
|
||||
### `tree_type` expansion
|
||||
|
||||
**Migration:** Drop and recreate the `ck_trees_tree_type` check constraint to allow `'troubleshooting' | 'procedural' | 'maintenance'`.
|
||||
|
||||
Maintenance flows reuse `tree_structure` (step-by-step like procedural) and `intake_form` (for capturing target-specific context at session start, e.g., patch version).
|
||||
|
||||
---
|
||||
|
||||
### `target_lists` table (new)
|
||||
|
||||
```sql
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid()
|
||||
team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE
|
||||
created_by UUID REFERENCES users(id) ON DELETE SET NULL
|
||||
name VARCHAR(255) NOT NULL
|
||||
description TEXT
|
||||
targets JSONB NOT NULL -- [{ "label": "RDS-01", "notes": "..." }, ...]
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
```
|
||||
|
||||
- Scoped to team; any engineer can create/edit/delete their team's lists
|
||||
- Each target entry: `label` (required, display name / hostname) + `notes` (optional, IP, role, etc.)
|
||||
|
||||
---
|
||||
|
||||
### `maintenance_schedules` table (new)
|
||||
|
||||
```sql
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid()
|
||||
tree_id UUID NOT NULL REFERENCES trees(id) ON DELETE CASCADE
|
||||
created_by UUID REFERENCES users(id) ON DELETE SET NULL
|
||||
cron_expression VARCHAR(100) NOT NULL -- e.g. "0 9 15 * *"
|
||||
timezone VARCHAR(100) NOT NULL DEFAULT 'UTC'
|
||||
target_list_id UUID REFERENCES target_lists(id) ON DELETE SET NULL
|
||||
is_active BOOLEAN NOT NULL DEFAULT true
|
||||
next_run_at TIMESTAMPTZ NOT NULL
|
||||
last_run_at TIMESTAMPTZ
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
```
|
||||
|
||||
- One active schedule per maintenance flow (enforced at API level)
|
||||
- `target_list_id` is optional — if null, schedule auto-creates sessions without targets (engineer specifies targets on the pending sessions)
|
||||
- `next_run_at` is computed from `cron_expression` + `timezone` at creation/update
|
||||
|
||||
---
|
||||
|
||||
### Sessions — batch tracking fields (new columns)
|
||||
|
||||
```sql
|
||||
batch_id UUID -- all sessions from one batch launch share this value
|
||||
target_label VARCHAR(255) -- e.g. "RDS-01"
|
||||
```
|
||||
|
||||
- `batch_id` is generated at batch launch time (not per-session)
|
||||
- `target_label` is the label from the target list entry or ad-hoc input
|
||||
|
||||
---
|
||||
|
||||
## Scheduling Engine
|
||||
|
||||
**APScheduler** runs in-process with the FastAPI backend (async scheduler).
|
||||
|
||||
On startup:
|
||||
1. Load all `is_active=true` maintenance schedules
|
||||
2. Register each as an APScheduler job using its `cron_expression` + `timezone`
|
||||
|
||||
When a schedule fires:
|
||||
1. Resolve target list (`target_list_id` → targets, or empty list if null)
|
||||
2. Generate a new `batch_id`
|
||||
3. Create one `Session` per target with `batch_id`, `target_label`, status `pending`
|
||||
4. Update `last_run_at`, compute and update `next_run_at`
|
||||
5. Create in-app notification: "Maintenance run ready: [Flow Name] — N sessions created"
|
||||
|
||||
Schedule changes (create/update/disable) are applied to APScheduler immediately via the API.
|
||||
|
||||
---
|
||||
|
||||
## Batch Launch (Ad-hoc)
|
||||
|
||||
Triggered from the maintenance flow detail page. Engineer picks target list via modal with four tabs:
|
||||
|
||||
| Tab | Description |
|
||||
|-----|-------------|
|
||||
| **Saved List** | Pick from team's saved target lists |
|
||||
| **Previous Run** | Browse this flow's past batches, re-use that target list |
|
||||
| **Manual Entry** | Paste/type server names (one per line) |
|
||||
| **PSA/RMM Import** | Placeholder — "Coming soon" |
|
||||
|
||||
After confirming, engineer sees a preview: "Will create N sessions for: RDS-01, RDS-02..."
|
||||
|
||||
On confirm: creates N sessions with shared `batch_id`, status `pending`.
|
||||
|
||||
---
|
||||
|
||||
## UI / UX
|
||||
|
||||
### Sidebar
|
||||
|
||||
```
|
||||
All Flows [total]
|
||||
Troubleshooting [count]
|
||||
Projects [count]
|
||||
Maintenance [count] ← new
|
||||
```
|
||||
|
||||
Links to `/trees?type=maintenance`.
|
||||
|
||||
### TreeLibraryPage
|
||||
|
||||
- `typeFilter` expands to `'all' | 'troubleshooting' | 'procedural' | 'maintenance'`
|
||||
- Maintenance flows show a distinct badge (wrench icon, amber accent color)
|
||||
|
||||
### Flow Editor
|
||||
|
||||
- New flow type selector includes "Maintenance"
|
||||
- Uses the same `ProceduralEditorPage` — no new editor needed
|
||||
|
||||
### Maintenance Flow Detail Page (`/flows/:id/maintenance`)
|
||||
|
||||
New page shown when opening a maintenance flow (via `getTreeNavigatePath`). Sections:
|
||||
- **Overview** — name, description, steps summary
|
||||
- **Schedule panel** — set/edit/disable cron schedule, timezone, assigned target list
|
||||
- **Batch Launch button** — opens target list modal
|
||||
- **Run history** — past batches grouped by `batch_id`, status rollup (e.g., "6/8 complete")
|
||||
|
||||
### Sessions Page — Batch View
|
||||
|
||||
Sessions with a shared `batch_id` collapsed into a single row:
|
||||
- Flow name, launch date, target count, completion status
|
||||
- Expand to see individual target sessions
|
||||
|
||||
### Target Lists Settings (`/account/target-lists`)
|
||||
|
||||
New page under Team settings. Engineers can:
|
||||
- Create a named target list with target entries (label + optional notes)
|
||||
- Edit / delete existing lists
|
||||
- See last-used date per list
|
||||
|
||||
### Routing
|
||||
|
||||
`getTreeNavigatePath()` in `@/lib/routing` gains `'maintenance'` case → `/flows/:id/maintenance`.
|
||||
|
||||
Individual session execution from the detail page still uses `ProceduralNavigationPage`.
|
||||
|
||||
---
|
||||
|
||||
## Rollout Phases
|
||||
|
||||
| Phase | Scope |
|
||||
|-------|-------|
|
||||
| 1 — DB + API | Alembic migration, model changes, target_lists + schedules endpoints, batch session creation API |
|
||||
| 2 — Core UI | Sidebar entry, type filter, flow badge, maintenance detail page, batch launch modal |
|
||||
| 3 — Scheduler | APScheduler integration, auto-session creation, in-app notifications |
|
||||
| 4 — Target Lists | Saved lists settings page under Team settings |
|
||||
|
||||
Each phase is independently shippable without breaking existing flows.
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
- `test_maintenance_tree_type.py` — CRUD, check constraint, filter by type
|
||||
- `test_target_lists.py` — create/list/update/delete, team scoping
|
||||
- `test_maintenance_schedules.py` — create/update/disable, `next_run_at` calculation, schedule fires + creates correct batch sessions
|
||||
- `test_batch_sessions.py` — correct session count, shared `batch_id`, `target_label` values, re-use previous session targets
|
||||
- Frontend: `npm run build` after each phase
|
||||
|
||||
---
|
||||
|
||||
## Future
|
||||
|
||||
- PSA/RMM import (ConnectWise, Kaseya) for target lists — Phase 4 roadmap item
|
||||
- Patch window constraints (maintenance flows only run within defined windows)
|
||||
- Per-target session results dashboard
|
||||
2390
docs/plans/archive/2026-02-17-maintenance-flows-plan.md
Normal file
2390
docs/plans/archive/2026-02-17-maintenance-flows-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
258
docs/plans/archive/2026-02-18-canvas-ux-fixes-design.md
Normal file
258
docs/plans/archive/2026-02-18-canvas-ux-fixes-design.md
Normal file
@@ -0,0 +1,258 @@
|
||||
# Canvas UX Fixes — Design Document
|
||||
|
||||
**Date:** 2026-02-18
|
||||
**Branch:** `feature/tree-editor-canvas`
|
||||
**Status:** Approved, pending implementation
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
The new TreeCanvas editor (Phase 1–4) was tested and three UX problems were identified:
|
||||
|
||||
1. **Scroll**: Expanded card forms have no height limit — long forms are cut off and unreachable
|
||||
2. **Busy forms**: Inline hint text (`<p className="text-xs">`) inside NodeForm components creates visual clutter
|
||||
3. **Answer stubs**: When building a decision node, users must immediately pick a child node type — there's no way to sketch out answer options first and decide types later
|
||||
|
||||
All three fixes apply exclusively to the canvas editor. No session, navigation, backend session-saving, or procedural flow code is affected.
|
||||
|
||||
---
|
||||
|
||||
## Fix 1: Card Scroll
|
||||
|
||||
### Problem
|
||||
|
||||
`TreeCanvasNode.tsx` renders the expanded editing area as an unbounded `<div>`. On decision nodes with many options, or on any node when the browser viewport is short, the card overflows off-screen. There is no scrollbar — content is unreachable. Tab cycling doesn't scroll the canvas to bring hidden fields into view.
|
||||
|
||||
### Design
|
||||
|
||||
Apply `max-h-[70vh] overflow-y-auto` to the expanded editing `<div>` inside `TreeCanvasNode.tsx`.
|
||||
|
||||
Make the save/cancel header row sticky (`sticky top-0 z-10 bg-card`) so the action buttons are always visible when the user scrolls the form content.
|
||||
|
||||
**Files changed:**
|
||||
- `frontend/src/components/tree-editor/TreeCanvasNode.tsx`
|
||||
- Add `max-h-[70vh] overflow-y-auto` to the expanded area `<div>` (currently `border-t border-border px-3 pb-3 pt-3`)
|
||||
- Add `sticky top-0 z-10 bg-card` to the card header `<div>` containing the save/cancel row when in expanded state
|
||||
|
||||
**No other files affected.**
|
||||
|
||||
---
|
||||
|
||||
## Fix 2: Info Tooltips
|
||||
|
||||
### Problem
|
||||
|
||||
`NodeFormDecision.tsx`, `NodeFormAction.tsx`, and `NodeFormResolution.tsx` each contain `<p className="mb-1 text-xs text-muted-foreground">` hint paragraphs below field labels. These add vertical height and visual noise inside a card that's already compact.
|
||||
|
||||
Examples of the current hint text:
|
||||
- "Supports markdown: **bold**, *italic*, - lists, 1. numbered lists, \`code\`"
|
||||
- "PowerShell or CLI commands to execute"
|
||||
- "Step-by-step instructions for resolving the issue"
|
||||
|
||||
### Design
|
||||
|
||||
Replace each hint `<p>` with a small `ⓘ` icon placed inline next to the field label. The icon shows a tooltip on hover containing the same text.
|
||||
|
||||
**Tooltip implementation:**
|
||||
|
||||
Use `title=""` on the icon element for a native browser tooltip. No third-party tooltip library needed — keeps the implementation minimal and consistent with the existing codebase pattern (the validation badge already uses `title={nodeErrors.map(...).join('\n')}`).
|
||||
|
||||
```tsx
|
||||
// Before
|
||||
<label className="block text-sm font-medium text-foreground">
|
||||
Description
|
||||
</label>
|
||||
<p className="mb-1 text-xs text-muted-foreground">
|
||||
Supports markdown: **bold**, *italic*, - lists, 1. numbered lists, `code`
|
||||
</p>
|
||||
|
||||
// After
|
||||
<label className="flex items-center gap-1.5 text-sm font-medium text-foreground">
|
||||
Description
|
||||
<span
|
||||
className="inline-flex items-center justify-center h-3.5 w-3.5 rounded-full border border-muted-foreground/40 text-[9px] text-muted-foreground cursor-help"
|
||||
title="Supports markdown: **bold**, *italic*, - lists, 1. numbered lists, `code`"
|
||||
>
|
||||
i
|
||||
</span>
|
||||
</label>
|
||||
```
|
||||
|
||||
**Files changed:**
|
||||
- `frontend/src/components/tree-editor/NodeFormDecision.tsx` — remove help_text hint `<p>`, replace with `ⓘ` on `Help Text` label
|
||||
- `frontend/src/components/tree-editor/NodeFormAction.tsx` — remove description markdown hint `<p>` and commands hint `<p>`, add `ⓘ` on those labels
|
||||
- `frontend/src/components/tree-editor/NodeFormResolution.tsx` — remove description markdown hint `<p>` and steps hint `<p>`, add `ⓘ` on those labels
|
||||
|
||||
---
|
||||
|
||||
## Fix 3: Answer Stubs (New `answer` Node Type)
|
||||
|
||||
### Problem
|
||||
|
||||
Decision nodes require the user to pick child node types at the same time they're creating the decision. This is backwards — you naturally know the answer options before you know what each one should do. The NodePicker in NodeFormDecision forces a concrete type selection (decision / action / solution) or leaves the option disconnected (`next_node_id: null`).
|
||||
|
||||
Users want to type answer labels first, see those answers appear as placeholder cards in the canvas, and then click each placeholder to assign a type and fill in details.
|
||||
|
||||
### Design
|
||||
|
||||
Introduce `'answer'` as a new internal NodeType that represents a typed-but-unresolved branch placeholder. Answer nodes are:
|
||||
- Created when a user types an answer label in the decision node form
|
||||
- Shown in the canvas as a dashed-border stub card with the answer label
|
||||
- Clickable to open a type picker (decision / action / solution) and convert to a real node
|
||||
- **Not publishable** — blocked by backend and frontend validation on publish
|
||||
|
||||
Answer nodes persist to draft saves so users don't lose their sketch when they navigate away.
|
||||
|
||||
### Data Model
|
||||
|
||||
**`frontend/src/types/tree.ts`**
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
export type NodeType = 'decision' | 'action' | 'solution'
|
||||
|
||||
// After
|
||||
export type NodeType = 'decision' | 'action' | 'solution' | 'answer'
|
||||
```
|
||||
|
||||
`TreeStructure` interface: no new fields needed. Answer nodes use:
|
||||
- `id`: auto-generated UUID (same as other nodes)
|
||||
- `type`: `'answer'`
|
||||
- `title`: the answer label text (e.g. "Server", "Desktop")
|
||||
- No other fields required
|
||||
|
||||
### NodeFormDecision Redesign
|
||||
|
||||
Replace the current options UI (NodePicker per option → picks existing or creates new) with a two-zone layout:
|
||||
|
||||
**Zone 1 — Answer Labels**
|
||||
A simple list of text inputs, one per answer option. Each input edits `options[i].label`. Add/remove/reorder via `DynamicArrayField` (already available).
|
||||
|
||||
No `next_node_id` selection here. When the user saves, for each option that has a label but no `next_node_id`, a new answer-type stub node is created and linked automatically.
|
||||
|
||||
```
|
||||
Options (answer labels):
|
||||
[ Server ] [×]
|
||||
[ Desktop ] [×]
|
||||
[ + Add Answer ]
|
||||
```
|
||||
|
||||
**Zone 2 — (removed) NodePicker per option**
|
||||
The per-option NodePicker is removed entirely. The canvas becomes the way to traverse to a child and set its type.
|
||||
|
||||
### TreeCanvas Changes
|
||||
|
||||
**Rendering answer nodes:**
|
||||
|
||||
When a node has `type === 'answer'`, render an `AnswerStubCard` instead of a full `TreeCanvasNode`. The stub card:
|
||||
- Dashed border: `border-2 border-dashed border-border`
|
||||
- Colored left accent: none (neutral/muted)
|
||||
- Shows the answer label (`node.title`) centered
|
||||
- Shows a "+ Choose Type" label below the title
|
||||
- On click: opens an inline type picker (3 buttons: Decision / Action / Solution)
|
||||
- On type selection: calls `updateNode(node.id, { type: selectedType })` and immediately expands the node for editing
|
||||
|
||||
**New component:** `frontend/src/components/tree-editor/AnswerStubCard.tsx`
|
||||
|
||||
Props:
|
||||
```typescript
|
||||
interface AnswerStubCardProps {
|
||||
node: TreeStructure // type === 'answer'
|
||||
fromOption?: string // the answer label (same as node.title)
|
||||
onSelectType: (nodeId: string, type: 'decision' | 'action' | 'solution') => void
|
||||
}
|
||||
```
|
||||
|
||||
### Stub Creation Logic (TreeCanvas)
|
||||
|
||||
When a decision node is saved (`onSave`), the canvas compares options before/after:
|
||||
|
||||
For each option in the saved node:
|
||||
- If `option.next_node_id` is null/undefined → create a new answer stub node with `title = option.label` and link `option.next_node_id` to its ID.
|
||||
- If `option.next_node_id` already points to a node → leave it.
|
||||
|
||||
This creation logic lives in `TreeCanvas.tsx`'s `handleNodeSave()` function, which already handles pending link resolution.
|
||||
|
||||
### Backend Validation
|
||||
|
||||
**`backend/app/core/tree_validation.py`**
|
||||
|
||||
`_validate_node()` currently rejects unknown node types:
|
||||
```python
|
||||
if node_type not in ('decision', 'action', 'solution'):
|
||||
errors.append(...)
|
||||
```
|
||||
|
||||
Changes:
|
||||
1. Allow `'answer'` type through without structural validation (answer nodes have no required fields beyond `id` and `type`).
|
||||
2. Add a publish-time check in `can_publish_tree()` (or in `validate_tree_structure()` before publish): if any node has `type == 'answer'`, reject with a clear message: `"Answer placeholders must be resolved to a node type before publishing."`
|
||||
3. The draft save endpoint (`PUT /trees/:id`) does not call `can_publish_tree()`, so draft saves continue to work with answer nodes present.
|
||||
|
||||
### Frontend Publish Guard
|
||||
|
||||
In `TreeEditorPage.tsx`, before calling the publish API, add a check:
|
||||
```typescript
|
||||
const hasAnswerNodes = findAllAnswerNodes(tree.tree_structure).length > 0
|
||||
if (hasAnswerNodes) {
|
||||
// Show toast or inline error: "Resolve all answer placeholders before publishing."
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
`findAllAnswerNodes` is a simple recursive traversal (can be a small utility function in `TreeCanvas.tsx` or a new file `lib/treeUtils.ts`).
|
||||
|
||||
### Visual Design (AnswerStubCard)
|
||||
|
||||
```
|
||||
┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
|
||||
Server
|
||||
[ ? Decision ] [ ⚡ Action ] [ ✓ Solution ]
|
||||
└ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘
|
||||
```
|
||||
|
||||
- Card: `min-w-[180px] max-w-[280px] rounded-xl border-2 border-dashed border-border bg-card/50`
|
||||
- Title: `text-sm font-heading font-medium text-foreground text-center py-2 px-3`
|
||||
- Type picker row (default state — not yet clicked): `text-xs text-muted-foreground text-center pb-2 cursor-pointer hover:text-foreground`
|
||||
- Clicking the card reveals three compact buttons for type selection
|
||||
- Type picker (expanded): three small buttons side-by-side in the card footer
|
||||
|
||||
---
|
||||
|
||||
## Files Changed Summary
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `frontend/src/components/tree-editor/TreeCanvasNode.tsx` | Fix 1: max-h + overflow-y + sticky header |
|
||||
| `frontend/src/components/tree-editor/NodeFormDecision.tsx` | Fix 2: ⓘ tooltip on help_text; Fix 3: replace NodePicker with answer label list |
|
||||
| `frontend/src/components/tree-editor/NodeFormAction.tsx` | Fix 2: ⓘ tooltips on description + commands fields |
|
||||
| `frontend/src/components/tree-editor/NodeFormResolution.tsx` | Fix 2: ⓘ tooltips on description + steps fields |
|
||||
| `frontend/src/components/tree-editor/TreeCanvas.tsx` | Fix 3: stub creation in handleNodeSave; AnswerStubCard rendering |
|
||||
| `frontend/src/components/tree-editor/AnswerStubCard.tsx` | Fix 3: NEW — dashed stub card with inline type picker |
|
||||
| `frontend/src/types/tree.ts` | Fix 3: add `'answer'` to NodeType union |
|
||||
| `backend/app/core/tree_validation.py` | Fix 3: allow 'answer' in draft; block on publish |
|
||||
| `frontend/src/pages/TreeEditorPage.tsx` | Fix 3: frontend publish guard |
|
||||
|
||||
---
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- No changes to session navigation, procedural flows, or maintenance flows
|
||||
- No changes to the Code mode editor
|
||||
- No changes to `treeEditorStore.ts` store actions (addNode, updateNode, deleteNode are used as-is)
|
||||
- No third-party tooltip library
|
||||
- No new backend endpoints
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
1. Open a troubleshooting tree in the canvas editor
|
||||
2. Click a decision node → card expands, form is scrollable with sticky save/cancel header
|
||||
3. Field labels show `ⓘ` icons; hovering reveals the hint text
|
||||
4. Type answer labels in the Options section; click ✓ to save
|
||||
5. Answer stub cards appear as dashed cards below the decision node
|
||||
6. Click a stub card → type picker appears; select "Decision" → card converts and expands for editing
|
||||
7. Draft save works with answer nodes present (no backend error)
|
||||
8. Attempt to publish with unresolved answer nodes → blocked with a clear error message
|
||||
9. `npm run build` passes with no TypeScript errors
|
||||
1003
docs/plans/archive/2026-02-18-canvas-ux-fixes-impl.md
Normal file
1003
docs/plans/archive/2026-02-18-canvas-ux-fixes-impl.md
Normal file
File diff suppressed because it is too large
Load Diff
155
docs/plans/archive/2026-02-18-feedback-form-design.md
Normal file
155
docs/plans/archive/2026-02-18-feedback-form-design.md
Normal file
@@ -0,0 +1,155 @@
|
||||
# Feedback Form — Design Document
|
||||
|
||||
> **Date:** 2026-02-18
|
||||
> **Revised:** 2026-02-18 — added DB persistence, feedback type helper text, confirmation email, future TODO notes
|
||||
|
||||
## Overview
|
||||
|
||||
A feedback form page where logged-in users can submit bug reports, feature requests, and general feedback. Submissions are persisted to a `feedback` database table and emailed to a configurable address via the existing Resend infrastructure. A confirmation email is sent back to the submitter.
|
||||
|
||||
## Feedback Types
|
||||
|
||||
| Type | Helper Text |
|
||||
|------|-------------|
|
||||
| Bug Report | Something is broken or not working as expected |
|
||||
| Feature Request | An idea for something new you'd like to see |
|
||||
| Usability Issue | Something works but is confusing or hard to use |
|
||||
| Documentation | Feedback on help docs, tooltips, or in-app guidance |
|
||||
| General Feedback | Anything else — thoughts, impressions, suggestions |
|
||||
|
||||
## Email Format
|
||||
|
||||
### Admin Notification Email
|
||||
|
||||
**Subject:**
|
||||
```
|
||||
[ResolutionFlow Feedback] Bug Report — 2026-02-18 — ACC-7X3K
|
||||
```
|
||||
|
||||
Where `ACC-7X3K` is the user's account display code.
|
||||
|
||||
**Body:**
|
||||
```
|
||||
Feedback Type: Bug Report
|
||||
Submitted By: engineer@example.com
|
||||
Account: Contoso IT Services (ACC-7X3K)
|
||||
Date: February 18, 2026
|
||||
|
||||
---
|
||||
|
||||
User's written feedback text goes here...
|
||||
```
|
||||
|
||||
Reply-to is set to the submitter's email for direct replies.
|
||||
|
||||
### Confirmation Email (to submitter)
|
||||
|
||||
**Subject:** `Thanks for your feedback — ResolutionFlow`
|
||||
|
||||
**Body:** Brief thank you, echoes back the feedback type and a preview of their message (first ~100 chars). Dark-themed HTML matching existing email templates.
|
||||
|
||||
Fire-and-forget: if this email fails, it's logged but doesn't affect the user's submission response.
|
||||
|
||||
## Database
|
||||
|
||||
### `feedback` table
|
||||
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| `id` | UUID | PK |
|
||||
| `account_id` | UUID | FK to `accounts`, nullable, SET NULL on delete |
|
||||
| `user_id` | UUID | FK to `users`, SET NULL on delete |
|
||||
| `email` | String(255) | The reply-to email submitted |
|
||||
| `feedback_type` | String(50) | Enum value |
|
||||
| `message` | Text | Full feedback text |
|
||||
| `created_at` | DateTime(tz) | Timestamp |
|
||||
|
||||
Indexes on `account_id`, `user_id`, `created_at`.
|
||||
|
||||
No admin view or API read endpoints — queryable directly in the DB when needed.
|
||||
|
||||
**Important:** DB write happens *before* email sending. Email failure does NOT prevent the feedback from being saved.
|
||||
|
||||
## Frontend
|
||||
|
||||
### Page
|
||||
|
||||
`FeedbackPage.tsx` — form page inside the app shell.
|
||||
|
||||
### Access Points
|
||||
|
||||
- Sidebar nav item (icon + "Feedback" label) — visible to all roles
|
||||
- Link card on `AccountSettingsPage`
|
||||
|
||||
### Route
|
||||
|
||||
`/feedback` — top-level app shell route (not nested under `/account`).
|
||||
|
||||
### Form Fields
|
||||
|
||||
| Field | Details |
|
||||
|-------|---------|
|
||||
| Email | Auto-filled from logged-in user, editable |
|
||||
| Feedback Type | Custom dropdown with description text per option |
|
||||
| Message | Textarea, required, min 10 chars |
|
||||
| Submit | `bg-gradient-brand`, disabled while submitting |
|
||||
|
||||
### UX Flow
|
||||
|
||||
1. Form loads with email pre-filled
|
||||
2. User opens feedback type dropdown — sees label + helper description for each option
|
||||
3. User selects type, writes message, submits
|
||||
4. Button shows loading state during submission
|
||||
5. On success: success message with confirmation email note, form resets
|
||||
6. On error: inline error, form stays populated for retry
|
||||
|
||||
### Styling
|
||||
|
||||
Standard page layout — `container mx-auto`, `bg-card border border-border rounded-xl` form card, `max-w-2xl` width.
|
||||
|
||||
### API Client
|
||||
|
||||
`feedbackApi.submit()` in a new `api/feedback.ts` module.
|
||||
|
||||
## Backend
|
||||
|
||||
### Endpoint
|
||||
|
||||
`POST /feedback` in `endpoints/feedback.py`
|
||||
|
||||
### Schema
|
||||
|
||||
`FeedbackSubmission` in `schemas/feedback.py`:
|
||||
- `email: EmailStr` — validated as email
|
||||
- `feedback_type: FeedbackType` — enum-validated against the 5 types
|
||||
- `message: str` — min 10 chars, max 5000 chars
|
||||
|
||||
### Auth
|
||||
|
||||
Requires `get_current_active_user`. Account display code pulled from user's account relationship server-side.
|
||||
|
||||
### Config
|
||||
|
||||
`FEEDBACK_EMAIL: Optional[str] = None` in `config.py`. Endpoint returns 503 if not configured.
|
||||
|
||||
### Email Service
|
||||
|
||||
Two new static methods on `EmailService`:
|
||||
- `send_feedback_email()` — admin notification with reply-to
|
||||
- `send_feedback_confirmation_email()` — thank-you to submitter (fire-and-forget)
|
||||
|
||||
Both use existing Resend client and dark-themed HTML matching existing email templates.
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
One submission per minute per user.
|
||||
|
||||
## Future Consideration
|
||||
|
||||
**Post-session contextual feedback prompt** — do NOT build now. TODO comments in `FeedbackPage.tsx` and `endpoints/feedback.py` serve as breadcrumbs. Concept: after completing a troubleshooting session, show a subtle inline prompt that opens a lightweight version of the feedback form pre-tagged with tree/session context. The feedback infrastructure (DB table, email service, API endpoint) built here should be directly reusable.
|
||||
|
||||
## Not Included (YAGNI)
|
||||
|
||||
- No admin view for feedback
|
||||
- No file attachments
|
||||
- No public (unauthenticated) access
|
||||
1094
docs/plans/archive/2026-02-18-feedback-form-implementation.md
Normal file
1094
docs/plans/archive/2026-02-18-feedback-form-implementation.md
Normal file
File diff suppressed because it is too large
Load Diff
235
docs/plans/archive/2026-02-18-flow-editor-react-flow-design.md
Normal file
235
docs/plans/archive/2026-02-18-flow-editor-react-flow-design.md
Normal file
@@ -0,0 +1,235 @@
|
||||
# Flow Editor — React Flow Migration Design
|
||||
|
||||
> **Date:** 2026-02-18
|
||||
> **Scope:** Replace hand-built CSS flexbox canvas with @xyflow/react for zoom, pan, auto-layout, and improved collapse UX
|
||||
|
||||
## Overview
|
||||
|
||||
The current flow editor canvas (`TreeCanvas.tsx`) uses pure CSS flexbox to position nodes. This works for small trees but breaks down with large flows — nodes overlap, there's no zoom/pan, and collapsing subtrees is hard to discover. This design replaces the canvas with React Flow (`@xyflow/react`), adds dagre-based auto-layout, and moves editing to a right-side panel.
|
||||
|
||||
## Problems Solved
|
||||
|
||||
1. **No zoom/pan** — users can only scroll; can't zoom out for a bird's-eye view or zoom into a section
|
||||
2. **Node overlap** — wide trees with many branches cause flexbox lanes to overlap
|
||||
3. **Collapse is hidden** — the subtree collapse toggle is a small icon in the node header, easy to miss
|
||||
4. **Inline editing bloats cards** — expanded cards are huge, disrupting the visual tree layout
|
||||
|
||||
## Architecture
|
||||
|
||||
### Source of Truth
|
||||
|
||||
The Zustand store's `treeStructure` (recursive nested object) remains the single source of truth. No store changes required. The canvas maintains a **derived** flat representation (`Node[]` and `Edge[]`) computed from the tree structure.
|
||||
|
||||
### Data Flow
|
||||
|
||||
```
|
||||
treeStructure (Zustand) → useTreeLayout hook → { nodes, edges } → ReactFlow → renders
|
||||
↓
|
||||
user clicks node → NodeEditorPanel opens
|
||||
user saves edits → updateNode(id, data) → store updates → re-derive
|
||||
```
|
||||
|
||||
### New Dependencies
|
||||
|
||||
- `@xyflow/react` — canvas framework (MIT, 20k+ GitHub stars)
|
||||
- `@dagrejs/dagre` — directed graph layout algorithm
|
||||
- `@types/dagre` — TypeScript types
|
||||
|
||||
## Interactions
|
||||
|
||||
### Zoom
|
||||
|
||||
Ctrl/Cmd + scroll wheel to zoom. Zoom range: 25%–200%. Plain scroll pans vertically (natural page scrolling feel).
|
||||
|
||||
### Pan
|
||||
|
||||
Click and drag on empty canvas space. Plain scroll pans vertically. Middle-click drag also pans.
|
||||
|
||||
### Node Selection
|
||||
|
||||
Single-click on a node body selects it and opens the side panel editor.
|
||||
|
||||
### Subtree Collapse
|
||||
|
||||
Single-click on a visible chevron icon at the bottom edge of any node that has children. Always visible (not behind hover). When collapsed:
|
||||
- Children and their edges are removed from the React Flow graph entirely
|
||||
- A pill below the node shows "N nodes hidden"
|
||||
- Clicking the pill or chevron again expands
|
||||
|
||||
### Zoom Controls
|
||||
|
||||
Small floating toolbar in bottom-left corner: zoom in (+), zoom out (−), fit-to-view. Uses React Flow's built-in `<Controls>` component.
|
||||
|
||||
### Minimap
|
||||
|
||||
Bottom-right corner. Collapsible via a toggle button — user can minimize or close it. Pannable and zoomable (click on minimap to jump to that area). Uses React Flow's built-in `<MiniMap>` component. Node colors in minimap match type accent colors (blue/yellow/green).
|
||||
|
||||
### Fit View
|
||||
|
||||
Auto-fits on initial load and when clicking the fit button. Applies padding so nodes aren't pressed against viewport edges.
|
||||
|
||||
## Custom Node Types
|
||||
|
||||
Four React Flow custom node types, all **compact** (no inline editing):
|
||||
|
||||
| Type | Accent | Icon | Content |
|
||||
|------|--------|------|---------|
|
||||
| `decision` | Blue left border | `HelpCircle` | Question text (1-2 lines), "N options" badge, option labels |
|
||||
| `action` | Yellow left border | `Zap` | Title, description preview (truncated) |
|
||||
| `solution` | Green left border | `CheckCircle` | Title, description preview (truncated) |
|
||||
| `answer` | Dashed border, muted | — | Label + "Choose Type" prompt |
|
||||
|
||||
### Card Specs
|
||||
|
||||
- **Width:** 280px (fixed — gives dagre consistent widths)
|
||||
- **Height:** Variable based on content (~80–120px estimated)
|
||||
- **Selected state:** `ring-1 ring-primary`
|
||||
- **Validation errors:** Red dot badge on nodes with errors
|
||||
- **Collapse chevron:** Visible at bottom of node when it has children
|
||||
|
||||
### Edges
|
||||
|
||||
Smoothstep edges (React Flow built-in) — route around nodes with rounded corners. Color: `border-border`.
|
||||
|
||||
**Edge labels:** Show the option text leading to each child. **Truncated to 35 characters + ellipsis** for long option text (e.g., "User reports intermittent VPN di…"). Full text visible on hover tooltip and in the side panel when the parent decision is selected.
|
||||
|
||||
## Side Panel Editor
|
||||
|
||||
When a node is selected, a right-side editor panel opens. The canvas container **resizes** (shrinks by panel width) rather than the panel overlaying the canvas — this prevents covering the selected node. React Flow handles container resize natively.
|
||||
|
||||
### Panel Specs
|
||||
|
||||
- **Width:** 400px
|
||||
- **Position:** Right side, part of the layout (not floating)
|
||||
- **Open triggers:** Single-click a node
|
||||
- **Close triggers:** X button, Escape key, clicking empty canvas
|
||||
- **Auto-center:** When panel opens, auto-pan so the selected node stays centered in the remaining canvas area (via React Flow's `setCenter` / `fitBounds`)
|
||||
|
||||
### Panel Structure
|
||||
|
||||
- **Header:** Node type icon + badge, node title (or "New Decision"), close button
|
||||
- **Body:** Renders existing form components — `NodeFormDecision`, `NodeFormAction`, `NodeFormResolution`. For `answer` nodes: type picker buttons (Decision/Action/Solution)
|
||||
- **Footer:** Save (`bg-gradient-brand`), Cancel, Delete (with confirmation), Duplicate
|
||||
|
||||
### Draft Model
|
||||
|
||||
Same local-draft-then-commit pattern as current inline editor:
|
||||
- Panel opens with a clone of the node data
|
||||
- Edits modify the draft only
|
||||
- Save writes to Zustand store → triggers re-derive of React Flow nodes/edges
|
||||
- Cancel discards draft and closes panel
|
||||
- Switching nodes while editing prompts save/discard
|
||||
|
||||
### Panel Coexistence
|
||||
|
||||
Only one right panel open at a time. Opening node editor closes metadata panel and vice versa.
|
||||
|
||||
## Layout Engine (Dagre)
|
||||
|
||||
### Configuration
|
||||
|
||||
- Direction: `rankdir: 'TB'` (top-to-bottom)
|
||||
- Node width: 280px
|
||||
- Node height: estimated heuristic (~80px base + content)
|
||||
- Rank separation (vertical gap): ~100px
|
||||
- Node separation (horizontal gap): ~40px
|
||||
|
||||
### Height Measurement Correction
|
||||
|
||||
Dagre needs node heights before rendering, but content varies. Strategy:
|
||||
1. **First pass:** Use heuristic height estimates based on node type and content length
|
||||
2. **After first paint:** Measure actual rendered heights via refs
|
||||
3. **If any height differs by >10px from estimate:** Re-run dagre with actual heights (single correction pass, no infinite loops)
|
||||
|
||||
This avoids visible layout jumps in most cases while catching edge cases like decision nodes with 8+ options.
|
||||
|
||||
### When Re-layout Runs
|
||||
|
||||
| Trigger | Re-layout? |
|
||||
|---------|-----------|
|
||||
| Node added/deleted | Yes |
|
||||
| Node moved (reparented) | Yes |
|
||||
| Options added/removed on a decision (structural change) | Yes |
|
||||
| Content-only edits (title, description text) | No |
|
||||
| Collapse/expand toggle | Yes (different nodes visible) |
|
||||
| Panel open/close | No (React Flow handles container resize) |
|
||||
|
||||
After re-layout, `fitView` is called with padding.
|
||||
|
||||
## File Changes
|
||||
|
||||
### New Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `components/tree-editor/FlowCanvas.tsx` | React Flow canvas — replaces `TreeCanvas.tsx` |
|
||||
| `components/tree-editor/FlowCanvasNode.tsx` | Custom compact node component (decision/action/solution) |
|
||||
| `components/tree-editor/FlowCanvasAnswerNode.tsx` | Custom node for answer stubs |
|
||||
| `components/tree-editor/NodeEditorPanel.tsx` | Right-side editor panel — replaces inline card editing |
|
||||
| `components/tree-editor/useTreeLayout.ts` | Hook: treeStructure → nodes/edges + dagre + measure-correct |
|
||||
| `lib/dagreLayout.ts` | Pure function: positioned nodes from dagre |
|
||||
|
||||
### Modified Files
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `TreeEditorLayout.tsx` | Flow mode renders `FlowCanvas` + `NodeEditorPanel` instead of `TreeCanvas` |
|
||||
| `TreeEditorPage.tsx` | Panel state: node editor vs metadata, single-panel-at-a-time |
|
||||
|
||||
### Unchanged
|
||||
|
||||
- `treeEditorStore.ts` — no store changes needed
|
||||
- `NodeFormDecision.tsx`, `NodeFormAction.tsx`, `NodeFormResolution.tsx` — reused inside panel
|
||||
- `MetadataSidePanel.tsx` — already works, gets single-panel-at-a-time rule
|
||||
- Code mode — completely untouched
|
||||
|
||||
### Removed from Active Flow Mode Path
|
||||
|
||||
- `TreeCanvas.tsx` → replaced by `FlowCanvas.tsx`
|
||||
- `TreeCanvasNode.tsx` → replaced by `FlowCanvasNode.tsx`
|
||||
- `AnswerStubCard.tsx` → logic moves to `FlowCanvasAnswerNode.tsx`
|
||||
|
||||
Old components stay in the repo but are no longer imported in Flow mode.
|
||||
|
||||
## React Flow Configuration
|
||||
|
||||
```tsx
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
onNodeClick={handleNodeClick}
|
||||
onPaneClick={handlePaneClick}
|
||||
fitView
|
||||
minZoom={0.25}
|
||||
maxZoom={2}
|
||||
zoomOnScroll={false} // plain scroll pans
|
||||
zoomOnPinch={true} // trackpad pinch zooms
|
||||
panOnScroll={true} // plain scroll pans vertically
|
||||
panOnScrollMode="vertical"
|
||||
selectionOnDrag={false} // no multi-select
|
||||
nodesDraggable={false} // dagre controls layout
|
||||
nodesConnectable={false} // no edge reconnection
|
||||
proOptions={{ hideAttribution: true }}
|
||||
>
|
||||
<Background variant="dots" gap={24} size={1} color="var(--border)" />
|
||||
<Controls showInteractive={false} />
|
||||
<MiniMap
|
||||
pannable
|
||||
zoomable
|
||||
nodeColor={getNodeColor}
|
||||
style={{ display: minimapVisible ? 'block' : 'none' }}
|
||||
/>
|
||||
</ReactFlow>
|
||||
```
|
||||
|
||||
## Not Included (YAGNI)
|
||||
|
||||
- No drag-to-reparent nodes on canvas
|
||||
- No visual edge reconnection (dragging edges)
|
||||
- No multi-select nodes
|
||||
- No undo/redo on canvas position changes (undo/redo stays on tree data only)
|
||||
- No manual node drag repositioning (dagre controls layout)
|
||||
- No light mode (dark-first design system)
|
||||
1474
docs/plans/archive/2026-02-18-flow-editor-react-flow-impl.md
Normal file
1474
docs/plans/archive/2026-02-18-flow-editor-react-flow-impl.md
Normal file
File diff suppressed because it is too large
Load Diff
1327
docs/plans/archive/2026-02-18-flow-editor-ux-impl.md
Normal file
1327
docs/plans/archive/2026-02-18-flow-editor-ux-impl.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,155 @@
|
||||
# Procedural & Maintenance Editor Redesign - Design Revisions
|
||||
|
||||
> **Date:** 2026-02-19
|
||||
> **Revises:** `docs/plans/2026-02-19-procedural-editor-redesign-design.md`
|
||||
> **Purpose:** Resolve implementation gaps and make the design decision-complete for engineering handoff
|
||||
|
||||
## Summary
|
||||
|
||||
This revision tightens the original design to match current architecture and APIs.
|
||||
It resolves contradictions around maintenance schedule persistence, aligns step-list behavior with store invariants, and defines concrete DnD/accessibility/test expectations.
|
||||
|
||||
## Revision Decisions (Locked)
|
||||
|
||||
1. Keep fixed-height editor layout with independent StepList scrolling.
|
||||
2. Use collapsible sections for Details, Intake Form, and Maintenance Schedule.
|
||||
3. Use existing installed `@dnd-kit/*` packages (no new dependency work).
|
||||
4. Keep `procedure_end` as non-draggable and always last.
|
||||
5. Keep schedule as optional for maintenance flows (manual batch launch remains valid).
|
||||
|
||||
## Critical Corrections to Original Design
|
||||
|
||||
1. **Maintenance schedule persistence**
|
||||
- Schedule is not embedded in `tree_structure`.
|
||||
- Schedule must be persisted through maintenance schedule endpoints.
|
||||
- New unsaved flow requires two-stage save:
|
||||
1. Save tree (`treesApi.create` / `treesApi.update`)
|
||||
2. Create/update schedule (`maintenanceSchedulesApi.create` or `maintenanceSchedulesApi.update`)
|
||||
- One schedule per tree (`uq_maintenance_schedules_tree_id`).
|
||||
|
||||
2. **Step empty-state semantics**
|
||||
- Original "0 steps" state conflicts with current store minimum step behavior.
|
||||
- Revised behavior:
|
||||
- If minimum-one-step invariant remains, remove "0 steps" UX language.
|
||||
- Empty-state is only shown if invariant is intentionally changed in store.
|
||||
|
||||
3. **DnD data model correction**
|
||||
- Do not mention recalculating `display_order` for procedural steps.
|
||||
- Reorder is array-index based only.
|
||||
- Explicitly block dragging `procedure_end`.
|
||||
|
||||
4. **Dependencies section correction**
|
||||
- Replace "New Dependencies" with "Existing Dependencies Used" for `@dnd-kit/core`, `@dnd-kit/sortable`, `@dnd-kit/utilities`.
|
||||
|
||||
5. **File impact correction**
|
||||
- Add API integration surfaces:
|
||||
- `frontend/src/api/maintenanceSchedules.ts`
|
||||
- target-list integration file(s) if inline list creation is in scope
|
||||
- Clarify interaction with `frontend/src/pages/MaintenanceFlowDetailPage.tsx` (retain schedule view/edit there or shift entirely to editor).
|
||||
|
||||
6. **Collapsible behavior clarification**
|
||||
- Use single-open accordion mode by default.
|
||||
- Details defaults expanded for new flows.
|
||||
- Intake defaults collapsed.
|
||||
- Maintenance Schedule defaults:
|
||||
- new maintenance flow: expanded
|
||||
- existing with schedule: collapsed summary
|
||||
- existing without schedule: collapsed with "Set Up" affordance
|
||||
|
||||
7. **A11y + keyboard DnD acceptance**
|
||||
- Include keyboard reorder acceptance criteria.
|
||||
- Include focus order and ARIA labeling criteria for section toggles and drag handles.
|
||||
|
||||
## Revised Maintenance Schedule Section Spec
|
||||
|
||||
### New maintenance flow (tree not yet saved)
|
||||
|
||||
1. Render schedule editor expanded.
|
||||
2. Keep schedule input in local UI draft state.
|
||||
3. On Save Draft / Publish:
|
||||
- Save tree first.
|
||||
- If tree save succeeds, create schedule with resulting `tree_id`.
|
||||
4. If schedule create fails:
|
||||
- Tree save remains successful.
|
||||
- Show actionable error ("Schedule not saved. Retry.").
|
||||
- Preserve schedule draft state.
|
||||
|
||||
### Existing maintenance flow
|
||||
|
||||
1. Load schedule via `maintenanceSchedulesApi.getForTree(treeId)`.
|
||||
2. Edit and persist via `maintenanceSchedulesApi.update(scheduleId, data)`.
|
||||
3. If no schedule exists, show collapsed "No schedule configured" summary + setup button.
|
||||
|
||||
## Revised StepList Reorder Spec
|
||||
|
||||
1. Draggable types:
|
||||
- `procedure_step`
|
||||
- `section_header`
|
||||
2. Non-draggable:
|
||||
- `procedure_end`
|
||||
3. Reorder behavior:
|
||||
- Move item by array index.
|
||||
- Preserve all step payload fields.
|
||||
- No implicit grouped movement under section headers.
|
||||
4. Keyboard behavior:
|
||||
- Drag handle focusable.
|
||||
- Enter/Space pick up + drop.
|
||||
- Arrow keys move while grabbed.
|
||||
- Escape cancels.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. **Layout**
|
||||
- Toolbar remains visible while StepList scrolls.
|
||||
- Details/Intake/Schedule sections collapse without shrinking StepList usability.
|
||||
|
||||
2. **Steps**
|
||||
- New step auto-expands.
|
||||
- New step scrolls into view.
|
||||
- Reorder works by pointer and keyboard.
|
||||
- `procedure_end` remains last and fixed.
|
||||
|
||||
3. **Maintenance schedule**
|
||||
- New unsaved maintenance flow can be saved without schedule.
|
||||
- Schedule can be created immediately after first tree save.
|
||||
- Existing schedule loads and updates in editor.
|
||||
- Schedule failure does not roll back successful tree save.
|
||||
|
||||
4. **Accessibility**
|
||||
- Section toggles keyboard operable.
|
||||
- Drag handles have accessible labels.
|
||||
- Focus remains stable after reorder.
|
||||
|
||||
5. **Build/Test**
|
||||
- `npm run build` succeeds.
|
||||
- Affected component/store tests pass.
|
||||
|
||||
## Updated File Impact
|
||||
|
||||
### Modified
|
||||
|
||||
- `frontend/src/pages/ProceduralEditorPage.tsx`
|
||||
- `frontend/src/components/procedural-editor/StepList.tsx`
|
||||
- `frontend/src/components/procedural-editor/IntakeFormBuilder.tsx`
|
||||
- `frontend/src/store/proceduralEditorStore.ts`
|
||||
- `frontend/src/api/maintenanceSchedules.ts`
|
||||
- `frontend/src/pages/MaintenanceFlowDetailPage.tsx` (if schedule UX ownership changes)
|
||||
|
||||
### New
|
||||
|
||||
- `frontend/src/components/procedural-editor/CollapsibleEditorSection.tsx`
|
||||
- `frontend/src/components/procedural-editor/MaintenanceScheduleSection.tsx`
|
||||
|
||||
### Existing dependencies used
|
||||
|
||||
- `@dnd-kit/core`
|
||||
- `@dnd-kit/sortable`
|
||||
- `@dnd-kit/utilities`
|
||||
|
||||
## Out of Scope (unchanged)
|
||||
|
||||
1. Intake field DnD reorder.
|
||||
2. Procedural undo/redo parity.
|
||||
3. Step templates/presets.
|
||||
4. Bulk step operations.
|
||||
5. Backend schema/model changes for procedural steps.
|
||||
@@ -0,0 +1,247 @@
|
||||
# Procedural & Maintenance Editor Redesign — Design
|
||||
|
||||
> **Date:** 2026-02-19
|
||||
> **Scope:** Restructure the procedural/maintenance flow editor for better space utilization, collapsible sections, drag-to-reorder steps, and maintenance-specific schedule management
|
||||
|
||||
## Overview
|
||||
|
||||
The current procedural editor (`ProceduralEditorPage.tsx`) uses a scrolling document layout where Details, Intake Form, and Steps stack vertically. The Details and Intake Form sections consume significant screen space, pushing the step list — the core editing surface — to the bottom. This redesign converts the page to a fixed-height editor with collapsible sections and drag-to-reorder steps.
|
||||
|
||||
## Problems Solved
|
||||
|
||||
1. **Steps buried at the bottom** — Details and Intake Form sections are "screen space goblins" that push the step list down, especially on smaller screens
|
||||
2. **No drag reorder** — grip handles are visible but non-functional; reordering requires deleting and re-creating steps
|
||||
3. **Adding steps is tedious** — new steps append at the bottom but don't auto-expand, requiring an extra click
|
||||
4. **No overview at a glance** — collapsed sections don't show useful summaries; users expand just to check what's there
|
||||
5. **Maintenance flows lack inline schedule management** — schedule/targets are only configurable on the detail page, not during initial creation
|
||||
|
||||
## Layout Architecture
|
||||
|
||||
### Current Layout (Scrolling Document)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Header (back, title, save, publish) │ ← scrolls with page
|
||||
├─────────────────────────────────────┤
|
||||
│ Details Card (~200px) │ ← always expanded
|
||||
│ Name, Description, Tags, Public │
|
||||
├─────────────────────────────────────┤
|
||||
│ Intake Form Card (~150-400px) │ ← always expanded
|
||||
│ Field editors... │
|
||||
├─────────────────────────────────────┤
|
||||
│ Steps Card (whatever's left) │ ← pushed to bottom
|
||||
│ Step list... │
|
||||
└─────────────────────────────────────┘
|
||||
↕ entire page scrolls
|
||||
```
|
||||
|
||||
### New Layout (Fixed-Height Editor)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Toolbar (sticky) │ ← fixed, never scrolls
|
||||
├─────────────────────────────────────┤
|
||||
│ ▶ Details: "DC Build" · 3 tags · … │ ← collapsed one-liner
|
||||
│ ▶ Intake Form: 3 fields: Host, … │ ← collapsed one-liner
|
||||
│ ▶ Schedule: Mon 2:00 AM · 5 targets│ ← maintenance only
|
||||
├─────────────────────────────────────┤
|
||||
│ │
|
||||
│ Steps (flex-1, scrolls alone) │ ← gets all remaining height
|
||||
│ ┌─ 1. Check prerequisites ───────┐│
|
||||
│ ├─ 2. Install AD DS role ────────┤│
|
||||
│ ├─ 3. Promote to DC ────────────┤│
|
||||
│ ├─ 4. Verify replication ───────┤│
|
||||
│ └─ + Add Step ───────────────────┘│
|
||||
│ │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Key Layout Changes
|
||||
|
||||
- **Page:** `container mx-auto` scrolling → `flex flex-col h-full overflow-hidden`
|
||||
- **Toolbar:** Scrolls with page → sticky at top (matches troubleshooting editor pattern)
|
||||
- **Sections:** Always-expanded cards → collapsible one-liners with rich summaries
|
||||
- **Step list:** Stacked in scroll flow → `flex-1 overflow-y-auto` (independent scrolling)
|
||||
|
||||
## Collapsible Sections
|
||||
|
||||
### Shared Wrapper: `CollapsibleEditorSection`
|
||||
|
||||
A reusable component used by Details, Intake Form, and Maintenance Schedule.
|
||||
|
||||
**Props:**
|
||||
- `title` — section label ("Details", "Intake Form", "Schedule")
|
||||
- `icon` — Lucide icon component
|
||||
- `summary` — rich one-line summary string shown when collapsed
|
||||
- `defaultExpanded` — whether to start expanded (default: `false`)
|
||||
- `children` — expanded content
|
||||
- `onEdit` — optional callback for the Edit button (alternative to expanding)
|
||||
|
||||
**Collapsed state:** Single row with icon, title, summary text, and expand chevron. Entire row is clickable.
|
||||
|
||||
**Expanded state:** Full content slides down with a subtle animation. Collapse chevron rotates.
|
||||
|
||||
**Accordion mode:** Single-open by default — expanding one section collapses others. Controlled by parent page component.
|
||||
|
||||
**Accessibility:**
|
||||
- Toggle button has `aria-expanded` and `aria-controls` pointing to section content `id`
|
||||
- Content region has matching `id`
|
||||
- Keyboard operable (Enter/Space to toggle)
|
||||
- Focus remains stable after toggle
|
||||
|
||||
### Details Section — Collapsed Summary
|
||||
|
||||
Format: `"Flow Name" · N tags · Public/Private`
|
||||
|
||||
Examples:
|
||||
- `"Domain Controller Build" · 3 tags · Public`
|
||||
- `"New Procedure" · No tags · Private` (new flow, default expanded)
|
||||
|
||||
**New flow behavior:** Details section starts **expanded** when creating a new flow (name is required), collapses after the user provides a name and clicks away or expands another section.
|
||||
|
||||
### Intake Form Section — Collapsed Summary
|
||||
|
||||
Format: `N fields: Field1, Field2, Field3` (field names truncated if many)
|
||||
|
||||
Examples:
|
||||
- `3 fields: Hostname, Domain Name, IP Address`
|
||||
- `6 fields: Hostname, Domain, IP, DNS, Gateway, …` (truncated with ellipsis)
|
||||
- `No fields defined` (empty state)
|
||||
|
||||
### Maintenance Schedule Section
|
||||
|
||||
Only renders when `treeType === 'maintenance'`.
|
||||
|
||||
**New flow (no schedule):** Section starts expanded with:
|
||||
- Cron expression builder (frequency picker: daily/weekly/monthly + time + timezone)
|
||||
- Target list selector (dropdown of saved target lists, or create new inline)
|
||||
- These fields write to local UI draft state (NOT tree_structure)
|
||||
- On save: tree saved first, then schedule created via `maintenanceSchedulesApi.create` with resulting `tree_id`
|
||||
- If schedule create fails: tree save remains successful, show actionable error, preserve draft
|
||||
|
||||
**Existing flow (has schedule):** Collapsed summary:
|
||||
- Format: `"Every Monday at 2:00 AM UTC · 5 targets"`
|
||||
- Expand to modify schedule and targets
|
||||
|
||||
**Existing flow (no schedule yet):** Shows collapsed with summary `"No schedule configured"` + "Set Up" button that expands the section.
|
||||
|
||||
## Step List Improvements
|
||||
|
||||
### Empty State
|
||||
|
||||
When the step list has 0 steps, show a centered empty state instead of a blank area:
|
||||
|
||||
```
|
||||
┌──────────────────────────┐
|
||||
│ 📋 │
|
||||
│ Add your first step │
|
||||
│ │
|
||||
│ Steps define the actions │
|
||||
│ engineers follow during │
|
||||
│ this procedure. │
|
||||
│ │
|
||||
│ [+ Add Step] [+ Section]│
|
||||
└──────────────────────────┘
|
||||
```
|
||||
|
||||
When 1-2 steps exist, the list renders normally — no special treatment needed since the steps themselves fill the space adequately.
|
||||
|
||||
### Step Count + Time in Header
|
||||
|
||||
The Steps section header shows aggregate info:
|
||||
|
||||
- `Steps (4 steps · ~25 min estimated)` — when steps have time estimates
|
||||
- `Steps (4 steps)` — when no time estimates set
|
||||
- `Steps (0 steps)` — empty state (note: store currently enforces minimum one `procedure_step`, so 0-step state only appears if invariant is intentionally changed)
|
||||
|
||||
### Auto-Expand New Steps
|
||||
|
||||
When `addStep()` is called, the new step is automatically expanded (`setExpandedStepId(newStep.id)`) and the step list scrolls to the bottom to show it. This eliminates the extra click to start editing.
|
||||
|
||||
Same for `addSectionHeader()` — auto-expand for immediate title editing.
|
||||
|
||||
### Drag-to-Reorder
|
||||
|
||||
**Library:** `@dnd-kit/core` + `@dnd-kit/sortable`
|
||||
|
||||
**Behavior:**
|
||||
- Drag via the `GripVertical` handle on each step card
|
||||
- Dragged card lifts with `shadow-lg` and slight scale
|
||||
- Drop target: blue insertion line between steps
|
||||
- Section headers are draggable — moving a section header moves it independently (steps below stay in place)
|
||||
- On drop: update the store's step array order (array-index based only, no `display_order` recalculation)
|
||||
- Keyboard accessible: focus grip handle, Enter/Space to pick up, arrow keys to move, Enter to drop, Escape to cancel
|
||||
|
||||
**Implementation:**
|
||||
- Wrap step list in `<DndContext>` + `<SortableContext>`
|
||||
- Each step card wrapped in `useSortable()` hook
|
||||
- Drag overlay shows a simplified card (just step number + title)
|
||||
- `onDragEnd` handler reorders the `steps` array in the procedural editor store
|
||||
|
||||
### Collapsed Step Cards
|
||||
|
||||
Current cards are already compact. Minor tightening:
|
||||
- Step number badge + content type icon + title + time estimate + chevron + delete (on hover)
|
||||
- No changes to the collapsed card layout — it's already well-designed
|
||||
|
||||
## Toolbar
|
||||
|
||||
Matches the troubleshooting editor's toolbar pattern:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ ← Back Edit Procedure — DC Build [Unsaved] │ Save │ Publish │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **Left:** Back button (→ `/my-trees`), flow type icon, title with flow name
|
||||
- **Right:** Unsaved indicator, Save Draft button, Publish button (gradient)
|
||||
- **Sticky:** `sticky top-0 z-10` within the editor area (not the app shell)
|
||||
|
||||
Maintenance flows show `Wrench` icon + "Edit Maintenance Flow" title.
|
||||
|
||||
## File Changes
|
||||
|
||||
### Modified Files
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `ProceduralEditorPage.tsx` | Layout restructure: scrolling → fixed-height, collapsible sections, toolbar refactor |
|
||||
| `StepList.tsx` | Drag reorder with @dnd-kit, auto-expand on add, empty state, step count header |
|
||||
| `IntakeFormBuilder.tsx` | Wrap in collapsible section with field-name summary |
|
||||
| `proceduralEditorStore.ts` | `reorderSteps(fromIndex, toIndex)` action, auto-expand on add |
|
||||
|
||||
### New Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `components/procedural-editor/CollapsibleEditorSection.tsx` | Shared collapsible wrapper with summary display |
|
||||
| `components/procedural-editor/MaintenanceScheduleSection.tsx` | Schedule builder + collapsed summary for maintenance flows |
|
||||
|
||||
### Existing Dependencies Used
|
||||
|
||||
- `@dnd-kit/core` — drag-and-drop framework (already installed)
|
||||
- `@dnd-kit/sortable` — sortable preset for ordered lists (already installed)
|
||||
- `@dnd-kit/utilities` — CSS utilities for transforms (already installed)
|
||||
|
||||
### Existing APIs Used
|
||||
|
||||
- `frontend/src/api/maintenanceSchedules.ts` — schedule CRUD via separate endpoints (NOT tree_structure)
|
||||
- `frontend/src/api/targetLists.ts` — target list selection for schedules
|
||||
|
||||
### Unchanged
|
||||
|
||||
- `StepEditor.tsx` — inline step editing form, no changes
|
||||
- `IntakeFieldEditor.tsx` — field editor, no changes
|
||||
- `proceduralEditorStore.ts` steps/intakeForm data model — no schema changes
|
||||
- Backend — no API changes needed
|
||||
- Troubleshooting tree editor — completely separate, unaffected
|
||||
|
||||
## Not Included (YAGNI)
|
||||
|
||||
- No drag-to-reorder intake form fields (low value, fields rarely reordered)
|
||||
- No inline cron expression text input (use friendly frequency picker instead)
|
||||
- No step templates or presets
|
||||
- No bulk step operations (select multiple, delete, move)
|
||||
- No step preview/dry-run from the editor
|
||||
- No undo/redo for the procedural editor (separate effort)
|
||||
@@ -0,0 +1,225 @@
|
||||
# Procedural Editor Redesign - Implementation Revisions
|
||||
|
||||
> **Date:** 2026-02-19
|
||||
> **Revises:** `docs/plans/2026-02-19-procedural-editor-redesign-impl.md`
|
||||
> **Related Design Revision:** `docs/plans/2026-02-19-procedural-editor-redesign-design-revisions.md`
|
||||
|
||||
## Goal
|
||||
|
||||
Revise the implementation plan so it matches actual architecture and APIs, with explicit handling for maintenance schedule persistence, step-list invariants, DnD constraints, and accessibility/test requirements.
|
||||
|
||||
## Critical Corrections from Original Impl Plan
|
||||
|
||||
1. Do not treat maintenance schedule as part of `tree_structure`.
|
||||
2. Do not use `display_order` for procedural step reorder.
|
||||
3. Do not assume `0 steps` state unless store invariant is changed intentionally.
|
||||
4. Do not list `@dnd-kit/*` as new dependency (already installed).
|
||||
5. Add explicit save orchestration for unsaved maintenance flows.
|
||||
6. Add explicit failure handling when tree save succeeds but schedule save fails.
|
||||
|
||||
## Phase 0: Scope Lock
|
||||
|
||||
### Task 0.1 - Confirm invariants and UX ownership
|
||||
|
||||
**Decisions to lock in code before implementation:**
|
||||
1. `procedure_end` remains fixed, non-draggable, last.
|
||||
2. Minimum one `procedure_step` remains enforced (recommended).
|
||||
3. Schedule editing in editor is source-of-truth for create/edit, with detail page as display/secondary entrypoint.
|
||||
|
||||
**Files:** none (decision checkpoint)
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Layout and Collapsible Sections
|
||||
|
||||
### Task 1.1 - Add shared collapsible wrapper
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/components/procedural-editor/CollapsibleEditorSection.tsx`
|
||||
|
||||
**Requirements:**
|
||||
1. Single-row collapsed summary.
|
||||
2. Keyboard-accessible toggle button.
|
||||
3. `aria-expanded`, `aria-controls`, and section `id`.
|
||||
4. Optional `defaultExpanded`.
|
||||
|
||||
### Task 1.2 - Convert ProceduralEditorPage to fixed-height editor
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/pages/ProceduralEditorPage.tsx`
|
||||
|
||||
**Changes:**
|
||||
1. Outer layout becomes `flex h-full flex-col overflow-hidden`.
|
||||
2. Toolbar becomes sticky.
|
||||
3. Details and Intake wrapped in `CollapsibleEditorSection`.
|
||||
4. Steps area becomes `flex-1 min-h-0 overflow-y-auto`.
|
||||
5. Accordion mode: only one section open at a time (explicit state in page component).
|
||||
|
||||
**Summaries:**
|
||||
1. Details: `"Name" - N tags - Public/Private`.
|
||||
2. Intake: `N fields: label1, label2...` (truncate).
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: StepList Behavior and DnD
|
||||
|
||||
### Task 2.1 - Align header/empty behavior with current store invariant
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/procedural-editor/StepList.tsx`
|
||||
- Optional invariant change (if desired): `frontend/src/store/proceduralEditorStore.ts`
|
||||
|
||||
**Required behavior (recommended):**
|
||||
1. Keep minimum one `procedure_step`.
|
||||
2. Remove/unset any `0 steps` UI paths.
|
||||
3. Header shows:
|
||||
- `Steps (N steps - ~M min)` when estimates exist
|
||||
- `Steps (N steps)` otherwise.
|
||||
|
||||
### Task 2.2 - Ensure new step auto-expands + scrolls into view
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/procedural-editor/StepList.tsx`
|
||||
- Verify existing store behavior in `frontend/src/store/proceduralEditorStore.ts`
|
||||
|
||||
**Behavior:**
|
||||
1. On add step/section, expanded editor opens immediately.
|
||||
2. Newly inserted row is scrolled into view via stable element refs (prefer scroll target by id over "scroll to bottom").
|
||||
|
||||
### Task 2.3 - Implement DnD with current model constraints
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/procedural-editor/StepList.tsx`
|
||||
- Modify: `frontend/src/store/proceduralEditorStore.ts` (reuse `moveStep`)
|
||||
|
||||
**Rules:**
|
||||
1. Draggable: `procedure_step`, `section_header`.
|
||||
2. Non-draggable: `procedure_end`.
|
||||
3. Reorder by array index only.
|
||||
4. No `display_order` recalculation for steps.
|
||||
5. Keyboard drag support and visible insertion indicator.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Maintenance Schedule Section (Correct API orchestration)
|
||||
|
||||
### Task 3.1 - Add schedule section component
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/components/procedural-editor/MaintenanceScheduleSection.tsx`
|
||||
- Modify: `frontend/src/pages/ProceduralEditorPage.tsx`
|
||||
|
||||
**Behavior:**
|
||||
1. Render only for `treeType === 'maintenance'`.
|
||||
2. Capture:
|
||||
- cron expression
|
||||
- timezone
|
||||
- target list id
|
||||
3. Collapsed summary:
|
||||
- configured: human-readable cadence + target list status
|
||||
- unconfigured: `No schedule configured`.
|
||||
|
||||
### Task 3.2 - Add schedule draft UI state and save orchestration
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/store/proceduralEditorStore.ts` (UI draft state only)
|
||||
- Modify: `frontend/src/pages/ProceduralEditorPage.tsx`
|
||||
- Use: `frontend/src/api/maintenanceSchedules.ts`
|
||||
|
||||
**Save flow:**
|
||||
1. Save tree first (`create`/`update`).
|
||||
2. If maintenance and schedule draft present:
|
||||
- if existing schedule id: `maintenanceSchedulesApi.update`
|
||||
- else: `maintenanceSchedulesApi.create` with saved tree id.
|
||||
3. If schedule save fails:
|
||||
- keep tree save success
|
||||
- show actionable error toast/banner
|
||||
- preserve schedule draft as dirty.
|
||||
|
||||
### Task 3.3 - Existing flow load
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/pages/ProceduralEditorPage.tsx`
|
||||
|
||||
**Behavior:**
|
||||
1. On edit maintenance flow, fetch schedule via `getForTree(treeId)`.
|
||||
2. 404 = no schedule yet (valid state).
|
||||
3. Hydrate schedule draft state for section UI.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Integration polish and consistency
|
||||
|
||||
### Task 4.1 - Clarify MaintenanceFlowDetailPage role
|
||||
|
||||
**Files:**
|
||||
- Modify (if needed): `frontend/src/pages/MaintenanceFlowDetailPage.tsx`
|
||||
|
||||
**Decision implementation:**
|
||||
1. Keep schedule read-only there, with "Edit in Flow Editor" CTA.
|
||||
2. Avoid split-brain schedule edits in two places unless explicitly desired.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Tests and verification
|
||||
|
||||
### Task 5.1 - Automated tests
|
||||
|
||||
**Files (new/updated):**
|
||||
- `frontend/src/components/procedural-editor/StepList.test.tsx`
|
||||
- `frontend/src/pages/ProceduralEditorPage.test.tsx`
|
||||
- `frontend/src/store/proceduralEditorStore.test.ts` (if absent, add focused tests)
|
||||
|
||||
**Minimum coverage:**
|
||||
1. Reorder respects `procedure_end` constraints.
|
||||
2. New steps auto-expand and scroll target call occurs.
|
||||
3. Accordion open/close state and summaries.
|
||||
4. Maintenance save orchestration:
|
||||
- tree create/update then schedule create/update
|
||||
- schedule failure does not revert tree success.
|
||||
|
||||
### Task 5.2 - Manual acceptance checklist
|
||||
|
||||
1. Steps list remains primary viewport focus in fixed-height layout.
|
||||
2. Details/Intake/Schedule sections collapse and summarize correctly.
|
||||
3. DnD works by mouse and keyboard.
|
||||
4. End step never drags.
|
||||
5. New maintenance flow:
|
||||
- can save draft without schedule
|
||||
- can save with schedule in one action (tree first, schedule second).
|
||||
6. Existing maintenance flow loads schedule and saves edits.
|
||||
|
||||
### Task 5.3 - Build and lint gates
|
||||
|
||||
1. `cd frontend && npm run build`
|
||||
2. `cd frontend && npm run test`
|
||||
3. `cd frontend && npm run lint`
|
||||
|
||||
---
|
||||
|
||||
## File Impact (Revised)
|
||||
|
||||
### Create
|
||||
1. `frontend/src/components/procedural-editor/CollapsibleEditorSection.tsx`
|
||||
2. `frontend/src/components/procedural-editor/MaintenanceScheduleSection.tsx`
|
||||
|
||||
### Modify
|
||||
1. `frontend/src/pages/ProceduralEditorPage.tsx`
|
||||
2. `frontend/src/components/procedural-editor/StepList.tsx`
|
||||
3. `frontend/src/components/procedural-editor/IntakeFormBuilder.tsx`
|
||||
4. `frontend/src/store/proceduralEditorStore.ts`
|
||||
5. `frontend/src/pages/MaintenanceFlowDetailPage.tsx` (if ownership adjusted)
|
||||
|
||||
### Existing APIs used
|
||||
1. `frontend/src/api/maintenanceSchedules.ts`
|
||||
2. target list API module(s) if inline list selection/creation is implemented
|
||||
|
||||
---
|
||||
|
||||
## Out of Scope (unchanged)
|
||||
|
||||
1. Intake field DnD reorder.
|
||||
2. Procedural undo/redo parity.
|
||||
3. Step templates/presets.
|
||||
4. Bulk step operations.
|
||||
5. Backend schema/model changes for procedural steps.
|
||||
811
docs/plans/archive/2026-02-19-procedural-editor-redesign-impl.md
Normal file
811
docs/plans/archive/2026-02-19-procedural-editor-redesign-impl.md
Normal file
@@ -0,0 +1,811 @@
|
||||
# Procedural Editor Redesign Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Restructure the procedural/maintenance flow editor with collapsible sections, fixed-height layout, drag-to-reorder steps, and maintenance schedule management.
|
||||
|
||||
**Architecture:** Convert ProceduralEditorPage from a scrolling document to a fixed-height editor. Details and Intake Form become collapsible one-liners. Step list gets all remaining height with independent scrolling. Add @dnd-kit drag-to-reorder on steps. Maintenance flows get an inline schedule section.
|
||||
|
||||
**Tech Stack:** React 19, TypeScript, Tailwind CSS, Zustand (proceduralEditorStore), @dnd-kit/core + @dnd-kit/sortable (already installed), existing maintenance schedule APIs.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: CollapsibleEditorSection Component
|
||||
|
||||
### Task 1: Create CollapsibleEditorSection
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/components/procedural-editor/CollapsibleEditorSection.tsx`
|
||||
|
||||
**Context:** This is a reusable wrapper that shows a one-line summary when collapsed and reveals full content when expanded. Used by Details, Intake Form, and Maintenance Schedule sections. See the design doc at `docs/plans/2026-02-19-procedural-editor-redesign-design.md` for the spec.
|
||||
|
||||
**Step 1: Create the component**
|
||||
|
||||
```tsx
|
||||
// frontend/src/components/procedural-editor/CollapsibleEditorSection.tsx
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface CollapsibleEditorSectionProps {
|
||||
title: string
|
||||
icon: ReactNode
|
||||
summary: string
|
||||
expanded?: boolean
|
||||
onToggle?: () => void
|
||||
defaultExpanded?: boolean
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function CollapsibleEditorSection({
|
||||
title,
|
||||
icon,
|
||||
summary,
|
||||
expanded: controlledExpanded,
|
||||
onToggle,
|
||||
defaultExpanded = false,
|
||||
children,
|
||||
}: CollapsibleEditorSectionProps) {
|
||||
const [internalExpanded, setInternalExpanded] = useState(defaultExpanded)
|
||||
const isExpanded = controlledExpanded ?? internalExpanded
|
||||
const sectionId = `section-${title.toLowerCase().replace(/\s+/g, '-')}`
|
||||
|
||||
const handleToggle = () => {
|
||||
if (onToggle) {
|
||||
onToggle()
|
||||
} else {
|
||||
setInternalExpanded(!internalExpanded)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-b border-border">
|
||||
{/* Collapsed header — always visible */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggle}
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls={sectionId}
|
||||
className="flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-accent/50"
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200',
|
||||
isExpanded && 'rotate-90'
|
||||
)}
|
||||
/>
|
||||
<span className="shrink-0 text-muted-foreground">{icon}</span>
|
||||
<span className="text-sm font-medium text-foreground">{title}</span>
|
||||
{!isExpanded && (
|
||||
<span className="min-w-0 truncate text-sm text-muted-foreground">
|
||||
— {summary}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Expanded content */}
|
||||
{isExpanded && (
|
||||
<div id={sectionId} className="px-4 pb-4 pt-1">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Verify it builds**
|
||||
|
||||
Run: `cd frontend && npm run build 2>&1 | tail -5`
|
||||
Expected: `built in` success message (component is created but not imported anywhere yet)
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/components/procedural-editor/CollapsibleEditorSection.tsx
|
||||
git commit -m "feat: add CollapsibleEditorSection component for procedural editor"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Layout Restructure
|
||||
|
||||
### Task 2: Convert ProceduralEditorPage to Fixed-Height Editor
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/pages/ProceduralEditorPage.tsx`
|
||||
|
||||
**Context:** The page currently uses `container mx-auto px-4 py-6` with vertical scrolling. We need to convert it to `flex flex-col h-full overflow-hidden` so the step list can scroll independently. The toolbar becomes a sticky header, and Details + Intake Form become collapsible sections.
|
||||
|
||||
Reference the existing troubleshooting editor pattern: `frontend/src/pages/TreeEditorPage.tsx` lines 409-410 for the `flex h-full flex-col overflow-hidden` pattern.
|
||||
|
||||
**Step 1: Read the current file**
|
||||
|
||||
Read: `frontend/src/pages/ProceduralEditorPage.tsx`
|
||||
Understand the full structure before making changes.
|
||||
|
||||
**Step 2: Restructure the layout**
|
||||
|
||||
Replace the entire render return (from `return (` to the closing `)`) with the new fixed-height layout. The key changes:
|
||||
|
||||
1. Outer wrapper: `<div className="flex h-full flex-col overflow-hidden">` (replaces `container mx-auto`)
|
||||
2. Toolbar: sticky `<div className="flex items-center justify-between border-b border-border bg-card px-4 py-2">` containing the back button, title, and save/publish buttons
|
||||
3. Collapsible sections zone: `<div className="shrink-0">` containing CollapsibleEditorSection wrappers for Details and IntakeFormBuilder
|
||||
4. Step list zone: `<div className="flex-1 min-h-0 overflow-y-auto px-4 py-4">` containing StepList
|
||||
|
||||
Import `CollapsibleEditorSection` at the top:
|
||||
```tsx
|
||||
import { CollapsibleEditorSection } from '@/components/procedural-editor/CollapsibleEditorSection'
|
||||
```
|
||||
|
||||
The Details collapsible section needs a summary string. Build it from store state:
|
||||
```tsx
|
||||
const detailsSummary = [
|
||||
name ? `"${name}"` : '"Untitled"',
|
||||
tags.length > 0 ? `${tags.length} tag${tags.length !== 1 ? 's' : ''}` : 'No tags',
|
||||
isPublic ? 'Public' : 'Private',
|
||||
].join(' · ')
|
||||
```
|
||||
|
||||
The IntakeFormBuilder collapsible section needs a summary too. Read the `intakeForm` array from the store (add it to destructuring if not already there) and build:
|
||||
```tsx
|
||||
const intakeSummary = intakeForm.length === 0
|
||||
? 'No fields defined'
|
||||
: `${intakeForm.length} field${intakeForm.length !== 1 ? 's' : ''}: ${intakeForm.map(f => f.label || f.variable_name).slice(0, 4).join(', ')}${intakeForm.length > 4 ? ', ...' : ''}`
|
||||
```
|
||||
|
||||
For the Details section content inside CollapsibleEditorSection, move the existing form fields (name input, description textarea, tags input, public checkbox) directly as children.
|
||||
|
||||
The Details section should `defaultExpanded={!isEditMode}` so new flows start with Details expanded (name is required), while existing flows start collapsed.
|
||||
|
||||
The Intake Form section should `defaultExpanded={false}` always.
|
||||
|
||||
**Step 3: Verify it builds**
|
||||
|
||||
Run: `cd frontend && npm run build 2>&1 | tail -5`
|
||||
Expected: Clean build
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/pages/ProceduralEditorPage.tsx
|
||||
git commit -m "feat: restructure procedural editor to fixed-height layout with collapsible sections"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Step List Improvements
|
||||
|
||||
### Task 3: Add Empty State and Step Count Header
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/procedural-editor/StepList.tsx`
|
||||
|
||||
**Context:** The step list needs: (1) a step count + total estimated time in the header, (2) auto-scroll to new steps. Note: the store enforces minimum one `procedure_step`, so remove any "0 steps" UI paths. The step list's outer card wrapper should be removed since ProceduralEditorPage now provides the scrolling container — StepList should just render its header and step items directly.
|
||||
|
||||
**Step 1: Read the current file**
|
||||
|
||||
Read: `frontend/src/components/procedural-editor/StepList.tsx`
|
||||
|
||||
**Step 2: Modify the header and add empty state**
|
||||
|
||||
Remove the outer `<div className="bg-card border border-border rounded-2xl p-4 sm:p-6">` card wrapper. The step list now renders directly in the scrolling container.
|
||||
|
||||
Update the header to show step count + total estimated time:
|
||||
```tsx
|
||||
const totalMinutes = steps
|
||||
.filter(s => s.type === 'procedure_step' && s.estimated_minutes)
|
||||
.reduce((sum, s) => sum + (s.estimated_minutes || 0), 0)
|
||||
|
||||
// In the header:
|
||||
<span className="text-sm text-muted-foreground">
|
||||
({procedureSteps.length} step{procedureSteps.length !== 1 ? 's' : ''}
|
||||
{totalMinutes > 0 ? ` · ~${totalMinutes} min` : ''})
|
||||
</span>
|
||||
```
|
||||
|
||||
Add empty state after the header, before the step map:
|
||||
```tsx
|
||||
{procedureSteps.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<Shield className="mb-3 h-10 w-10 text-muted-foreground/50" />
|
||||
<h3 className="mb-1 text-sm font-medium text-foreground">Add your first step</h3>
|
||||
<p className="mb-4 max-w-xs text-xs text-muted-foreground">
|
||||
Steps define the actions engineers follow during this procedure.
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => addStep()}
|
||||
className="flex items-center gap-1.5 rounded-md bg-gradient-brand px-3 py-1.5 text-sm font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Add Step
|
||||
</button>
|
||||
<button
|
||||
onClick={() => addSectionHeader()}
|
||||
className="flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<SeparatorHorizontal className="h-3.5 w-3.5" />
|
||||
Add Section
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
**Step 3: Add auto-scroll to new step**
|
||||
|
||||
When a new step is added, the list should scroll to show it. Add a ref and useEffect:
|
||||
|
||||
```tsx
|
||||
import { useRef, useEffect } from 'react'
|
||||
|
||||
const listEndRef = useRef<HTMLDivElement>(null)
|
||||
const prevStepCount = useRef(steps.length)
|
||||
|
||||
useEffect(() => {
|
||||
if (steps.length > prevStepCount.current) {
|
||||
listEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
prevStepCount.current = steps.length
|
||||
}, [steps.length])
|
||||
|
||||
// At the bottom of the step list, before the Add Step button:
|
||||
<div ref={listEndRef} />
|
||||
```
|
||||
|
||||
**Step 4: Verify it builds**
|
||||
|
||||
Run: `cd frontend && npm run build 2>&1 | tail -5`
|
||||
Expected: Clean build
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/components/procedural-editor/StepList.tsx
|
||||
git commit -m "feat: add empty state, step count header, and auto-scroll to step list"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Drag-to-Reorder Steps
|
||||
|
||||
### Task 4: Add @dnd-kit Drag-to-Reorder to StepList
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/procedural-editor/StepList.tsx`
|
||||
|
||||
**Context:** @dnd-kit is already installed (`@dnd-kit/core@^6.3.1`, `@dnd-kit/sortable@^10.0.0`). There's an existing pattern in `frontend/src/components/step-library/CategoryRow.tsx` using `useSortable` and in `frontend/src/pages/admin/AdminCategoriesPage.tsx` using `DndContext` + `SortableContext`. The store already has `moveStep(fromIndex, toIndex)`. Reorder is array-index based only — do NOT recalculate `display_order`. `procedure_end` must remain non-draggable and always last. Drag handles must have accessible labels and keyboard support (Enter/Space to pick up, arrow keys to move, Escape to cancel).
|
||||
|
||||
**Step 1: Read the existing @dnd-kit pattern**
|
||||
|
||||
Read: `frontend/src/components/step-library/CategoryRow.tsx` (lines 1-50 for the useSortable pattern)
|
||||
Read: `frontend/src/pages/admin/AdminCategoriesPage.tsx` (lines 1-10 for imports, lines 110-140 for handleDragEnd)
|
||||
|
||||
**Step 2: Add DndContext to StepList**
|
||||
|
||||
Add imports at the top of StepList.tsx:
|
||||
```tsx
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from '@dnd-kit/core'
|
||||
import {
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy,
|
||||
useSortable,
|
||||
} from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
```
|
||||
|
||||
Add sensors and drag handler before the return:
|
||||
```tsx
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
||||
)
|
||||
|
||||
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
||||
const { active, over } = event
|
||||
if (!over || active.id === over.id) return
|
||||
|
||||
const oldIndex = steps.findIndex(s => s.id === active.id)
|
||||
const newIndex = steps.findIndex(s => s.id === over.id)
|
||||
if (oldIndex === -1 || newIndex === -1) return
|
||||
|
||||
// Don't allow moving past the procedure_end step
|
||||
const endIndex = steps.findIndex(s => s.type === 'procedure_end')
|
||||
if (newIndex >= endIndex) return
|
||||
|
||||
moveStep(oldIndex, newIndex)
|
||||
}, [steps, moveStep])
|
||||
```
|
||||
|
||||
Wrap the step list `<div className="space-y-2">` with DndContext and SortableContext:
|
||||
```tsx
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={steps.filter(s => s.type !== 'procedure_end').map(s => s.id)} strategy={verticalListSortingStrategy}>
|
||||
<div className="space-y-2">
|
||||
{steps.map((step) => {
|
||||
// ... existing rendering logic
|
||||
})}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
```
|
||||
|
||||
**Step 3: Make each step card sortable**
|
||||
|
||||
For each step card (procedure_step collapsed, section_header collapsed), extract the card div into a `SortableStepCard` wrapper or apply `useSortable` inline.
|
||||
|
||||
The simplest approach: create a small wrapper component inside StepList.tsx:
|
||||
|
||||
```tsx
|
||||
function SortableStepWrapper({ id, children, disabled }: { id: string; children: ReactNode; disabled?: boolean }) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id, disabled })
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} className={cn(isDragging && 'opacity-50 z-10')}>
|
||||
{/* Pass drag handle props to children via render prop or context */}
|
||||
{typeof children === 'function'
|
||||
? children({ dragHandleProps: { ...attributes, ...listeners } })
|
||||
: children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Actually, a simpler approach: apply useSortable directly to each card's GripVertical button. Wrap each step's outer `<div>` with `ref={setNodeRef}` and pass `{...attributes} {...listeners}` to the GripVertical button.
|
||||
|
||||
For collapsed procedure_step cards (the main case), the existing `<GripVertical>` button at line 148 gets the sortable props:
|
||||
```tsx
|
||||
// Replace the GripVertical span/button:
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 cursor-grab active:cursor-grabbing text-muted-foreground"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</button>
|
||||
```
|
||||
|
||||
For expanded steps (StepEditor), disable sorting (`disabled: true` in useSortable) since the user is editing.
|
||||
|
||||
The `procedure_end` step should NOT be in the SortableContext items array (already excluded above) and should not have useSortable applied.
|
||||
|
||||
**Step 4: Verify it builds**
|
||||
|
||||
Run: `cd frontend && npm run build 2>&1 | tail -5`
|
||||
Expected: Clean build
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/components/procedural-editor/StepList.tsx
|
||||
git commit -m "feat: add drag-to-reorder steps with @dnd-kit"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Maintenance Schedule Section
|
||||
|
||||
### Task 5: Create MaintenanceScheduleSection
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/components/procedural-editor/MaintenanceScheduleSection.tsx`
|
||||
|
||||
**Context:** This component renders only for maintenance flows. Schedule is NOT part of `tree_structure` — it's persisted through separate maintenance schedule API endpoints. Two-stage save: tree first, then schedule. If schedule save fails, tree save remains successful — show actionable error and preserve schedule draft state. It uses the existing `maintenanceSchedulesApi` from `frontend/src/api/maintenanceSchedules.ts` and `targetListsApi` from `frontend/src/api/targetLists.ts`. Types are in `frontend/src/types/maintenance.ts`. Schedule draft state should be local UI state, not stored in proceduralEditorStore.
|
||||
|
||||
Read these files first:
|
||||
- `frontend/src/api/maintenanceSchedules.ts`
|
||||
- `frontend/src/api/targetLists.ts`
|
||||
- `frontend/src/types/maintenance.ts`
|
||||
|
||||
**Step 1: Create the component**
|
||||
|
||||
```tsx
|
||||
// frontend/src/components/procedural-editor/MaintenanceScheduleSection.tsx
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Calendar, Clock } from 'lucide-react'
|
||||
import { maintenanceSchedulesApi } from '@/api/maintenanceSchedules'
|
||||
import { targetListsApi } from '@/api/targetLists'
|
||||
import type { MaintenanceSchedule, TargetList } from '@/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from '@/lib/toast'
|
||||
|
||||
interface MaintenanceScheduleSectionProps {
|
||||
treeId: string | null // null for new flows
|
||||
}
|
||||
|
||||
const FREQUENCY_OPTIONS = [
|
||||
{ value: 'daily', label: 'Daily', cron: (hour: number, min: number) => `${min} ${hour} * * *` },
|
||||
{ value: 'weekly-mon', label: 'Weekly (Monday)', cron: (hour: number, min: number) => `${min} ${hour} * * 1` },
|
||||
{ value: 'weekly-fri', label: 'Weekly (Friday)', cron: (hour: number, min: number) => `${min} ${hour} * * 5` },
|
||||
{ value: 'monthly', label: 'Monthly (1st)', cron: (hour: number, min: number) => `${min} ${hour} 1 * *` },
|
||||
] as const
|
||||
|
||||
const TIMEZONE_OPTIONS = [
|
||||
'UTC',
|
||||
'America/New_York',
|
||||
'America/Chicago',
|
||||
'America/Denver',
|
||||
'America/Los_Angeles',
|
||||
'Europe/London',
|
||||
'Europe/Berlin',
|
||||
'Asia/Tokyo',
|
||||
'Australia/Sydney',
|
||||
]
|
||||
|
||||
export function MaintenanceScheduleSection({ treeId }: MaintenanceScheduleSectionProps) {
|
||||
const [schedule, setSchedule] = useState<MaintenanceSchedule | null>(null)
|
||||
const [targetLists, setTargetLists] = useState<TargetList[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
|
||||
// Form state
|
||||
const [frequency, setFrequency] = useState('weekly-mon')
|
||||
const [hour, setHour] = useState(9)
|
||||
const [minute, setMinute] = useState(0)
|
||||
const [timezone, setTimezone] = useState('UTC')
|
||||
const [selectedTargetListId, setSelectedTargetListId] = useState<string>('')
|
||||
|
||||
// Load existing schedule and target lists
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const lists = await targetListsApi.list()
|
||||
setTargetLists(lists)
|
||||
|
||||
if (treeId) {
|
||||
try {
|
||||
const existing = await maintenanceSchedulesApi.getForTree(treeId)
|
||||
setSchedule(existing)
|
||||
if (existing.target_list_id) {
|
||||
setSelectedTargetListId(existing.target_list_id)
|
||||
}
|
||||
} catch {
|
||||
// No schedule yet — that's fine
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Target lists may not load — non-critical
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
load()
|
||||
}, [treeId])
|
||||
|
||||
const handleSaveSchedule = async () => {
|
||||
if (!treeId) {
|
||||
toast.error('Save the flow first before configuring a schedule')
|
||||
return
|
||||
}
|
||||
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const freqOption = FREQUENCY_OPTIONS.find(f => f.value === frequency)
|
||||
const cronExpression = freqOption?.cron(hour, minute) ?? `${minute} ${hour} * * 1`
|
||||
|
||||
if (schedule) {
|
||||
const updated = await maintenanceSchedulesApi.update(schedule.id, {
|
||||
cron_expression: cronExpression,
|
||||
timezone,
|
||||
target_list_id: selectedTargetListId || undefined,
|
||||
})
|
||||
setSchedule(updated)
|
||||
toast.success('Schedule updated')
|
||||
} else {
|
||||
const created = await maintenanceSchedulesApi.create({
|
||||
tree_id: treeId,
|
||||
cron_expression: cronExpression,
|
||||
timezone,
|
||||
target_list_id: selectedTargetListId || undefined,
|
||||
})
|
||||
setSchedule(created)
|
||||
toast.success('Schedule created')
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to save schedule')
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const selectedTargetList = targetLists.find(tl => tl.id === selectedTargetListId)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="px-4 py-3 text-sm text-muted-foreground">Loading schedule...</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Frequency */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-muted-foreground">Frequency</label>
|
||||
<select
|
||||
value={frequency}
|
||||
onChange={(e) => setFrequency(e.target.value)}
|
||||
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
|
||||
>
|
||||
{FREQUENCY_OPTIONS.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Time */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-muted-foreground">Hour</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={23}
|
||||
value={hour}
|
||||
onChange={(e) => setHour(Number(e.target.value))}
|
||||
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-muted-foreground">Minute</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={59}
|
||||
value={minute}
|
||||
onChange={(e) => setMinute(Number(e.target.value))}
|
||||
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timezone */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-muted-foreground">Timezone</label>
|
||||
<select
|
||||
value={timezone}
|
||||
onChange={(e) => setTimezone(e.target.value)}
|
||||
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
|
||||
>
|
||||
{TIMEZONE_OPTIONS.map(tz => (
|
||||
<option key={tz} value={tz}>{tz}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Target List */}
|
||||
{targetLists.length > 0 && (
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-muted-foreground">Target List (optional)</label>
|
||||
<select
|
||||
value={selectedTargetListId}
|
||||
onChange={(e) => setSelectedTargetListId(e.target.value)}
|
||||
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
|
||||
>
|
||||
<option value="">None — manual targets only</option>
|
||||
{targetLists.map(tl => (
|
||||
<option key={tl.id} value={tl.id}>{tl.name} ({tl.targets.length} targets)</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Save button */}
|
||||
<button
|
||||
onClick={handleSaveSchedule}
|
||||
disabled={isSaving || !treeId}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium',
|
||||
treeId
|
||||
? 'bg-gradient-brand text-white shadow-lg shadow-primary/20 hover:opacity-90'
|
||||
: 'bg-card border border-border text-muted-foreground cursor-not-allowed opacity-50'
|
||||
)}
|
||||
>
|
||||
<Clock className="h-4 w-4" />
|
||||
{isSaving ? 'Saving...' : schedule ? 'Update Schedule' : 'Create Schedule'}
|
||||
</button>
|
||||
{!treeId && (
|
||||
<p className="text-xs text-muted-foreground">Save the flow first to configure a schedule.</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Build a summary string helper**
|
||||
|
||||
Add this exported function at the bottom of the file (used by ProceduralEditorPage for the collapsible section summary):
|
||||
|
||||
```tsx
|
||||
export function getScheduleSummary(schedule: MaintenanceSchedule | null, targetList?: TargetList | null): string {
|
||||
if (!schedule) return 'No schedule configured'
|
||||
|
||||
// Parse cron for human-readable display
|
||||
const parts = schedule.cron_expression.split(' ')
|
||||
const min = parts[0] ?? '0'
|
||||
const hour = parts[1] ?? '0'
|
||||
const timeStr = `${hour.padStart(2, '0')}:${min.padStart(2, '0')}`
|
||||
|
||||
const dayOfWeek = parts[4]
|
||||
let freqStr = 'Custom'
|
||||
if (parts[2] === '*' && parts[3] === '*') {
|
||||
if (dayOfWeek === '*') freqStr = 'Daily'
|
||||
else if (dayOfWeek === '1') freqStr = 'Every Monday'
|
||||
else if (dayOfWeek === '5') freqStr = 'Every Friday'
|
||||
} else if (parts[2] === '1') {
|
||||
freqStr = 'Monthly (1st)'
|
||||
}
|
||||
|
||||
const targetStr = targetList ? ` · ${targetList.targets.length} targets` : ''
|
||||
return `${freqStr} at ${timeStr} ${schedule.timezone}${targetStr}`
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Verify it builds**
|
||||
|
||||
Run: `cd frontend && npm run build 2>&1 | tail -5`
|
||||
Expected: Clean build
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/components/procedural-editor/MaintenanceScheduleSection.tsx
|
||||
git commit -m "feat: add MaintenanceScheduleSection with schedule builder and summary"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Wire Maintenance Schedule into ProceduralEditorPage
|
||||
|
||||
### Task 6: Add Schedule Section to ProceduralEditorPage
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/pages/ProceduralEditorPage.tsx`
|
||||
|
||||
**Context:** The MaintenanceScheduleSection should appear as a third collapsible section, only for maintenance flows. It needs access to `treeId` (from store) and renders between Intake Form and the step list.
|
||||
|
||||
**Step 1: Read the current ProceduralEditorPage**
|
||||
|
||||
Read: `frontend/src/pages/ProceduralEditorPage.tsx`
|
||||
|
||||
**Step 2: Add imports and the schedule section**
|
||||
|
||||
Add imports:
|
||||
```tsx
|
||||
import { MaintenanceScheduleSection, getScheduleSummary } from '@/components/procedural-editor/MaintenanceScheduleSection'
|
||||
import { Calendar } from 'lucide-react'
|
||||
```
|
||||
|
||||
Add the schedule state (load existing schedule for summary):
|
||||
```tsx
|
||||
import { useState, useEffect } from 'react'
|
||||
import { maintenanceSchedulesApi } from '@/api/maintenanceSchedules'
|
||||
import { targetListsApi } from '@/api/targetLists'
|
||||
import type { MaintenanceSchedule, TargetList } from '@/types'
|
||||
|
||||
// Inside the component:
|
||||
const [schedule, setSchedule] = useState<MaintenanceSchedule | null>(null)
|
||||
const [scheduleTargetList, setScheduleTargetList] = useState<TargetList | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMaintenance || !treeId) return
|
||||
const loadSchedule = async () => {
|
||||
try {
|
||||
const s = await maintenanceSchedulesApi.getForTree(treeId)
|
||||
setSchedule(s)
|
||||
if (s.target_list_id) {
|
||||
const tl = await targetListsApi.get(s.target_list_id)
|
||||
setScheduleTargetList(tl)
|
||||
}
|
||||
} catch {
|
||||
// No schedule — fine
|
||||
}
|
||||
}
|
||||
loadSchedule()
|
||||
}, [isMaintenance, treeId])
|
||||
|
||||
const scheduleSummary = getScheduleSummary(schedule, scheduleTargetList)
|
||||
```
|
||||
|
||||
Add the collapsible schedule section after the Intake Form section and before the step list:
|
||||
```tsx
|
||||
{isMaintenance && (
|
||||
<CollapsibleEditorSection
|
||||
title="Schedule"
|
||||
icon={<Calendar className="h-4 w-4" />}
|
||||
summary={scheduleSummary}
|
||||
defaultExpanded={!isEditMode || !schedule}
|
||||
>
|
||||
<MaintenanceScheduleSection treeId={treeId} />
|
||||
</CollapsibleEditorSection>
|
||||
)}
|
||||
```
|
||||
|
||||
**Step 3: Verify it builds**
|
||||
|
||||
Run: `cd frontend && npm run build 2>&1 | tail -5`
|
||||
Expected: Clean build
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/pages/ProceduralEditorPage.tsx
|
||||
git commit -m "feat: wire maintenance schedule section into procedural editor"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Final Verification
|
||||
|
||||
### Task 7: Build and Manual Testing Checklist
|
||||
|
||||
**Step 1: Run full build**
|
||||
|
||||
Run: `cd frontend && npm run build 2>&1 | tail -10`
|
||||
Expected: Clean build with no TypeScript or lint errors
|
||||
|
||||
**Step 2: Manual testing checklist**
|
||||
|
||||
Start the dev server: `cd frontend && npm run dev`
|
||||
|
||||
Test each scenario:
|
||||
|
||||
**Procedural flow — new:**
|
||||
- [ ] Navigate to `/flows/new?type=procedural`
|
||||
- [ ] Page uses fixed-height layout (no page scrolling)
|
||||
- [ ] Details section is expanded by default (name field visible)
|
||||
- [ ] Intake Form section is collapsed, shows "No fields defined"
|
||||
- [ ] No Schedule section visible (procedural, not maintenance)
|
||||
- [ ] Step list shows empty state with "Add your first step"
|
||||
- [ ] Click "Add Step" — new step appears and auto-expands
|
||||
- [ ] Step list scrolls independently from the toolbar
|
||||
- [ ] Fill in name, collapse Details — summary shows `"Name" · No tags · Private`
|
||||
|
||||
**Procedural flow — existing:**
|
||||
- [ ] Edit an existing procedural flow
|
||||
- [ ] Details section is collapsed with rich summary
|
||||
- [ ] Intake Form section is collapsed with field names
|
||||
- [ ] Steps visible immediately with count and estimated time
|
||||
- [ ] Drag a step by its grip handle — reorders correctly
|
||||
- [ ] Expanded step cannot be dragged
|
||||
- [ ] Section headers can be dragged
|
||||
- [ ] procedure_end step stays at the bottom (cannot be dragged above it)
|
||||
|
||||
**Maintenance flow — new:**
|
||||
- [ ] Navigate to `/flows/new?type=maintenance`
|
||||
- [ ] Schedule section is visible and expanded
|
||||
- [ ] Can select frequency, time, timezone
|
||||
- [ ] "Save the flow first" message shown (no treeId yet)
|
||||
- [ ] After saving, schedule can be created
|
||||
|
||||
**Maintenance flow — existing with schedule:**
|
||||
- [ ] Schedule section shows collapsed summary: "Every Monday at 09:00 UTC · 5 targets"
|
||||
- [ ] Expand to modify schedule
|
||||
- [ ] Update saves correctly
|
||||
|
||||
**Step 3: Commit any fixes found during testing**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "fix: address issues found during manual testing"
|
||||
```
|
||||
|
||||
**Step 4: Final commit message for the complete feature**
|
||||
|
||||
If all tests pass and no fixes needed, the branch is ready for PR.
|
||||
745
docs/plans/archive/2026-02-20-dashboard-implementation-plan.md
Normal file
745
docs/plans/archive/2026-02-20-dashboard-implementation-plan.md
Normal file
@@ -0,0 +1,745 @@
|
||||
# Dashboard: My Flows, Favorites/Pin UI, and AI Builder — Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Transform the dashboard into a structured personal workspace with a Favorites section (pinned flows), paginated "My Flows", shared pin store, and AI Builder access from the Library page.
|
||||
|
||||
**Architecture:** Zustand store (`pinnedFlowsStore`) becomes single source of truth for pin state across Sidebar, Dashboard, and Library. URL-synced pagination hook manages My Flows paging. Cached quota hook avoids redundant AI quota fetches. All three library view components gain optional pin button props.
|
||||
|
||||
**Tech Stack:** React 19, Zustand, React Router v7, Tailwind CSS, Lucide icons, Axios
|
||||
|
||||
**Design doc:** `docs/plans/2026-02-20-final-dashboard-plan.md`
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Add `pin()` to `pinnedFlowsApi`
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/api/pinnedFlows.ts`
|
||||
|
||||
**Step 1: Add `pin` method**
|
||||
|
||||
In `frontend/src/api/pinnedFlows.ts`, add after the `unpin` method:
|
||||
|
||||
```typescript
|
||||
pin: async (treeId: string): Promise<void> => {
|
||||
await apiClient.post(`/trees/${treeId}/pin`)
|
||||
},
|
||||
```
|
||||
|
||||
**Step 2: Verify build**
|
||||
|
||||
Run: `cd /c/Dev/Projects/patherly/frontend && npx tsc --noEmit`
|
||||
Expected: No errors
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/api/pinnedFlows.ts
|
||||
git commit -m "feat: add pin() method to pinnedFlowsApi"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Create `pinnedFlowsStore`
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/store/pinnedFlowsStore.ts`
|
||||
|
||||
**Step 1: Create the store**
|
||||
|
||||
Create `frontend/src/store/pinnedFlowsStore.ts`:
|
||||
|
||||
```typescript
|
||||
import { create } from 'zustand'
|
||||
import { pinnedFlowsApi } from '@/api/pinnedFlows'
|
||||
import type { PinnedFlow } from '@/api/pinnedFlows'
|
||||
import { toast } from '@/lib/toast'
|
||||
|
||||
interface PinnedFlowsState {
|
||||
items: PinnedFlow[]
|
||||
isLoaded: boolean
|
||||
isLoading: boolean
|
||||
isMutatingByTreeId: Record<string, boolean>
|
||||
error: string | null
|
||||
|
||||
// Actions
|
||||
load: (force?: boolean) => Promise<void>
|
||||
pin: (treeId: string) => Promise<void>
|
||||
unpin: (treeId: string) => Promise<void>
|
||||
toggle: (treeId: string) => void
|
||||
|
||||
// Derived helpers
|
||||
isPinned: (treeId: string) => boolean
|
||||
}
|
||||
|
||||
export const usePinnedFlowsStore = create<PinnedFlowsState>()((set, get) => ({
|
||||
items: [],
|
||||
isLoaded: false,
|
||||
isLoading: false,
|
||||
isMutatingByTreeId: {},
|
||||
error: null,
|
||||
|
||||
load: async (force = false) => {
|
||||
const state = get()
|
||||
if (state.isLoaded && !force) return
|
||||
if (state.isLoading) return
|
||||
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
const data = await pinnedFlowsApi.list()
|
||||
set({ items: data.items, isLoaded: true, isLoading: false })
|
||||
} catch {
|
||||
set({ error: 'Failed to load pinned flows', isLoading: false })
|
||||
}
|
||||
},
|
||||
|
||||
pin: async (treeId: string) => {
|
||||
const state = get()
|
||||
if (state.isMutatingByTreeId[treeId]) return
|
||||
|
||||
set({ isMutatingByTreeId: { ...state.isMutatingByTreeId, [treeId]: true } })
|
||||
|
||||
try {
|
||||
await pinnedFlowsApi.pin(treeId)
|
||||
// Reload to get full PinnedFlow object with tree_name, tree_type, etc.
|
||||
const data = await pinnedFlowsApi.list()
|
||||
set((s) => ({
|
||||
items: data.items,
|
||||
isMutatingByTreeId: { ...s.isMutatingByTreeId, [treeId]: false },
|
||||
}))
|
||||
} catch (err: unknown) {
|
||||
const status = (err as { response?: { status?: number } })?.response?.status
|
||||
if (status === 409) {
|
||||
toast.error('Maximum of 15 favorites reached. Unpin a flow to add a new one.')
|
||||
} else {
|
||||
toast.error('Failed to pin flow')
|
||||
}
|
||||
set((s) => ({
|
||||
isMutatingByTreeId: { ...s.isMutatingByTreeId, [treeId]: false },
|
||||
}))
|
||||
}
|
||||
},
|
||||
|
||||
unpin: async (treeId: string) => {
|
||||
const state = get()
|
||||
if (state.isMutatingByTreeId[treeId]) return
|
||||
|
||||
// Optimistic remove
|
||||
const prevItems = state.items
|
||||
set({
|
||||
items: state.items.filter((f) => f.tree_id !== treeId),
|
||||
isMutatingByTreeId: { ...state.isMutatingByTreeId, [treeId]: true },
|
||||
})
|
||||
|
||||
try {
|
||||
await pinnedFlowsApi.unpin(treeId)
|
||||
set((s) => ({
|
||||
isMutatingByTreeId: { ...s.isMutatingByTreeId, [treeId]: false },
|
||||
}))
|
||||
} catch {
|
||||
// Rollback
|
||||
toast.error('Failed to unpin flow')
|
||||
set((s) => ({
|
||||
items: prevItems,
|
||||
isMutatingByTreeId: { ...s.isMutatingByTreeId, [treeId]: false },
|
||||
}))
|
||||
}
|
||||
},
|
||||
|
||||
toggle: (treeId: string) => {
|
||||
const state = get()
|
||||
if (state.isPinned(treeId)) {
|
||||
state.unpin(treeId)
|
||||
} else {
|
||||
state.pin(treeId)
|
||||
}
|
||||
},
|
||||
|
||||
isPinned: (treeId: string) => {
|
||||
return get().items.some((f) => f.tree_id === treeId)
|
||||
},
|
||||
}))
|
||||
|
||||
// Derived selectors (use outside component or in selectors)
|
||||
export const selectPinnedTreeIds = (state: PinnedFlowsState): Set<string> =>
|
||||
new Set(state.items.map((f) => f.tree_id))
|
||||
|
||||
export const selectPinLoadingTreeIds = (state: PinnedFlowsState): Set<string> =>
|
||||
new Set(
|
||||
Object.entries(state.isMutatingByTreeId)
|
||||
.filter(([, v]) => v)
|
||||
.map(([k]) => k)
|
||||
)
|
||||
```
|
||||
|
||||
**Step 2: Verify build**
|
||||
|
||||
Run: `cd /c/Dev/Projects/patherly/frontend && npx tsc --noEmit`
|
||||
Expected: No errors
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/store/pinnedFlowsStore.ts
|
||||
git commit -m "feat: create pinnedFlowsStore — single source of truth for pin state"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Add `dashboardMyFlowsView` to `userPreferencesStore`
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/store/userPreferencesStore.ts`
|
||||
|
||||
**Step 1: Add preference**
|
||||
|
||||
In the `UserPreferencesState` interface, add:
|
||||
```typescript
|
||||
dashboardMyFlowsView: 'grid' | 'list' | 'table'
|
||||
setDashboardMyFlowsView: (view: 'grid' | 'list' | 'table') => void
|
||||
```
|
||||
|
||||
In the store implementation, add alongside existing fields:
|
||||
```typescript
|
||||
dashboardMyFlowsView: 'grid',
|
||||
setDashboardMyFlowsView: (view) => set({ dashboardMyFlowsView: view }),
|
||||
```
|
||||
|
||||
**Step 2: Verify build**
|
||||
|
||||
Run: `cd /c/Dev/Projects/patherly/frontend && npx tsc --noEmit`
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/store/userPreferencesStore.ts
|
||||
git commit -m "feat: add dashboardMyFlowsView preference (independent from Library)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Replace local pin state in Sidebar with store
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/layout/Sidebar.tsx`
|
||||
|
||||
**Step 1: Swap to store**
|
||||
|
||||
Remove from imports:
|
||||
- `pinnedFlowsApi` import
|
||||
- `PinnedFlow` type import
|
||||
|
||||
Add import:
|
||||
```typescript
|
||||
import { usePinnedFlowsStore } from '@/store/pinnedFlowsStore'
|
||||
```
|
||||
|
||||
Remove from component:
|
||||
- `const [pinnedFlows, setPinnedFlows] = useState<PinnedFlow[]>([])`
|
||||
- The `pinnedFlowsApi.list()` call from the `useEffect`
|
||||
- The entire `handleUnpin` function
|
||||
|
||||
Replace with:
|
||||
```typescript
|
||||
const pinnedItems = usePinnedFlowsStore((s) => s.items)
|
||||
const loadPinned = usePinnedFlowsStore((s) => s.load)
|
||||
const unpinFlow = usePinnedFlowsStore((s) => s.unpin)
|
||||
```
|
||||
|
||||
In the `useEffect` that fetches data, remove the `pinnedFlowsApi.list()` from `Promise.all`. Add a separate call:
|
||||
```typescript
|
||||
useEffect(() => { loadPinned() }, [loadPinned])
|
||||
```
|
||||
|
||||
Update `PinnedFlowsSection` props:
|
||||
```tsx
|
||||
<PinnedFlowsSection flows={pinnedItems} onUnpin={unpinFlow} />
|
||||
```
|
||||
|
||||
**Step 2: Verify build**
|
||||
|
||||
Run: `cd /c/Dev/Projects/patherly/frontend && npx tsc --noEmit`
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/components/layout/Sidebar.tsx
|
||||
git commit -m "refactor: sidebar uses pinnedFlowsStore instead of local state"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Create `usePaginationParams` hook
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/hooks/usePaginationParams.ts`
|
||||
|
||||
**Step 1: Create the hook**
|
||||
|
||||
```typescript
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
|
||||
type PageSize = number | 'all'
|
||||
|
||||
interface UsePaginationParamsOptions {
|
||||
defaultPageSize?: number
|
||||
allowedPageSizes?: PageSize[]
|
||||
}
|
||||
|
||||
export function usePaginationParams(options: UsePaginationParamsOptions = {}) {
|
||||
const { defaultPageSize = 10, allowedPageSizes = [10, 25, 50, 'all'] } = options
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
|
||||
const page = useMemo(() => {
|
||||
const raw = searchParams.get('page')
|
||||
const n = raw ? parseInt(raw, 10) : 1
|
||||
return Number.isFinite(n) && n >= 1 ? n : 1
|
||||
}, [searchParams])
|
||||
|
||||
const pageSize = useMemo((): PageSize => {
|
||||
const raw = searchParams.get('size')
|
||||
if (raw === 'all' && allowedPageSizes.includes('all')) return 'all'
|
||||
const n = raw ? parseInt(raw, 10) : defaultPageSize
|
||||
if (Number.isFinite(n) && allowedPageSizes.includes(n)) return n
|
||||
return defaultPageSize
|
||||
}, [searchParams, defaultPageSize, allowedPageSizes])
|
||||
|
||||
const setPage = useCallback(
|
||||
(newPage: number) => {
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev)
|
||||
if (newPage <= 1) {
|
||||
next.delete('page')
|
||||
} else {
|
||||
next.set('page', String(newPage))
|
||||
}
|
||||
return next
|
||||
})
|
||||
},
|
||||
[setSearchParams]
|
||||
)
|
||||
|
||||
const setPageSize = useCallback(
|
||||
(newSize: PageSize) => {
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev)
|
||||
next.set('size', String(newSize))
|
||||
next.delete('page') // reset to page 1
|
||||
return next
|
||||
})
|
||||
},
|
||||
[setSearchParams]
|
||||
)
|
||||
|
||||
return { page, pageSize, setPage, setPageSize }
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Verify build**
|
||||
|
||||
Run: `cd /c/Dev/Projects/patherly/frontend && npx tsc --noEmit`
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/hooks/usePaginationParams.ts
|
||||
git commit -m "feat: create usePaginationParams hook — URL-synced pagination"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Create `useCachedQuota` hook
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/hooks/useCachedQuota.ts`
|
||||
|
||||
**Step 1: Create the hook**
|
||||
|
||||
```typescript
|
||||
import { useState, useEffect } from 'react'
|
||||
import { aiBuilderApi } from '@/api'
|
||||
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000 // 5 minutes
|
||||
|
||||
let cachedResult: { aiEnabled: boolean; timestamp: number } | null = null
|
||||
|
||||
export function useCachedQuota() {
|
||||
const [aiEnabled, setAiEnabled] = useState(cachedResult?.aiEnabled ?? false)
|
||||
const [isLoading, setIsLoading] = useState(!cachedResult)
|
||||
|
||||
useEffect(() => {
|
||||
if (cachedResult && Date.now() - cachedResult.timestamp < CACHE_TTL_MS) {
|
||||
setAiEnabled(cachedResult.aiEnabled)
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
aiBuilderApi
|
||||
.getQuota()
|
||||
.then((q) => {
|
||||
cachedResult = { aiEnabled: q.ai_enabled, timestamp: Date.now() }
|
||||
setAiEnabled(q.ai_enabled)
|
||||
})
|
||||
.catch(() => {
|
||||
// Leave as false
|
||||
})
|
||||
.finally(() => setIsLoading(false))
|
||||
}, [])
|
||||
|
||||
return { aiEnabled, isLoading }
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Verify build**
|
||||
|
||||
Run: `cd /c/Dev/Projects/patherly/frontend && npx tsc --noEmit`
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/hooks/useCachedQuota.ts
|
||||
git commit -m "feat: create useCachedQuota hook — 5-min TTL AI quota cache"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Add pin button props to `TreeGridView`
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/library/TreeGridView.tsx`
|
||||
|
||||
**Step 1: Read current file to understand structure**
|
||||
|
||||
Read `frontend/src/components/library/TreeGridView.tsx` fully before editing.
|
||||
|
||||
**Step 2: Add optional pin props to interface**
|
||||
|
||||
Add to `TreeGridViewProps`:
|
||||
```typescript
|
||||
pinnedTreeIds?: Set<string>
|
||||
onTogglePin?: (treeId: string) => void
|
||||
pinLoadingTreeIds?: Set<string>
|
||||
```
|
||||
|
||||
**Step 3: Add star button to each card**
|
||||
|
||||
Import `Star` from `lucide-react`. In each card's top-right area, render (only when `onTogglePin` is provided):
|
||||
|
||||
```tsx
|
||||
{onTogglePin && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
onTogglePin(tree.id)
|
||||
}}
|
||||
disabled={pinLoadingTreeIds?.has(tree.id)}
|
||||
aria-label={pinnedTreeIds?.has(tree.id) ? 'Remove from favorites' : 'Add to favorites'}
|
||||
className={cn(
|
||||
'absolute top-3 right-3 rounded-md p-1 transition-colors',
|
||||
pinnedTreeIds?.has(tree.id)
|
||||
? 'text-amber-400 hover:text-amber-300'
|
||||
: 'text-muted-foreground/40 hover:text-amber-400',
|
||||
pinLoadingTreeIds?.has(tree.id) && 'opacity-50 pointer-events-none'
|
||||
)}
|
||||
>
|
||||
<Star size={16} fill={pinnedTreeIds?.has(tree.id) ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
)}
|
||||
```
|
||||
|
||||
**Step 4: Verify build**
|
||||
|
||||
Run: `cd /c/Dev/Projects/patherly/frontend && npx tsc --noEmit`
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/components/library/TreeGridView.tsx
|
||||
git commit -m "feat: add optional pin/favorite button to TreeGridView"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Add pin button props to `TreeListView`
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/library/TreeListView.tsx`
|
||||
|
||||
**Step 1: Read the file, then add same optional props and star button pattern as Task 7.**
|
||||
|
||||
Place the star button at the end of each row (before the actions area). Use the exact same props interface addition and button pattern from Task 7.
|
||||
|
||||
**Step 2: Verify build**
|
||||
|
||||
Run: `cd /c/Dev/Projects/patherly/frontend && npx tsc --noEmit`
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/components/library/TreeListView.tsx
|
||||
git commit -m "feat: add optional pin/favorite button to TreeListView"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Add pin button props to `TreeTableView`
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/library/TreeTableView.tsx`
|
||||
|
||||
**Step 1: Read the file, then add same optional props.**
|
||||
|
||||
Add a narrow "Favorite" column as the leftmost column. Header: star icon. Cell: same button pattern from Task 7. Only render the column when `onTogglePin` is provided.
|
||||
|
||||
**Step 2: Verify build**
|
||||
|
||||
Run: `cd /c/Dev/Projects/patherly/frontend && npx tsc --noEmit`
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/components/library/TreeTableView.tsx
|
||||
git commit -m "feat: add optional pin/favorite column to TreeTableView"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 10: TreeLibraryPage — Create dropdown + AI Builder + pin wiring
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/pages/TreeLibraryPage.tsx`
|
||||
|
||||
**Step 1: Add imports**
|
||||
|
||||
```typescript
|
||||
import { ChevronDown, Sparkles, FolderTree, ListOrdered, Wrench } from 'lucide-react'
|
||||
import { usePinnedFlowsStore, selectPinnedTreeIds, selectPinLoadingTreeIds } from '@/store/pinnedFlowsStore'
|
||||
import { useCachedQuota } from '@/hooks/useCachedQuota'
|
||||
import { AIFlowBuilderModal } from '@/components/ai-builder/AIFlowBuilderModal'
|
||||
```
|
||||
|
||||
**Step 2: Add state and store selectors**
|
||||
|
||||
Inside the component, add:
|
||||
```typescript
|
||||
const [showCreateMenu, setShowCreateMenu] = useState(false)
|
||||
const [showAIBuilder, setShowAIBuilder] = useState(false)
|
||||
const { aiEnabled } = useCachedQuota()
|
||||
|
||||
const pinnedTreeIds = usePinnedFlowsStore(selectPinnedTreeIds)
|
||||
const pinLoadingTreeIds = usePinnedFlowsStore(selectPinLoadingTreeIds)
|
||||
const togglePin = usePinnedFlowsStore((s) => s.toggle)
|
||||
const loadPinned = usePinnedFlowsStore((s) => s.load)
|
||||
|
||||
useEffect(() => { loadPinned() }, [loadPinned])
|
||||
```
|
||||
|
||||
**Step 3: Replace the `<Link>` create button**
|
||||
|
||||
Replace the single `<Link to={...}>` create button with the dropdown menu pattern from `MyTreesPage.tsx` (lines 134-197). Copy that exact pattern — it already has Troubleshooting Tree, Procedural Flow, Maintenance Flow, divider, and Build with AI (conditional on `aiEnabled`).
|
||||
|
||||
**Step 4: Pass pin props to view components**
|
||||
|
||||
Add to each `TreeGridView`, `TreeListView`, `TreeTableView` render:
|
||||
```tsx
|
||||
pinnedTreeIds={pinnedTreeIds}
|
||||
onTogglePin={togglePin}
|
||||
pinLoadingTreeIds={pinLoadingTreeIds}
|
||||
```
|
||||
|
||||
**Step 5: Add AI Builder modal**
|
||||
|
||||
Before the closing `</div>` of the component, add:
|
||||
```tsx
|
||||
{showAIBuilder && (
|
||||
<AIFlowBuilderModal
|
||||
isOpen={showAIBuilder}
|
||||
onClose={() => setShowAIBuilder(false)}
|
||||
/>
|
||||
)}
|
||||
```
|
||||
|
||||
**Step 6: Verify build**
|
||||
|
||||
Run: `cd /c/Dev/Projects/patherly/frontend && npx tsc --noEmit`
|
||||
|
||||
**Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/pages/TreeLibraryPage.tsx
|
||||
git commit -m "feat: Library page — create dropdown with AI Builder + pin controls on all views"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 11: QuickStartPage — Favorites section + paginated My Flows
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/pages/QuickStartPage.tsx`
|
||||
|
||||
This is the largest task. The page gets a major refactor.
|
||||
|
||||
**Step 1: Read the current file fully (already done in exploration)**
|
||||
|
||||
**Step 2: Rewrite QuickStartPage**
|
||||
|
||||
The page structure becomes:
|
||||
|
||||
1. **Page header** with "Create" dropdown (same pattern as Task 10)
|
||||
2. **QuickStats** (keep existing)
|
||||
3. **Search** (keep existing inline search)
|
||||
4. **Recent Sessions** (keep existing `SessionsPanel`)
|
||||
5. **Favorites section** (NEW — from `pinnedFlowsStore.items`)
|
||||
6. **My Flows section** (NEW — paginated, `author_id` filter, view toggle)
|
||||
|
||||
Key implementation details:
|
||||
|
||||
**Favorites section:**
|
||||
- Source: `usePinnedFlowsStore` items
|
||||
- Layout: wrapping grid, max 2 rows (~8 cards). "View all favorites" expands if > 8.
|
||||
- Each card: flow name + type emoji + unpin star button
|
||||
- Skeleton: 4 pulse placeholder cards while `isLoading`
|
||||
- Empty: "Star a flow to pin it here for quick access."
|
||||
|
||||
**My Flows section:**
|
||||
- Source: `treesApi.list({ author_id: user.id, sort_by: 'updated_at', limit: pageSize + 1, skip: (page - 1) * pageSize })`
|
||||
- Use `usePaginationParams({ defaultPageSize: 10, allowedPageSizes: [10, 25, 50, 'all'] })`
|
||||
- `hasNextPage = response.length > pageSize; displayItems = response.slice(0, pageSize)`
|
||||
- For "All": fetch in chunks of 100 up to 500 max
|
||||
- View toggle: `ViewToggle` bound to `dashboardMyFlowsView`
|
||||
- Render: `TreeGridView` / `TreeListView` / `TreeTableView` with pin props
|
||||
- Pagination controls: `Prev` / `Next` / page label / size dropdown
|
||||
- Skeleton: 6 placeholder cards/rows
|
||||
- Empty: "You haven't created any flows yet." + "Create your first flow" CTA
|
||||
|
||||
**Remove:**
|
||||
- The `FiltersBar` (All, Recently Used, My Flows, Team Flows) — replaced by the Favorites + My Flows structure
|
||||
- The `SectionGroup` "All Flows" wrapper
|
||||
- The hard-cap of 20 items
|
||||
|
||||
**Step 3: Verify build**
|
||||
|
||||
Run: `cd /c/Dev/Projects/patherly/frontend && npm run build`
|
||||
Expected: Build succeeds with no errors
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/pages/QuickStartPage.tsx
|
||||
git commit -m "feat: dashboard — Favorites grid + paginated My Flows + skeletons + empty states"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 12: PinnedFlowsSection — Dual collapse + smooth transitions
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/sidebar/PinnedFlowsSection.tsx`
|
||||
|
||||
**Step 1: Read the file (already done)**
|
||||
|
||||
**Step 2: Implement dual collapse**
|
||||
|
||||
Add state:
|
||||
```typescript
|
||||
const [showAll, setShowAll] = useState(false)
|
||||
const TRUNCATE_COUNT = 5
|
||||
```
|
||||
|
||||
Modify header collapse to reset truncation:
|
||||
```typescript
|
||||
const handleToggleCollapse = () => {
|
||||
if (collapsed) {
|
||||
setShowAll(false) // Reset to truncated on re-expand
|
||||
}
|
||||
setCollapsed(!collapsed)
|
||||
}
|
||||
```
|
||||
|
||||
Replace the flow list rendering:
|
||||
```typescript
|
||||
const visibleFlows = showAll ? flows : flows.slice(0, TRUNCATE_COUNT)
|
||||
const hasMore = flows.length > TRUNCATE_COUNT
|
||||
```
|
||||
|
||||
Add click handler on flow buttons that auto-collapses:
|
||||
```typescript
|
||||
onClick={() => {
|
||||
setShowAll(false) // Collapse back to 5
|
||||
navigate(getTreeNavigatePath(flow.tree_id, flow.tree_type))
|
||||
}}
|
||||
```
|
||||
|
||||
Add "Show more" / "Show less" link after the flow list:
|
||||
```tsx
|
||||
{hasMore && !collapsed && (
|
||||
<button
|
||||
onClick={() => setShowAll(!showAll)}
|
||||
className="px-3 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{showAll ? 'Show less' : `Show more (${flows.length})`}
|
||||
</button>
|
||||
)}
|
||||
```
|
||||
|
||||
Add CSS transition on the list container:
|
||||
```tsx
|
||||
<div
|
||||
className="space-y-0.5 overflow-hidden transition-[max-height] duration-250 ease-out"
|
||||
style={{ maxHeight: collapsed ? 0 : showAll ? `${flows.length * 40 + 40}px` : `${TRUNCATE_COUNT * 40 + 40}px` }}
|
||||
>
|
||||
```
|
||||
|
||||
**Step 3: Verify build**
|
||||
|
||||
Run: `cd /c/Dev/Projects/patherly/frontend && npx tsc --noEmit`
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/components/sidebar/PinnedFlowsSection.tsx
|
||||
git commit -m "feat: sidebar pinned section — dual collapse + show more/less + smooth transitions"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 13: Final build validation
|
||||
|
||||
**Step 1: Full build check**
|
||||
|
||||
Run: `cd /c/Dev/Projects/patherly/frontend && npm run build`
|
||||
Expected: Build succeeds with zero errors
|
||||
|
||||
**Step 2: Fix any type errors or import issues**
|
||||
|
||||
If build fails, fix issues and amend the relevant commit.
|
||||
|
||||
**Step 3: Commit any final fixes**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "fix: resolve build errors from dashboard implementation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary of files changed
|
||||
|
||||
| # | File | Action |
|
||||
|---|------|--------|
|
||||
| 1 | `frontend/src/api/pinnedFlows.ts` | Add `pin()` method |
|
||||
| 2 | `frontend/src/store/pinnedFlowsStore.ts` | **New** — Zustand pin store |
|
||||
| 3 | `frontend/src/store/userPreferencesStore.ts` | Add `dashboardMyFlowsView` |
|
||||
| 4 | `frontend/src/components/layout/Sidebar.tsx` | Use store instead of local state |
|
||||
| 5 | `frontend/src/hooks/usePaginationParams.ts` | **New** — URL-synced pagination |
|
||||
| 6 | `frontend/src/hooks/useCachedQuota.ts` | **New** — AI quota cache |
|
||||
| 7 | `frontend/src/components/library/TreeGridView.tsx` | Optional pin button |
|
||||
| 8 | `frontend/src/components/library/TreeListView.tsx` | Optional pin button |
|
||||
| 9 | `frontend/src/components/library/TreeTableView.tsx` | Optional pin column |
|
||||
| 10 | `frontend/src/pages/TreeLibraryPage.tsx` | Create dropdown + AI Builder + pins |
|
||||
| 11 | `frontend/src/pages/QuickStartPage.tsx` | Major refactor: Favorites + My Flows |
|
||||
| 12 | `frontend/src/components/sidebar/PinnedFlowsSection.tsx` | Dual collapse |
|
||||
362
docs/plans/archive/2026-02-20-final-dashboard-plan.md
Normal file
362
docs/plans/archive/2026-02-20-final-dashboard-plan.md
Normal file
@@ -0,0 +1,362 @@
|
||||
# Dashboard: My Flows, Favorites/Pin UI, and AI Builder in Create Menu
|
||||
|
||||
## Final Implementation Plan (Reviewed & Merged)
|
||||
|
||||
### Decisions Locked
|
||||
|
||||
1. **"My Flows"** = trees where `author_id = currentUser.id` (includes forks, since forks set the forking user as author).
|
||||
2. **Pagination** = `Prev / Next` with page-size selector (10 / 25 / 50 / All). No numbered total pages — the `/trees` API returns no total count. Page and page size are synced to URL query params (`?page=3&size=25`).
|
||||
3. **Pin state** = shared Zustand store (`pinnedFlowsStore`), used by Sidebar, Dashboard, and Library. No local state for pins anywhere. **This store owns pin CRUD only — no other state belongs here.**
|
||||
4. **"Show: All"** = fetches in chunks of 100, capped at 500 items maximum. If ceiling is reached, show message: "Showing first 500 flows. Use search or filters to find specific flows."
|
||||
5. **Dashboard view preference** = separate key (`dashboardMyFlowsView`) from Library view preference. The two are independent.
|
||||
6. **Sidebar pinned section** = two independent collapse states: header collapse (hide/show entire section) and list truncation (5 vs. all). When header is collapsed and re-expanded, list resets to 5 items (truncated default).
|
||||
7. **Max pins** = 15, enforced by backend. Frontend handles 409 conflict with user-facing toast.
|
||||
8. **AI Builder quota** = cached with 5-minute TTL. Not re-fetched on every page load.
|
||||
9. **Favorites layout** = compact wrapping grid, max 2 rows (~8 cards visible). "View all" expand link if more than 8 pinned flows.
|
||||
10. **Loading states** = skeleton loaders for both Favorites and My Flows sections during initial fetch. Meaningful empty states with CTAs for new users.
|
||||
11. **Accessibility** = all pin/favorite buttons include `aria-label` (dynamic: "Add to favorites" / "Remove from favorites"), `e.stopPropagation()`, and `e.preventDefault()`.
|
||||
|
||||
### Known API Constraints (No Backend Changes)
|
||||
|
||||
- `GET /trees` returns `TreeListItem[]` with no total count metadata. Pagination uses `skip` + `limit` params (max `limit` is 100).
|
||||
- `POST /trees/{id}/pin`, `DELETE /trees/{id}/pin`, `GET /trees/pinned`, `PATCH /trees/pinned/reorder` all exist and work.
|
||||
- `pinnedFlowsApi` already has `list()` and `unpin()`. Needs `pin()` added.
|
||||
- Backend enforces `MAX_PINNED_FLOWS=15` and returns 409 on conflict.
|
||||
|
||||
### Files Modified
|
||||
|
||||
| File | Phase | Change |
|
||||
|------|-------|--------|
|
||||
| `frontend/src/api/pinnedFlows.ts` | 1 | Add `pin()` method |
|
||||
| `frontend/src/store/pinnedFlowsStore.ts` | 1 | **New file.** Zustand store — single source of truth for all pin state |
|
||||
| `frontend/src/store/userPreferencesStore.ts` | 1 | Add `dashboardMyFlowsView` preference + setter |
|
||||
| `frontend/src/hooks/usePaginationParams.ts` | 1 | **New file.** Custom hook — reads/writes `page` and `size` URL query params |
|
||||
| `frontend/src/hooks/useCachedQuota.ts` | 1 | **New file.** Custom hook — fetches AI quota with 5-min TTL cache |
|
||||
| `frontend/src/components/layout/Sidebar.tsx` | 1 | Replace local pinned state with `pinnedFlowsStore` selectors |
|
||||
| `frontend/src/components/library/TreeGridView.tsx` | 2 | Add optional pin props + star button with aria-label |
|
||||
| `frontend/src/components/library/TreeListView.tsx` | 2 | Add optional pin props + star button with aria-label |
|
||||
| `frontend/src/components/library/TreeTableView.tsx` | 2 | Add optional pin props + star/favorite column with aria-label |
|
||||
| `frontend/src/pages/TreeLibraryPage.tsx` | 3 | Replace Create link with dropdown menu + AI Builder (cached quota) + wire pin store |
|
||||
| `frontend/src/pages/QuickStartPage.tsx` | 4 | Major refactor: Favorites grid + paginated My Flows + skeletons + empty states |
|
||||
| `frontend/src/components/sidebar/PinnedFlowsSection.tsx` | 5 | Dual collapse states + reset-on-reexpand + auto-collapse on navigation |
|
||||
|
||||
---
|
||||
|
||||
### Phase 1: Infrastructure (Stores, Hooks, API)
|
||||
|
||||
**Goal:** Build the shared state and utility layers that every subsequent phase depends on.
|
||||
|
||||
#### 1a. Add `pin()` to API client
|
||||
|
||||
**File:** `frontend/src/api/pinnedFlows.ts`
|
||||
|
||||
```typescript
|
||||
pin: async (treeId: string) => apiClient.post(`/trees/${treeId}/pin`)
|
||||
```
|
||||
|
||||
#### 1b. Create `pinnedFlowsStore`
|
||||
|
||||
**File:** `frontend/src/store/pinnedFlowsStore.ts` (new)
|
||||
|
||||
State:
|
||||
- `items: PinnedFlow[]` — the pinned flows list
|
||||
- `isLoaded: boolean` — whether initial fetch has completed
|
||||
- `isLoading: boolean` — whether initial fetch is in progress
|
||||
- `isMutatingByTreeId: Record<string, boolean>` — per-tree mutation tracking
|
||||
- `error: string | null`
|
||||
|
||||
Actions:
|
||||
- `load(force?: boolean)` — fetch from API. Skip if `isLoaded` unless `force=true`.
|
||||
- `pin(treeId: string)` — optimistic add to `items`, call API, rollback + toast on failure. On 409: toast "Maximum of 15 favorites reached. Unpin a flow to add a new one." and do not add to items.
|
||||
- `unpin(treeId: string)` — optimistic remove from `items`, call API, rollback + toast on failure.
|
||||
- `toggle(treeId: string)` — calls `pin` or `unpin` based on current state.
|
||||
|
||||
Derived:
|
||||
- `isPinned(treeId: string): boolean`
|
||||
- `pinnedTreeIds: Set<string>` — for passing as prop to view components
|
||||
- `pinLoadingTreeIds: Set<string>` — derived from `isMutatingByTreeId` for disabling buttons
|
||||
|
||||
**Scope guardrail:** This store owns pin CRUD and derived pin state only. Dashboard layout preferences belong in `userPreferencesStore`. Recently viewed flows or other concerns belong in separate stores/hooks.
|
||||
|
||||
#### 1c. Replace local pin state in Sidebar
|
||||
|
||||
**File:** `frontend/src/components/layout/Sidebar.tsx`
|
||||
|
||||
- Remove local `useState` / `useEffect` for pinned flows (currently mount-only fetch)
|
||||
- Import and use `usePinnedFlowsStore` selectors and actions
|
||||
- Call `pinnedFlowsStore.load()` on mount (store handles deduplication)
|
||||
|
||||
#### 1d. Add dashboard view preference
|
||||
|
||||
**File:** `frontend/src/store/userPreferencesStore.ts`
|
||||
|
||||
- New field: `dashboardMyFlowsView: 'grid' | 'list' | 'table'` (default: `'grid'`)
|
||||
- New setter: `setDashboardMyFlowsView`
|
||||
- Persisted to localStorage alongside existing preferences
|
||||
- This is independent from the existing `treeLibraryView` preference
|
||||
|
||||
#### 1e. Create pagination params hook
|
||||
|
||||
**File:** `frontend/src/hooks/usePaginationParams.ts` (new)
|
||||
|
||||
A reusable hook that syncs pagination state to URL query params:
|
||||
|
||||
```typescript
|
||||
// Usage:
|
||||
const { page, pageSize, setPage, setPageSize } = usePaginationParams({
|
||||
defaultPageSize: 10,
|
||||
allowedPageSizes: [10, 25, 50, 'all'],
|
||||
})
|
||||
// URL: /dashboard?page=2&size=25
|
||||
// Handles: invalid values (falls back to defaults), page reset when size changes
|
||||
```
|
||||
|
||||
Behavior:
|
||||
- Reads `page` and `size` from URL search params on mount
|
||||
- Falls back to defaults if missing or invalid
|
||||
- `setPageSize` resets `page` to 1 (changing page size while on page 3 is confusing)
|
||||
- Validates `page` is a positive integer, `size` is one of the allowed values
|
||||
- Uses `useSearchParams` from React Router
|
||||
|
||||
#### 1f. Create cached quota hook
|
||||
|
||||
**File:** `frontend/src/hooks/useCachedQuota.ts` (new)
|
||||
|
||||
```typescript
|
||||
// Usage:
|
||||
const { aiEnabled, isLoading } = useCachedQuota()
|
||||
```
|
||||
|
||||
Behavior:
|
||||
- On first call, fetches `aiBuilderApi.getQuota()`
|
||||
- Caches result in module-level variable with timestamp
|
||||
- Subsequent calls within 5 minutes return cached value (no API call)
|
||||
- After 5 minutes, re-fetches on next call
|
||||
- Returns `{ aiEnabled: boolean, isLoading: boolean }`
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: Pin/Unpin Controls in Library Views
|
||||
|
||||
**Goal:** Add favorite buttons to all three view components without breaking existing behavior.
|
||||
|
||||
**Files:** `TreeGridView.tsx`, `TreeListView.tsx`, `TreeTableView.tsx`
|
||||
|
||||
#### New optional props on all three:
|
||||
|
||||
```typescript
|
||||
pinnedTreeIds?: Set<string>
|
||||
onTogglePin?: (treeId: string) => void
|
||||
pinLoadingTreeIds?: Set<string>
|
||||
```
|
||||
|
||||
Props are optional so these components remain backward-compatible with any page that doesn't use pins.
|
||||
|
||||
#### Pin button pattern (all three views):
|
||||
|
||||
```tsx
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
onTogglePin?.(treeId);
|
||||
}}
|
||||
disabled={pinLoadingTreeIds?.has(treeId)}
|
||||
aria-label={pinnedTreeIds?.has(treeId) ? "Remove from favorites" : "Add to favorites"}
|
||||
className={/* reduced opacity when disabled */}
|
||||
>
|
||||
{pinnedTreeIds?.has(treeId) ? <StarFilledIcon /> : <StarOutlineIcon />}
|
||||
</button>
|
||||
```
|
||||
|
||||
#### View-specific placement:
|
||||
- **Grid:** Star icon in top-right corner of card
|
||||
- **List:** Star icon at the end of each row
|
||||
- **Table:** Dedicated narrow "Favorite" column (leftmost)
|
||||
|
||||
**Critical:** `e.stopPropagation()` + `e.preventDefault()` prevents the click from triggering card/row navigation. Button is disabled (reduced opacity, no pointer events) while that tree's mutation is in-flight.
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: TreeLibraryPage — Create Menu + Pin Wiring
|
||||
|
||||
**Goal:** Add AI Builder access and connect Library page to shared pin store.
|
||||
|
||||
**File:** `frontend/src/pages/TreeLibraryPage.tsx`
|
||||
|
||||
#### 3a. Replace Create link with dropdown
|
||||
|
||||
Replace the single `<Link>` create button with a dropdown menu (same visual pattern as `MyTreesPage`'s `showCreateMenu`).
|
||||
|
||||
Menu items (fixed order):
|
||||
1. Troubleshooting Tree
|
||||
2. Procedural Flow
|
||||
3. Maintenance Flow
|
||||
4. `<divider>`
|
||||
5. Build with AI *(only shown when `aiEnabled` is true)*
|
||||
|
||||
#### 3b. AI Builder integration
|
||||
|
||||
- Use `useCachedQuota()` hook (from Phase 1f) — no fetch-on-mount, uses cached value
|
||||
- Import and render `AIFlowBuilderModal` (already built)
|
||||
- Modal state: `showAIBuilder` boolean, toggled by menu item click
|
||||
|
||||
#### 3c. Wire view components to pin store
|
||||
|
||||
- Import `usePinnedFlowsStore`
|
||||
- Call `store.load()` on mount
|
||||
- Pass `pinnedTreeIds`, `onTogglePin: store.toggle`, and `pinLoadingTreeIds` to active view component
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: QuickStartPage Refactor — Favorites + My Flows
|
||||
|
||||
**Goal:** Transform the dashboard from a flat "All Flows" dump into a structured personal workspace.
|
||||
|
||||
**File:** `frontend/src/pages/QuickStartPage.tsx`
|
||||
|
||||
#### 4a. Favorites Section (above My Flows)
|
||||
|
||||
**Layout:** Compact wrapping grid. Max 2 visible rows (~8 cards). If more than 8 pinned flows, show "View all favorites" link that expands to show all.
|
||||
|
||||
**Data source:** `pinnedFlowsStore.items`, ordered by `display_order`.
|
||||
|
||||
**Each card:** Click to navigate + unpin button (star icon, same pattern as Phase 2).
|
||||
|
||||
**Section header:** "Favorites" with count badge (e.g., "Favorites (7)").
|
||||
|
||||
**Loading state:** Skeleton loader — 4 placeholder cards with pulse animation, matching the grid layout dimensions so content doesn't shift when data arrives.
|
||||
|
||||
**Empty state:** Subtle message: "Star a flow to pin it here for quick access." No CTA button — the action is contextual (you star flows from the Library or My Flows).
|
||||
|
||||
#### 4b. My Flows Section (replaces "All Flows")
|
||||
|
||||
**Section header:** "My Flows"
|
||||
|
||||
**Data source:** `treesApi.list({ author_id: currentUser.id, sort_by: 'updated_at', skip, limit })`
|
||||
|
||||
**Pagination (via `usePaginationParams` hook):**
|
||||
- Page size selector: dropdown with 10 (default), 25, 50, All
|
||||
- For numeric sizes:
|
||||
```typescript
|
||||
// Request one extra item to detect if there's a next page.
|
||||
// If response.length > pageSize, a next page exists.
|
||||
// We only display the first `pageSize` items.
|
||||
const response = await treesApi.list({
|
||||
author_id: currentUser.id,
|
||||
limit: pageSize + 1,
|
||||
skip: (page - 1) * pageSize,
|
||||
});
|
||||
const hasNextPage = response.length > pageSize;
|
||||
const displayItems = response.slice(0, pageSize);
|
||||
```
|
||||
- For "All": fetch in chunks of 100 (`skip=0, limit=100`, then `skip=100`, etc.) until response returns fewer than 100 items OR 500 items total reached. If ceiling hit, show: "Showing first 500 flows. Use search or filters to find specific flows."
|
||||
- Controls: `Prev` (disabled on page 1) / `Next` (disabled when `!hasNextPage`) / current page label / size dropdown
|
||||
- URL synced: `?page=2&size=25` — changing page size resets to page 1
|
||||
|
||||
**View toggle:** Reuse `ViewToggle` component, bound to `dashboardMyFlowsView` preference (independent from Library).
|
||||
|
||||
**Render:** Pass `TreeGridView` / `TreeListView` / `TreeTableView` with pin props from store.
|
||||
|
||||
**Loading state:** Skeleton loader — 6 placeholder cards/rows matching the active view type (grid skeleton for grid view, row skeletons for list/table view).
|
||||
|
||||
**Empty state:** "You haven't created any flows yet." with a CTA button: "Create your first flow" that triggers the Create dropdown menu (same options as TreeLibraryPage: Troubleshooting Tree, Procedural Flow, Maintenance Flow, divider, Build with AI).
|
||||
|
||||
#### 4c. Cleanup
|
||||
|
||||
- Remove the current hard-cap of 20 items
|
||||
- Remove the "All Flows" `SectionGroup` wrapper
|
||||
- Keep existing stats cards, recent sessions, and search panels unless explicitly removed later
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: Sidebar PinnedFlowsSection — Dual Collapse
|
||||
|
||||
**Goal:** Make the sidebar pinned section polished without taking over the sidebar.
|
||||
|
||||
**File:** `frontend/src/components/sidebar/PinnedFlowsSection.tsx`
|
||||
|
||||
#### Two independent collapse states:
|
||||
|
||||
1. **Header collapse:** Click section header → hides/shows entire pinned flows area (existing behavior, keep it). When re-expanding after a collapse, **always reset list truncation to 5 items.**
|
||||
|
||||
2. **List truncation:** When section is expanded:
|
||||
- Show first 5 pinned flows by default
|
||||
- "Show more (X)" link at bottom expands to show all (X = total count)
|
||||
- "Show less" link collapses back to 5
|
||||
- Clicking a pinned flow link: navigate AND auto-collapse back to 5
|
||||
|
||||
#### Smooth transitions:
|
||||
|
||||
- CSS `max-height` transition on the list container: `transition: max-height 250ms ease-out`
|
||||
- Keep it subtle — no dramatic animations
|
||||
|
||||
---
|
||||
|
||||
### Test Cases
|
||||
|
||||
#### `pinnedFlowsStore` unit tests:
|
||||
- `load()` populates `items` from API
|
||||
- `load()` skips fetch when `isLoaded=true` (unless `force=true`)
|
||||
- `toggle()` pins an unpinned tree, unpins a pinned tree
|
||||
- Optimistic update: `items` updates immediately before API resolves
|
||||
- Rollback: `items` reverts if API call fails
|
||||
- 409 conflict: shows error toast, does not add to `items`
|
||||
- Sidebar + Dashboard selectors reflect same state after mutation
|
||||
- `pinnedTreeIds` derived set updates correctly
|
||||
|
||||
#### `usePaginationParams` hook tests:
|
||||
- Reads `page` and `size` from URL on mount
|
||||
- Falls back to defaults when URL params missing or invalid
|
||||
- `setPageSize` resets page to 1
|
||||
- Invalid page (negative, zero, non-number) falls back to 1
|
||||
|
||||
#### `useCachedQuota` hook tests:
|
||||
- First call fetches from API
|
||||
- Second call within 5 minutes returns cached value (no API call)
|
||||
- Call after 5 minutes re-fetches
|
||||
|
||||
#### `PinnedFlowsSection` component tests:
|
||||
- Shows max 5 items by default
|
||||
- "Show more" reveals all items
|
||||
- Clicking a flow collapses list back to 5
|
||||
- Header collapse hides entire section
|
||||
- Re-expanding after header collapse resets to 5 items
|
||||
|
||||
#### `QuickStartPage` integration tests:
|
||||
- My Flows uses `author_id` filter
|
||||
- Numeric page size: requests `limit=size+1`, displays `size` results
|
||||
- "All" fetches iteratively, stops at 500 ceiling
|
||||
- Favorites section updates immediately after pin/unpin
|
||||
- Empty state shows CTA when user has zero flows
|
||||
- Skeleton loaders appear during fetch
|
||||
- URL params update when page/size changes
|
||||
|
||||
#### `TreeLibraryPage` tests:
|
||||
- Create dropdown renders all flow type options
|
||||
- "Build with AI" only shown when `aiEnabled=true`
|
||||
- "Build with AI" opens `AIFlowBuilderModal`
|
||||
- Pin buttons work and sync with store
|
||||
|
||||
#### Regression:
|
||||
- `cd frontend && npm run test`
|
||||
- `cd frontend && npm run build`
|
||||
|
||||
### Manual Verification Checklist
|
||||
|
||||
- [ ] Dashboard: Favorites section shows pinned flows in compact grid, My Flows shows paginated authored flows
|
||||
- [ ] Pin a flow on Library page → appears in Dashboard Favorites AND Sidebar immediately (no navigation)
|
||||
- [ ] Unpin from Dashboard → removed from Sidebar immediately
|
||||
- [ ] Page size dropdown: 10/25/50/All all work, Prev/Next show/hide correctly
|
||||
- [ ] "Show All" stops at 500 items with message if ceiling hit
|
||||
- [ ] Change page → URL updates to `?page=X&size=Y`. Refresh → same page/size restored.
|
||||
- [ ] View toggle on Dashboard is independent from Library view toggle
|
||||
- [ ] Sidebar: max 5 shown, "Show more" expands, clicking a flow collapses and navigates
|
||||
- [ ] Collapse sidebar section → re-expand → list is back to 5 (not "show all")
|
||||
- [ ] Try to pin a 16th flow → toast about max limit, flow not pinned
|
||||
- [ ] AI Builder: Library page "Create New" → "Build with AI" opens modal (uses cached quota)
|
||||
- [ ] New user with zero flows: sees empty state with "Create your first flow" CTA
|
||||
- [ ] New user with zero favorites: sees "Star a flow to pin it here" message
|
||||
- [ ] During initial load: skeleton placeholders visible, no layout shift when data arrives
|
||||
- [ ] Pin button: screen reader announces "Add to favorites" / "Remove from favorites"
|
||||
- [ ] Build passes: `cd frontend && npm run build` with no errors
|
||||
263
docs/plans/archive/2026-02-20-frontend-standardization-prompt.md
Normal file
263
docs/plans/archive/2026-02-20-frontend-standardization-prompt.md
Normal file
@@ -0,0 +1,263 @@
|
||||
# Frontend Standardization: Full Audit & Fix
|
||||
|
||||
## Objective
|
||||
|
||||
Perform a comprehensive audit of the entire ResolutionFlow frontend and standardize four UI patterns that are currently inconsistent across the application. Create reusable components where they don't exist, then retrofit every page and component to use them. When done, the entire app should feel like one developer built every page.
|
||||
|
||||
Use every tool, agent, and skill at your disposal. Search every file. Don't ask — fix.
|
||||
|
||||
---
|
||||
|
||||
## Pattern 1: Loading States → Replace All Spinners with Skeleton Components
|
||||
|
||||
### The Problem
|
||||
|
||||
Loading states are implemented differently across the app. Some pages use a centered spinning circle, others use pulse-animated rectangles, and some use nothing. There is no shared reusable skeleton component.
|
||||
|
||||
### Current Inconsistencies to Find and Fix
|
||||
|
||||
**Spinner pattern (replace everywhere you find it):**
|
||||
```tsx
|
||||
// THIS PATTERN — find every instance and replace with skeletons
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
</div>
|
||||
```
|
||||
|
||||
**Known locations (search for more):**
|
||||
- `frontend/src/pages/SessionHistoryPage.tsx` — uses spinner
|
||||
- `frontend/src/pages/TreeLibraryPage.tsx` — uses spinner
|
||||
- `frontend/src/components/step-library/StepLibraryBrowser.tsx` — uses spinner or loading boolean
|
||||
- `frontend/src/pages/QuickStartPage.tsx` — uses spinner or loading state
|
||||
- `frontend/src/pages/TreeEditorPage.tsx` — check for loading state
|
||||
- `frontend/src/pages/SessionDetailPage.tsx` — check for loading state
|
||||
- `frontend/src/pages/TreeNavigationPage.tsx` — check for loading state
|
||||
- `frontend/src/pages/MyTreesPage.tsx` — check for loading state
|
||||
- Any other page or component with `isLoading` state
|
||||
|
||||
### What to Build
|
||||
|
||||
**Create `frontend/src/components/common/Skeleton.tsx`:**
|
||||
|
||||
A set of composable skeleton primitives:
|
||||
|
||||
```tsx
|
||||
// Base skeleton block with pulse animation
|
||||
export function Skeleton({ className }: { className?: string }) {
|
||||
return <div className={cn("animate-pulse rounded bg-muted", className)} />
|
||||
}
|
||||
|
||||
// Pre-built skeleton layouts for common patterns:
|
||||
export function SkeletonCard() { /* Card-shaped skeleton matching TreeGridView card dimensions */ }
|
||||
export function SkeletonRow() { /* Row-shaped skeleton matching TreeListView row dimensions */ }
|
||||
export function SkeletonTableRow() { /* Table row skeleton matching TreeTableView row */ }
|
||||
export function SkeletonText({ lines = 3 }: { lines?: number }) { /* Text block skeleton */ }
|
||||
```
|
||||
|
||||
### The Standard (enforce everywhere)
|
||||
|
||||
- **Page-level data loading:** Show skeleton placeholders that match the shape of the content that will appear. Grid pages get skeleton cards. List pages get skeleton rows. Detail pages get skeleton text blocks.
|
||||
- **Component-level loading:** Small inline skeletons (e.g., a single line for a name loading).
|
||||
- **Never show a centered spinner.** The only acceptable spinner is on a button that is performing an action (e.g., "Saving..." with a small inline spinner). Page/section loading always uses skeletons.
|
||||
- **Skeleton count should match expected content.** If a page typically shows 6 cards, show 6 skeleton cards. If a list shows 10 rows, show 10 skeleton rows.
|
||||
|
||||
### Audit Instructions
|
||||
|
||||
1. Search the entire `frontend/src/` directory for: `animate-spin`, `border-t-transparent`, `Loader2` (Lucide spinner icon), and any `isLoading` state variable.
|
||||
2. For every match, determine if it's a page/section loading state or a button action state.
|
||||
3. Replace all page/section loading states with appropriate skeleton components.
|
||||
4. Leave button-level spinners alone (e.g., "Saving..." on submit buttons is fine).
|
||||
|
||||
---
|
||||
|
||||
## Pattern 2: Empty States → Add Meaningful Messages + CTAs Everywhere
|
||||
|
||||
### The Problem
|
||||
|
||||
Empty states across the app are bare text with no guidance and no call to action. A new user sees "No sessions found." or "No trees found." and has no idea what to do next.
|
||||
|
||||
### Current Inconsistencies to Find and Fix
|
||||
|
||||
**Known bare empty states:**
|
||||
- `SessionHistoryPage.tsx`: `"No sessions found."` — no CTA
|
||||
- `TreeLibraryPage.tsx`: `"No trees found. Try adjusting your filters."` — has filter hint but no create CTA
|
||||
- `StepLibraryBrowser.tsx`: check for empty state pattern
|
||||
- `QuickStartPage.tsx`: check for empty state pattern
|
||||
- `MyTreesPage.tsx`: check for empty state pattern
|
||||
- Any page that renders a list and has a `length === 0` check
|
||||
|
||||
### What to Build
|
||||
|
||||
**Create `frontend/src/components/common/EmptyState.tsx`:**
|
||||
|
||||
```tsx
|
||||
interface EmptyStateProps {
|
||||
icon?: React.ReactNode // Optional icon above the message
|
||||
title: string // e.g., "No sessions yet"
|
||||
description?: string // e.g., "Start a troubleshooting session to see it here"
|
||||
action?: {
|
||||
label: string // e.g., "Start a Session"
|
||||
onClick: () => void
|
||||
}
|
||||
secondaryAction?: {
|
||||
label: string // e.g., "Clear filters"
|
||||
onClick: () => void
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### The Standard (enforce everywhere)
|
||||
|
||||
Every empty state must have:
|
||||
1. **A clear title** that says what's empty (not just "No results")
|
||||
2. **A description** that tells the user why it's empty or what to do
|
||||
3. **A primary CTA** (when applicable) that lets them take the obvious next action
|
||||
4. **A secondary action** (when applicable) for filter/search scenarios: "Clear filters" or "Try a different search"
|
||||
|
||||
**Context-specific empty states:**
|
||||
|
||||
| Page / Section | Title | Description | CTA |
|
||||
|---|---|---|---|
|
||||
| Session History (no sessions at all) | "No sessions yet" | "Start a troubleshooting session to see your history here" | "Browse Flows" → navigate to /trees |
|
||||
| Session History (filter returns empty) | "No matching sessions" | "Try adjusting your filters" | "Clear filters" → reset filter |
|
||||
| Tree Library (no trees at all) | "No flows available" | "Create your first troubleshooting flow to get started" | "Create Flow" → open create dropdown/navigate |
|
||||
| Tree Library (search returns empty) | "No flows match your search" | "Try different keywords or clear your filters" | "Clear search" → reset search |
|
||||
| My Trees (no authored trees) | "You haven't created any flows yet" | "Build a troubleshooting flow to guide your team" | "Create Flow" → open create dropdown |
|
||||
| Step Library (no steps) | "No steps found" | "Create your first reusable step" | "Create Step" → open create form |
|
||||
| Dashboard Favorites (no pins) | "No favorites yet" | "Star a flow to pin it here for quick access" | No button (action is contextual) |
|
||||
| Dashboard My Flows (no authored flows) | "You haven't created any flows yet" | "Build your first troubleshooting flow" | "Create your first flow" → open create dropdown |
|
||||
|
||||
### Audit Instructions
|
||||
|
||||
1. Search `frontend/src/` for: `length === 0`, `.length === 0`, `sessions.length`, `trees.length`, and any conditional rendering that shows text when a list is empty.
|
||||
2. For every match, check if the empty state has a title, description, and CTA.
|
||||
3. Replace bare text empty states with the `EmptyState` component using the appropriate context from the table above.
|
||||
4. If you find an empty state not listed in the table, follow the same pattern: clear title, helpful description, obvious next action.
|
||||
|
||||
---
|
||||
|
||||
## Pattern 3: Accessibility — Aria Labels on All Icon-Only Buttons
|
||||
|
||||
### The Problem
|
||||
|
||||
Some icon-only buttons have proper `aria-label` attributes (e.g., `ThemeToggle` uses `aria-label={`Switch to ${label} theme`}`), but most don't. Icon buttons without aria-labels are invisible to screen readers.
|
||||
|
||||
### The Gold Standard (already in the codebase)
|
||||
|
||||
From `ThemeToggle.tsx`:
|
||||
```tsx
|
||||
<button
|
||||
onClick={() => setTheme(value)}
|
||||
className={cn('rounded p-1.5 transition-colors', ...)}
|
||||
aria-label={`Switch to ${label} theme`}
|
||||
aria-pressed={theme === value}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</button>
|
||||
```
|
||||
|
||||
From `StepDetailModal.tsx`:
|
||||
```tsx
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-md p-1 hover:bg-accent"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
```
|
||||
|
||||
### The Standard (enforce everywhere)
|
||||
|
||||
Every `<button>` that contains only an icon (no visible text) MUST have:
|
||||
1. `aria-label="Descriptive action"` — describes what the button does, not what the icon looks like
|
||||
2. For toggle buttons: `aria-pressed={isActive}` (like the ThemeToggle pattern)
|
||||
3. For buttons inside clickable parent containers (cards, rows): `e.stopPropagation()` and `e.preventDefault()` in the onClick handler
|
||||
|
||||
**Common icon buttons to look for:**
|
||||
- Close buttons (X icon) → `aria-label="Close"`
|
||||
- Delete buttons (Trash icon) → `aria-label="Delete [thing]"`
|
||||
- Edit buttons (Pencil icon) → `aria-label="Edit [thing]"`
|
||||
- Filter clear buttons (X in a chip) → `aria-label="Remove [filter name] filter"`
|
||||
- Expand/collapse buttons (ChevronDown/Up) → `aria-label="Expand"` / `aria-label="Collapse"`
|
||||
- Copy buttons → `aria-label="Copy to clipboard"`
|
||||
- Pin/favorite buttons → `aria-label="Add to favorites"` / `aria-label="Remove from favorites"`
|
||||
- Sort buttons → `aria-label="Sort by [field]"`
|
||||
- Navigation arrows → `aria-label="Previous page"` / `aria-label="Next page"`
|
||||
- Menu/hamburger buttons → `aria-label="Open menu"`
|
||||
- Any icon-only button with `<SomeLucideIcon />` and no text sibling
|
||||
|
||||
### Audit Instructions
|
||||
|
||||
1. Search `frontend/src/` for all `<button` elements.
|
||||
2. For each button, check if it contains visible text content (a text node or a `<span>` with text).
|
||||
3. If the button contains ONLY an icon (Lucide component, SVG, or image) with no visible text, verify it has an `aria-label`.
|
||||
4. If `aria-label` is missing, add an appropriate one based on the button's purpose (check the onClick handler and surrounding context to determine what the button does).
|
||||
5. For toggle buttons (pin/unpin, expand/collapse, theme), add `aria-pressed` where appropriate.
|
||||
6. Also check for `<a>` tags that contain only icons — these need `aria-label` too.
|
||||
|
||||
---
|
||||
|
||||
## Pattern 4: Error Banners (Verification Only)
|
||||
|
||||
### The Current Pattern (already consistent — verify it stays that way)
|
||||
|
||||
```tsx
|
||||
{error && (
|
||||
<div className="mb-6 rounded-md bg-destructive/10 p-4 text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
### Audit Instructions
|
||||
|
||||
1. Search for all error display patterns in `frontend/src/`.
|
||||
2. Verify they all use the same `bg-destructive/10 p-4 text-destructive` pattern.
|
||||
3. If any page uses a different error display style, update it to match.
|
||||
4. Do NOT change this pattern — just verify consistency.
|
||||
|
||||
---
|
||||
|
||||
## Execution Order
|
||||
|
||||
1. **Create the reusable components first:**
|
||||
- `frontend/src/components/common/Skeleton.tsx`
|
||||
- `frontend/src/components/common/EmptyState.tsx`
|
||||
|
||||
2. **Audit and fix Pattern 1 (Loading Skeletons):**
|
||||
- Search for every spinner and `isLoading` pattern
|
||||
- Replace with appropriate skeleton components
|
||||
- Verify each page renders skeletons that match the shape of expected content
|
||||
|
||||
3. **Audit and fix Pattern 2 (Empty States):**
|
||||
- Search for every empty list/empty state pattern
|
||||
- Replace with `EmptyState` component using context-appropriate messaging
|
||||
- Ensure every empty state has at minimum a title and description
|
||||
|
||||
4. **Audit and fix Pattern 3 (Aria Labels):**
|
||||
- Search for every icon-only button
|
||||
- Add `aria-label` to every one that's missing it
|
||||
- Add `aria-pressed` to toggle buttons
|
||||
|
||||
5. **Verify Pattern 4 (Error Banners):**
|
||||
- Quick scan to confirm consistency
|
||||
- Fix any outliers
|
||||
|
||||
6. **Final verification:**
|
||||
- `cd frontend && npm run build` — must pass with zero errors
|
||||
- `cd frontend && npm run test` — must pass
|
||||
- Visually spot-check: no page should show a centered spinner anymore
|
||||
|
||||
---
|
||||
|
||||
## Rules
|
||||
|
||||
- Do NOT change any business logic. Only change presentation/UI patterns.
|
||||
- Do NOT change any API calls or data fetching logic.
|
||||
- Do NOT add new dependencies. Use only Tailwind CSS utilities and existing project patterns.
|
||||
- Do NOT change the error banner pattern — it's already correct.
|
||||
- DO use `cn()` from `@/lib/utils` for conditional class merging (existing project standard).
|
||||
- DO use Lucide icons (existing project standard). No `title` prop on Lucide — wrap in `<span title="...">` if tooltip is needed.
|
||||
- DO support dark mode in all new components (use Tailwind's `text-foreground`, `bg-muted`, `text-muted-foreground` etc., not hardcoded colors).
|
||||
- DO keep the new components simple. No over-engineering. The `Skeleton` and `EmptyState` components should be under 100 lines each.
|
||||
354
docs/plans/archive/2026-02-24-ai-builder-ux-improvements-plan.md
Normal file
354
docs/plans/archive/2026-02-24-ai-builder-ux-improvements-plan.md
Normal file
@@ -0,0 +1,354 @@
|
||||
# AI Builder UX Improvements Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Three frontend-only UX improvements: always-available Publish button, "Generate All" branches in the AI wizard, and rotating activity messages during generation.
|
||||
|
||||
**Architecture:** All changes are frontend-only. Feature 1 is a one-line fix in `TreeEditorPage.tsx`. Features 2 and 3 involve adding state to `aiFlowBuilderStore.ts` and updating `BranchDetailView.tsx` and `GeneratingAnimation.tsx`. No backend changes.
|
||||
|
||||
**Tech Stack:** React 19, TypeScript, Zustand, Tailwind CSS v3, Lucide React icons.
|
||||
|
||||
**Design doc:** `docs/plans/2026-02-24-ai-builder-ux-improvements.md`
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Fix Publish button — remove `!isDirty` gate
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/pages/TreeEditorPage.tsx` (line ~654)
|
||||
|
||||
**Step 1: Make the change**
|
||||
|
||||
Find this line in `TreeEditorPage.tsx`:
|
||||
```tsx
|
||||
disabled={isSaving || !isDirty || hasBlockingErrors}
|
||||
```
|
||||
Change to:
|
||||
```tsx
|
||||
disabled={isSaving || hasBlockingErrors}
|
||||
```
|
||||
That's the only change needed. The button's `title` text references "Ctrl+S when no errors" which is still accurate — leave it.
|
||||
|
||||
**Step 2: Verify build passes**
|
||||
|
||||
```bash
|
||||
cd frontend && npm run build 2>&1 | tail -20
|
||||
```
|
||||
Expected: no errors.
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/pages/TreeEditorPage.tsx
|
||||
git commit -m "fix: allow publishing clean drafts without requiring local edits"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Add rotating activity messages to `GeneratingAnimation`
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/ai-builder/GeneratingAnimation.tsx`
|
||||
|
||||
**Step 1: Read the current file**
|
||||
|
||||
Read `frontend/src/components/ai-builder/GeneratingAnimation.tsx` to understand current structure before changing it.
|
||||
|
||||
**Step 2: Rewrite with rotating messages**
|
||||
|
||||
Replace the entire file content with:
|
||||
|
||||
```tsx
|
||||
import { useEffect, useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const MESSAGES = [
|
||||
'Setting up your flow...',
|
||||
'Building diagnostic paths...',
|
||||
'Putting the pieces in place...',
|
||||
'Almost there...',
|
||||
] as const
|
||||
|
||||
const MESSAGE_DURATIONS = [4000, 8000, 8000, Infinity] // ms each message shows
|
||||
|
||||
interface GeneratingAnimationProps {
|
||||
branchContext?: { current: number; total: number }
|
||||
}
|
||||
|
||||
export function GeneratingAnimation({ branchContext }: GeneratingAnimationProps) {
|
||||
const [messageIndex, setMessageIndex] = useState(0)
|
||||
|
||||
// Reset and advance message on mount/remount
|
||||
useEffect(() => {
|
||||
setMessageIndex(0)
|
||||
let current = 0
|
||||
|
||||
const advance = () => {
|
||||
current += 1
|
||||
if (current < MESSAGES.length - 1) {
|
||||
setMessageIndex(current)
|
||||
timer = setTimeout(advance, MESSAGE_DURATIONS[current])
|
||||
} else {
|
||||
setMessageIndex(MESSAGES.length - 1)
|
||||
}
|
||||
}
|
||||
|
||||
let timer = setTimeout(advance, MESSAGE_DURATIONS[0])
|
||||
return () => clearTimeout(timer)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-4 py-10">
|
||||
{/* Spinner */}
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-primary" />
|
||||
|
||||
{/* Branch context (Generate All mode) */}
|
||||
{branchContext && (
|
||||
<p className="text-xs font-label uppercase tracking-wide text-muted-foreground">
|
||||
Branch {branchContext.current} of {branchContext.total}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Rotating message */}
|
||||
<p
|
||||
key={messageIndex}
|
||||
className={cn(
|
||||
'text-sm text-muted-foreground transition-opacity duration-500',
|
||||
)}
|
||||
>
|
||||
{MESSAGES[messageIndex]}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Verify build passes**
|
||||
|
||||
```bash
|
||||
cd frontend && npm run build 2>&1 | tail -20
|
||||
```
|
||||
Expected: no errors.
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/components/ai-builder/GeneratingAnimation.tsx
|
||||
git commit -m "feat: add rotating activity messages to generation loading state"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Add `generateAllBranchDetails` and cancel to the store
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/store/aiFlowBuilderStore.ts`
|
||||
|
||||
**Step 1: Read the current store**
|
||||
|
||||
Read `frontend/src/store/aiFlowBuilderStore.ts` fully to understand current state shape before modifying.
|
||||
|
||||
**Step 2: Add new state fields and actions**
|
||||
|
||||
Add to the `AIFlowBuilderState` interface (after `isLoading: boolean`):
|
||||
```tsx
|
||||
isGeneratingAll: boolean
|
||||
stopGeneratingAll: boolean
|
||||
generateAllBranchDetails: () => Promise<void>
|
||||
cancelGenerateAll: () => void
|
||||
```
|
||||
|
||||
Add to the initial state in `create()(...)` (after `isLoading: false`):
|
||||
```tsx
|
||||
isGeneratingAll: false,
|
||||
stopGeneratingAll: false,
|
||||
```
|
||||
|
||||
Add the two new actions after `assemble`:
|
||||
|
||||
```tsx
|
||||
generateAllBranchDetails: async () => {
|
||||
const { selectedBranches, generateBranchDetail } = get()
|
||||
const undetailed = selectedBranches.filter((b) => !b.steps)
|
||||
if (undetailed.length === 0) return
|
||||
|
||||
set({ isGeneratingAll: true, stopGeneratingAll: false, error: null })
|
||||
|
||||
for (const branch of undetailed) {
|
||||
if (get().stopGeneratingAll) break
|
||||
// Set currentBranchIndex so tabs show the active branch
|
||||
const idx = get().selectedBranches.findIndex((b) => b.name === branch.name)
|
||||
if (idx !== -1) set({ currentBranchIndex: idx })
|
||||
await generateBranchDetail(branch.name)
|
||||
// If generateBranchDetail set phase to 'error', stop
|
||||
if (get().phase === 'error') break
|
||||
}
|
||||
|
||||
set({ isGeneratingAll: false })
|
||||
},
|
||||
|
||||
cancelGenerateAll: () => {
|
||||
set({ stopGeneratingAll: true })
|
||||
},
|
||||
```
|
||||
|
||||
Also add `isGeneratingAll: false, stopGeneratingAll: false` to the `reset()` action's `set({...})` call.
|
||||
|
||||
**Step 3: Verify TypeScript compiles**
|
||||
|
||||
```bash
|
||||
cd frontend && npm run build 2>&1 | tail -20
|
||||
```
|
||||
Expected: no errors.
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/store/aiFlowBuilderStore.ts
|
||||
git commit -m "feat: add generateAllBranchDetails and cancelGenerateAll to AI builder store"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Update `BranchDetailView` with Generate All UI
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/ai-builder/BranchDetailView.tsx`
|
||||
|
||||
**Step 1: Read the current file**
|
||||
|
||||
Read `frontend/src/components/ai-builder/BranchDetailView.tsx` fully.
|
||||
|
||||
**Step 2: Add imports and pull new store state**
|
||||
|
||||
Add `Zap, Square` to the lucide-react import (Zap = lightning bolt for "Generate All", Square = stop).
|
||||
|
||||
Pull new state from store in the component:
|
||||
```tsx
|
||||
const {
|
||||
// existing...
|
||||
isGeneratingAll,
|
||||
generateAllBranchDetails,
|
||||
cancelGenerateAll,
|
||||
} = useAIFlowBuilderStore()
|
||||
```
|
||||
|
||||
**Step 3: Add Generate All / Stop button above branch tabs**
|
||||
|
||||
After the opening `<div className="flex flex-col gap-4">` and before the branch tabs div, add:
|
||||
|
||||
```tsx
|
||||
{/* Generate All / Stop control */}
|
||||
{(() => {
|
||||
const undetailedCount = selectedBranches.filter((b) => !b.steps).length
|
||||
if (undetailedCount === 0) return null
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{undetailedCount} branch{undetailedCount !== 1 ? 'es' : ''} need detail
|
||||
</span>
|
||||
{isGeneratingAll ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={cancelGenerateAll}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-red-400/30 bg-red-400/10 px-3 py-1.5 text-xs font-medium text-red-400 hover:bg-red-400/20"
|
||||
>
|
||||
<Square className="h-3 w-3" />
|
||||
Stop
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={generateAllBranchDetails}
|
||||
disabled={isLoading}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-gradient-brand px-3 py-1.5 text-xs font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
<Zap className="h-3 w-3" />
|
||||
Generate All
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
```
|
||||
|
||||
**Step 4: Disable individual controls during `isGeneratingAll`**
|
||||
|
||||
On the "Generate Detail" button (the primary one in the empty-state section):
|
||||
```tsx
|
||||
disabled={isLoading || isGeneratingAll}
|
||||
```
|
||||
|
||||
On the "Skip" button:
|
||||
```tsx
|
||||
disabled={isGeneratingAll}
|
||||
// also add: className includes opacity-50 when disabled
|
||||
```
|
||||
|
||||
On the "Regenerate" button:
|
||||
```tsx
|
||||
disabled={isLoading || isGeneratingAll}
|
||||
```
|
||||
|
||||
**Step 5: Pass `branchContext` to `GeneratingAnimation`**
|
||||
|
||||
The `GeneratingAnimation` is rendered when `phase === 'generating' && isLoading`. Update that render:
|
||||
|
||||
```tsx
|
||||
if (phase === 'generating' && isLoading) {
|
||||
return (
|
||||
<GeneratingAnimation
|
||||
branchContext={
|
||||
isGeneratingAll
|
||||
? {
|
||||
current: selectedBranches.filter((b) => b.steps).length + 1,
|
||||
total: selectedBranches.length,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Step 6: Verify build passes**
|
||||
|
||||
```bash
|
||||
cd frontend && npm run build 2>&1 | tail -20
|
||||
```
|
||||
Expected: no errors.
|
||||
|
||||
**Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/components/ai-builder/BranchDetailView.tsx
|
||||
git commit -m "feat: add Generate All button and per-branch progress to AI builder detail stage"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Push and verify
|
||||
|
||||
**Step 1: Push branch**
|
||||
|
||||
```bash
|
||||
git push
|
||||
```
|
||||
|
||||
**Step 2: Verify CI passes**
|
||||
|
||||
```bash
|
||||
gh pr checks 88 2>&1 | head -20
|
||||
```
|
||||
|
||||
Expected: all checks passing (or wait for them to run).
|
||||
|
||||
**Step 3: Manual smoke test checklist**
|
||||
|
||||
- [ ] Open a fresh AI-generated draft in the tree editor → Publish button is enabled
|
||||
- [ ] Open AI Flow Builder, complete foundation → scaffold → select branches
|
||||
- [ ] On detail stage: "Generate All" button is visible
|
||||
- [ ] Click "Generate All" → branches generate one at a time, tabs show progress, "Branch X of Y" appears in animation
|
||||
- [ ] "Stop" button appears during run, clicking it halts after current branch
|
||||
- [ ] Activity messages cycle: "Setting up your flow..." → "Building diagnostic paths..." → "Putting the pieces in place..." → "Almost there..."
|
||||
- [ ] Single-branch generate still works as before
|
||||
119
docs/plans/archive/2026-02-24-ai-builder-ux-improvements.md
Normal file
119
docs/plans/archive/2026-02-24-ai-builder-ux-improvements.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# AI Builder UX Improvements
|
||||
|
||||
> **Date:** 2026-02-24
|
||||
> **Branch:** frontend-standardization (PR #88)
|
||||
> **Status:** Approved for implementation
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Three focused UX improvements to the AI Flow Builder and tree editor:
|
||||
|
||||
1. **Publish always available** — remove unnecessary `!isDirty` gate on the Publish button
|
||||
2. **Generate All branches** — one-click auto-generation of all branch details sequentially
|
||||
3. **Honest activity messages** — replace spinner-only loading with rotating status messages
|
||||
|
||||
---
|
||||
|
||||
## Feature 1: Publish Button Always Available
|
||||
|
||||
### Problem
|
||||
The Publish button in `TreeEditorPage.tsx` is disabled when `!isDirty`. This prevents publishing a freshly AI-generated draft that landed in the editor already saved — the tree is valid and ready, but the button is greyed out because nothing has been locally changed.
|
||||
|
||||
This same issue affects any future import/ingestion pathway where a draft arrives pre-saved.
|
||||
|
||||
### Solution
|
||||
Remove `!isDirty` from the Publish button's `disabled` condition. Keep it disabled only for `isSaving || hasBlockingErrors`.
|
||||
|
||||
The Save Draft button retains its `!isDirty` guard (no reason to re-save an unchanged draft).
|
||||
|
||||
### Change
|
||||
**File:** `frontend/src/pages/TreeEditorPage.tsx`
|
||||
**Line:** ~654
|
||||
Before: `disabled={isSaving || !isDirty || hasBlockingErrors}`
|
||||
After: `disabled={isSaving || hasBlockingErrors}`
|
||||
|
||||
---
|
||||
|
||||
## Feature 2: Generate All Branches
|
||||
|
||||
### Problem
|
||||
With 4–7 branches, users must click "Generate Detail" for each branch individually, wait for it to complete, then click the next. For 6 branches this is 6 separate interactions with wait time between each.
|
||||
|
||||
### Solution
|
||||
Add a "Generate All" button that auto-generates all undetailed branches sequentially without user intervention.
|
||||
|
||||
### Store changes (`aiFlowBuilderStore.ts`)
|
||||
Add:
|
||||
- `isGeneratingAll: boolean` — true while auto-run is in progress
|
||||
- `stopGeneratingAll: boolean` — flag set by user to cancel mid-run
|
||||
- `generateAllBranchDetails(): Promise<void>` — sequential loop
|
||||
- `cancelGenerateAll(): void` — sets stop flag
|
||||
|
||||
`generateAllBranchDetails()` logic:
|
||||
1. Set `isGeneratingAll: true`, `stopGeneratingAll: false`
|
||||
2. Loop through `selectedBranches` in order, skipping branches that already have `steps`
|
||||
3. For each: set `currentBranchIndex` to the active branch (so tabs show live progress), call `generateBranchDetail(branch.name)`
|
||||
4. After each call, check `stopGeneratingAll` — if true, break
|
||||
5. On `generateBranchDetail` failure: stop loop, set `isGeneratingAll: false`, leave error state on that branch (existing error handling handles display)
|
||||
6. On complete: set `isGeneratingAll: false`
|
||||
|
||||
### UI changes (`BranchDetailView.tsx`)
|
||||
- Add "Generate All" button above branch tabs, visible when ≥1 branch has no `steps` and `!isGeneratingAll`
|
||||
- During a run: button becomes "Stop" (calls `cancelGenerateAll`)
|
||||
- Individual "Generate Detail" and "Skip" buttons disabled during `isGeneratingAll`
|
||||
- Branch tabs show `currentBranchIndex` highlight during run so user can see which is active
|
||||
- When `isGeneratingAll`, show "Branch X of Y" context in the generating animation
|
||||
|
||||
### Error handling
|
||||
On failure mid-run: stop at the failed branch, show the existing error indicator on that branch tab (red instead of green check), display the error message. User can retry that branch individually or skip it. No global abort.
|
||||
|
||||
---
|
||||
|
||||
## Feature 3: Honest Activity Messages
|
||||
|
||||
### Problem
|
||||
`GeneratingAnimation` shows a spinner with static text. For waits of 5–30+ seconds this gives no sense of progress.
|
||||
|
||||
### Solution
|
||||
Replace the static text in `GeneratingAnimation` with rotating messages tied to elapsed time. Messages are always-true descriptions of what the system is doing at a high level.
|
||||
|
||||
### Message sequence
|
||||
| Elapsed | Message |
|
||||
|---------|---------|
|
||||
| 0–4s | "Setting up your flow..." |
|
||||
| 4–12s | "Building diagnostic paths..." |
|
||||
| 12–20s | "Putting the pieces in place..." |
|
||||
| 20s+ | "Almost there..." |
|
||||
|
||||
Messages advance on a timer that resets each time generation starts. They use `useEffect` with `setInterval` — no new dependencies needed.
|
||||
|
||||
### Generate All context
|
||||
When `isGeneratingAll` is true, show "Branch X of Y" as a small label above the rotating message. This comes from the store's `currentBranchIndex` and `selectedBranches.length`.
|
||||
|
||||
### Change
|
||||
**File:** `frontend/src/components/ai-builder/GeneratingAnimation.tsx`
|
||||
Replace static text with a `useEffect`-driven message cycler. Accept optional `branchContext?: { current: number; total: number }` prop.
|
||||
|
||||
`BranchDetailView.tsx` passes `branchContext` when `isGeneratingAll` is true.
|
||||
|
||||
---
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `frontend/src/pages/TreeEditorPage.tsx` | Remove `!isDirty` from Publish disabled condition |
|
||||
| `frontend/src/store/aiFlowBuilderStore.ts` | Add `isGeneratingAll`, `stopGeneratingAll`, `generateAllBranchDetails`, `cancelGenerateAll` |
|
||||
| `frontend/src/components/ai-builder/BranchDetailView.tsx` | Add Generate All / Stop button, disable controls during run, pass branch context |
|
||||
| `frontend/src/components/ai-builder/GeneratingAnimation.tsx` | Add rotating activity messages with elapsed timer, accept branchContext prop |
|
||||
|
||||
No backend changes required.
|
||||
|
||||
---
|
||||
|
||||
## Non-Goals
|
||||
- SSE/streaming status from backend (deferred, would be needed for accurate retry messaging)
|
||||
- Parallel branch generation (rate limit risk, sequential is safer and shows progress)
|
||||
- Persisting generate-all state across modal close (not needed, user can re-run)
|
||||
178
docs/plans/archive/2026-02-24-procedural-custom-steps-design.md
Normal file
178
docs/plans/archive/2026-02-24-procedural-custom-steps-design.md
Normal file
@@ -0,0 +1,178 @@
|
||||
# Procedural Custom Steps — Design
|
||||
|
||||
> **Date:** February 24, 2026
|
||||
> **Status:** Approved
|
||||
> **Phase:** 2.5 — Feature 2 of 3
|
||||
|
||||
---
|
||||
|
||||
## What We're Building
|
||||
|
||||
Add "Add Custom Step" support to `ProceduralNavigationPage` so engineers can insert ad-hoc steps between any existing checklist items during execution. The inserted step appears inline in the checklist and detail panel — worked through just like a regular step before proceeding.
|
||||
|
||||
---
|
||||
|
||||
## Key Difference from Troubleshooting Custom Steps
|
||||
|
||||
`useCustomStepFlow` is built for tree-graph navigation: `currentNodeId`, `findNode`, `setCurrentNodeId`, path traversal, continuation modal for picking descendants, custom branch mode. **None of that applies here.**
|
||||
|
||||
Procedural flows are linear arrays. A custom step is just a new `ProceduralStep`-shaped object injected at a position in the array. No new hook needed — all state lives in `ProceduralNavigationPage`.
|
||||
|
||||
---
|
||||
|
||||
## Data Model
|
||||
|
||||
Custom steps are stored in session `custom_steps` (already a JSONB array on `Session`). The existing `CustomStep` type is:
|
||||
|
||||
```ts
|
||||
interface CustomStep {
|
||||
id: string
|
||||
inserted_after_node_id: string // step ID it was inserted after
|
||||
step_data: Step | CustomStepDraft
|
||||
timestamp: string
|
||||
}
|
||||
```
|
||||
|
||||
For procedural flows, `inserted_after_node_id` is the `ProceduralStep.id` of the step it follows.
|
||||
|
||||
A custom step is represented in the runtime `ProceduralStep[]` as:
|
||||
|
||||
```ts
|
||||
{
|
||||
id: customStep.id, // the CustomStep UUID
|
||||
type: 'procedure_step',
|
||||
title: step_data.title,
|
||||
description: step_data.content.instructions,
|
||||
content_type: 'action',
|
||||
commands: step_data.content.commands (mapped),
|
||||
// marker for custom steps:
|
||||
_isCustom: true // not in ProceduralStep type — use a local union
|
||||
}
|
||||
```
|
||||
|
||||
Rather than mutating `ProceduralStep`, we use a local discriminated union:
|
||||
|
||||
```ts
|
||||
type RuntimeStep = ProceduralStep | CustomProceduralStep
|
||||
|
||||
interface CustomProceduralStep {
|
||||
id: string
|
||||
type: 'procedure_step'
|
||||
title: string
|
||||
description?: string
|
||||
content_type: 'action'
|
||||
commands?: CommandBlock[]
|
||||
isCustom: true // discriminant
|
||||
}
|
||||
```
|
||||
|
||||
The `procedureSteps` array used for rendering becomes `RuntimeStep[]` instead of `ProceduralStep[]`.
|
||||
|
||||
---
|
||||
|
||||
## User Flow
|
||||
|
||||
1. Engineer is on any step in a procedural flow
|
||||
2. Clicks **"+ Add Step"** button below the current step detail
|
||||
3. `CustomStepModal` opens (existing component — Create tab + Browse Library tab)
|
||||
4. Engineer creates or selects a step → `PostStepActionModal` appears
|
||||
5. **"Use Now"**: inserts after current step, closes modals, advances to the new step
|
||||
6. **"Save for Later"**: saves to library only, no insertion
|
||||
7. **"Do Both"**: saves to library + inserts
|
||||
|
||||
The inserted step renders in `StepDetail` and `StepChecklist` with a visual "Custom" badge. The engineer marks it complete the same way as any other step (the "Mark Complete" button).
|
||||
|
||||
No continuation modal. No custom branch mode. No fork flow.
|
||||
|
||||
---
|
||||
|
||||
## What Changes
|
||||
|
||||
### ProceduralNavigationPage.tsx
|
||||
|
||||
**New state:**
|
||||
```ts
|
||||
const [runtimeSteps, setRuntimeSteps] = useState<RuntimeStep[]>([])
|
||||
const [showCustomStepModal, setShowCustomStepModal] = useState(false)
|
||||
const [showPostStepModal, setShowPostStepModal] = useState(false)
|
||||
const [pendingCustomStep, setPendingCustomStep] = useState<Step | CustomStepDraft | null>(null)
|
||||
const [pendingIsFromLibrary, setPendingIsFromLibrary] = useState(false)
|
||||
const [isSavingStep, setIsSavingStep] = useState(false)
|
||||
const [sessionCustomSteps, setSessionCustomSteps] = useState<CustomStep[]>([])
|
||||
```
|
||||
|
||||
**`runtimeSteps`** is initialized from `tree.tree_structure.steps` and updated when custom steps are inserted. `procedureSteps` (currently `steps.filter(s => s.type === 'procedure_step')`) becomes `runtimeSteps.filter(...)`.
|
||||
|
||||
**`handleInsertCustomStep(step, isFromLibrary)`:**
|
||||
- Builds a `CustomProceduralStep` from the draft/step
|
||||
- Inserts it into `runtimeSteps` after `currentStepIndex`
|
||||
- Adds a `CustomStep` entry to `sessionCustomSteps`
|
||||
- Calls `sessionsApi.update(session.id, { custom_steps: newCustomSteps })`
|
||||
- Advances `currentStepIndex` by 1 (focus moves to the new step)
|
||||
|
||||
**`handleStepCreated(step, isFromLibrary)`:** — same pattern as troubleshooting
|
||||
- Sets `pendingCustomStep`, `pendingIsFromLibrary`
|
||||
- Closes `CustomStepModal`, opens `PostStepActionModal`
|
||||
|
||||
**Add `handleSaveForLater`, `handleUseNow`, `handleBoth`** — same logic as `useCustomStepFlow` but without path/node navigation.
|
||||
|
||||
**"+ Add Step" button:** Renders in the right panel below `StepDetail`, above `StepFeedback`. Only shown on the current active (incomplete) step. Not shown on already-completed steps or on custom steps (no nesting).
|
||||
|
||||
**Session resume:** On `resumeSession`, initialize `runtimeSteps` from the tree steps then inject custom steps from `sessionData.custom_steps` at the correct positions.
|
||||
|
||||
### StepChecklist.tsx
|
||||
|
||||
Accept `RuntimeStep[]` instead of `ProceduralStep[]`. Render a small "Custom" badge (amber dot or label) next to custom step titles.
|
||||
|
||||
### StepDetail.tsx
|
||||
|
||||
Accept `RuntimeStep` instead of `ProceduralStep`. When `step.isCustom === true`, render slightly differently: no `content_type` badge (use a plain "Custom Step" label instead), show instructions as description, render commands if present using the existing command block renderer.
|
||||
|
||||
### New type: `RuntimeStep`
|
||||
|
||||
Add to `frontend/src/types/tree.ts` (or a separate `procedural.ts`):
|
||||
|
||||
```ts
|
||||
export interface CustomProceduralStep {
|
||||
id: string
|
||||
type: 'procedure_step'
|
||||
title: string
|
||||
description?: string
|
||||
content_type: 'action'
|
||||
commands?: CommandBlock[]
|
||||
isCustom: true
|
||||
}
|
||||
|
||||
export type RuntimeStep = ProceduralStep | CustomProceduralStep
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## What Does NOT Change
|
||||
|
||||
- `useCustomStepFlow` — not used at all in procedural flows
|
||||
- `ContinuationModal` — not used (no branching)
|
||||
- Fork flow — not used
|
||||
- `CustomStepModal` — reused as-is (already works for both create and browse)
|
||||
- `PostStepActionModal` — reused as-is
|
||||
- `sessionsApi.update` with `custom_steps` — already supports this
|
||||
- Backend — no changes needed
|
||||
|
||||
---
|
||||
|
||||
## Checklist render (sidebar)
|
||||
|
||||
Custom steps show in the checklist with an amber `✦` or small "Custom" chip:
|
||||
|
||||
```
|
||||
✅ 1. Check service status
|
||||
✅ 2. Restart broker agent
|
||||
3. [✦ Custom] Verify VDA re-registration ← custom step
|
||||
4. Check event log
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Completion behavior
|
||||
|
||||
When the engineer marks the last custom step complete and it was inserted before the last regular step, `currentStepIndex` increments normally — they continue with the remaining regular steps. The total step count in the progress bar updates when custom steps are inserted.
|
||||
578
docs/plans/archive/2026-02-24-procedural-custom-steps-plan.md
Normal file
578
docs/plans/archive/2026-02-24-procedural-custom-steps-plan.md
Normal file
@@ -0,0 +1,578 @@
|
||||
# Procedural Custom Steps — Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Add "Add Custom Step" support to `ProceduralNavigationPage` so engineers can insert ad-hoc steps between existing checklist items during a session.
|
||||
|
||||
**Architecture:** Introduce a `RuntimeStep = ProceduralStep | CustomProceduralStep` discriminated union type. `ProceduralNavigationPage` grows new state + handlers for the custom-step modal flow (reusing `CustomStepModal` and `PostStepActionModal` as-is). `StepChecklist` and `StepDetail` accept `RuntimeStep` instead of `ProceduralStep`. No backend changes needed.
|
||||
|
||||
**Tech Stack:** React 19, TypeScript, Tailwind CSS, existing `sessionsApi.update`, `CustomStepModal`, `PostStepActionModal`
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add `RuntimeStep` union type
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/types/tree.ts`
|
||||
- Modify: `frontend/src/types/index.ts` (re-export if needed)
|
||||
|
||||
**Context:** `ProceduralStep` is already in `tree.ts` along with `CommandBlock`. The new `CustomProceduralStep` interface lives in the same file. No hook needed — this is purely a type.
|
||||
|
||||
**Step 1: Add the types at the bottom of `frontend/src/types/tree.ts`**
|
||||
|
||||
After the existing `ProceduralStep` interface (line ~124), add:
|
||||
|
||||
```ts
|
||||
export interface CustomProceduralStep {
|
||||
id: string
|
||||
type: 'procedure_step'
|
||||
title: string
|
||||
description?: string
|
||||
content_type: 'action'
|
||||
commands?: CommandBlock[]
|
||||
isCustom: true
|
||||
}
|
||||
|
||||
export type RuntimeStep = ProceduralStep | CustomProceduralStep
|
||||
```
|
||||
|
||||
**Step 2: Export from `frontend/src/types/index.ts`**
|
||||
|
||||
Check what is currently re-exported from `tree.ts` in `index.ts` and add `CustomProceduralStep` and `RuntimeStep` to the export list.
|
||||
|
||||
Run: `cd frontend && npm run build 2>&1 | tail -20`
|
||||
Expected: no new type errors
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/types/tree.ts frontend/src/types/index.ts
|
||||
git commit -m "feat: add RuntimeStep union type for procedural custom steps
|
||||
|
||||
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Update `StepChecklist` to accept `RuntimeStep[]`
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/procedural/StepChecklist.tsx`
|
||||
|
||||
**Context:** Currently `StepChecklistProps.steps` is `ProceduralStep[]`. It filters internally for `type === 'procedure_step'`. Custom steps already have `type: 'procedure_step'` so the filter still works. We need to:
|
||||
1. Change prop type to `RuntimeStep[]`
|
||||
2. Render an amber "Custom" badge next to custom step titles
|
||||
|
||||
**Step 1: Update the import and prop type**
|
||||
|
||||
Change:
|
||||
```tsx
|
||||
import type { ProceduralStep } from '@/types'
|
||||
```
|
||||
To:
|
||||
```tsx
|
||||
import type { RuntimeStep } from '@/types'
|
||||
```
|
||||
|
||||
Change `StepChecklistProps`:
|
||||
```tsx
|
||||
interface StepChecklistProps {
|
||||
steps: RuntimeStep[]
|
||||
currentStepIndex: number
|
||||
completedStepIds: Set<string>
|
||||
onStepClick: (index: number) => void
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Add the "Custom" badge in the step row**
|
||||
|
||||
In the button's `<span className="min-w-0 flex-1 truncate">` row, add a badge after the title when `'isCustom' in step && step.isCustom`:
|
||||
|
||||
```tsx
|
||||
<span className="min-w-0 flex-1 flex items-center gap-1.5 truncate">
|
||||
<span className="truncate">{step.title || 'Untitled step'}</span>
|
||||
{'isCustom' in step && step.isCustom && (
|
||||
<span className="shrink-0 rounded-full bg-amber-400/15 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wide text-amber-400">
|
||||
Custom
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
```
|
||||
|
||||
**Step 3: Build check**
|
||||
|
||||
Run: `cd frontend && npm run build 2>&1 | tail -20`
|
||||
Expected: no new errors
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/components/procedural/StepChecklist.tsx
|
||||
git commit -m "feat: StepChecklist accepts RuntimeStep[], renders amber Custom badge
|
||||
|
||||
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Update `StepDetail` to accept `RuntimeStep`
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/procedural/StepDetail.tsx`
|
||||
|
||||
**Context:** `StepDetail` currently only accepts `ProceduralStep`. For `CustomProceduralStep`, we skip the `content_type` badge (show "Custom Step" label instead), hide `warning_text`/`expected_outcome`/`verification`/`reference_url` sections (they don't exist on custom steps), and show description + commands normally. The `canComplete()` check and Mark Complete button stay the same.
|
||||
|
||||
**Step 1: Update import and prop type**
|
||||
|
||||
```tsx
|
||||
import type { RuntimeStep, CommandBlock } from '@/types'
|
||||
```
|
||||
|
||||
Change `StepDetailProps`:
|
||||
```tsx
|
||||
interface StepDetailProps {
|
||||
step: RuntimeStep
|
||||
// ... rest unchanged
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Handle custom step header (replace content_type badge block)**
|
||||
|
||||
The existing header block uses `step.content_type` to pick a config. Add a guard:
|
||||
|
||||
```tsx
|
||||
// At top of component body, after existing state:
|
||||
const isCustom = 'isCustom' in step && step.isCustom
|
||||
|
||||
// In the header JSX, replace the content_type badge span:
|
||||
{isCustom ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-amber-400/15 px-2 py-0.5 text-xs text-amber-400">
|
||||
✦ Custom Step
|
||||
</span>
|
||||
) : (
|
||||
<span className={cn('inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs', config.bg, config.color)}>
|
||||
<Icon className="h-3 w-3" />
|
||||
{config.label}
|
||||
</span>
|
||||
)}
|
||||
```
|
||||
|
||||
Note: `contentType` and `config` are still computed but only used in the non-custom branch. TypeScript will be happy because `ProceduralStep` has `content_type` and custom step skips that branch.
|
||||
|
||||
**Step 3: Guard sections that don't exist on custom steps**
|
||||
|
||||
Sections that need guarding (wrap with `!isCustom &&`):
|
||||
- `{step.warning_text && ...}` — already implicitly guarded since `CustomProceduralStep` has no `warning_text`, but TypeScript may complain → add `!isCustom &&` before the expression
|
||||
- `{step.expected_outcome && ...}` — same
|
||||
- `{verificationPrompt && ...}` — same (derive `verificationPrompt` with `!isCustom &&` check)
|
||||
- `{step.reference_url && ...}` — same
|
||||
|
||||
The description block, commandBlocks block, and notes block all work fine as-is (custom steps have `description?` and `commands?` matching the normalized shapes).
|
||||
|
||||
Specifically, for `verificationPrompt` / `verificationType`:
|
||||
```tsx
|
||||
const verificationPrompt = isCustom ? undefined : (step.verification_prompt || step.verification?.prompt)
|
||||
const verificationType = isCustom ? undefined : (step.verification_type || step.verification?.type)
|
||||
```
|
||||
|
||||
For `commandBlocks` normalization — `CustomProceduralStep.commands` is `CommandBlock[]` already, so the existing ternary handles it fine.
|
||||
|
||||
**Step 4: Build check**
|
||||
|
||||
Run: `cd frontend && npm run build 2>&1 | tail -20`
|
||||
Expected: no errors
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/components/procedural/StepDetail.tsx
|
||||
git commit -m "feat: StepDetail accepts RuntimeStep, renders Custom Step badge for custom steps
|
||||
|
||||
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Wire custom step flow into `ProceduralNavigationPage`
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/pages/ProceduralNavigationPage.tsx`
|
||||
|
||||
**Context:** This is the main task. Current page has no custom step support. We add:
|
||||
- `runtimeSteps: RuntimeStep[]` state (replaces `procedureSteps` derived value)
|
||||
- `sessionCustomSteps: CustomStep[]` state
|
||||
- `showCustomStepModal`, `showPostStepModal`, `pendingCustomStep`, `pendingIsFromLibrary`, `isSavingStep` state
|
||||
- `handleStepCreated` — closes CustomStepModal, opens PostStepActionModal
|
||||
- `handleInsertCustomStep` — builds CustomProceduralStep, inserts into runtimeSteps, persists
|
||||
- `handleSaveForLater` / `handleUseNow` / `handleBoth` — wire PostStepActionModal buttons
|
||||
- "Add Step" button below StepDetail, above StepFeedback
|
||||
- Resume: inject custom steps at correct positions
|
||||
|
||||
**Step 1: Add new imports**
|
||||
|
||||
```tsx
|
||||
import { CustomStepModal } from '@/components/step-library/CustomStepModal'
|
||||
import { PostStepActionModal } from '@/components/session/PostStepActionModal'
|
||||
import type { RuntimeStep, CustomProceduralStep } from '@/types'
|
||||
import type { CustomStep } from '@/types/session'
|
||||
import type { Step } from '@/types/step'
|
||||
import type { CustomStepDraft } from '@/components/step-library/CustomStepModal'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
```
|
||||
|
||||
Check if `uuid` package is already available: `grep -r "from 'uuid'" frontend/src/`. If not, use `crypto.randomUUID()` instead (available in all modern browsers and Node 16+).
|
||||
|
||||
**Step 2: Add new state (after existing state declarations)**
|
||||
|
||||
```tsx
|
||||
const [runtimeSteps, setRuntimeSteps] = useState<RuntimeStep[]>([])
|
||||
const [sessionCustomSteps, setSessionCustomSteps] = useState<CustomStep[]>([])
|
||||
const [showCustomStepModal, setShowCustomStepModal] = useState(false)
|
||||
const [showPostStepModal, setShowPostStepModal] = useState(false)
|
||||
const [pendingCustomStep, setPendingCustomStep] = useState<Step | CustomStepDraft | null>(null)
|
||||
const [pendingIsFromLibrary, setPendingIsFromLibrary] = useState(false)
|
||||
const [isSavingStep, setIsSavingStep] = useState(false)
|
||||
```
|
||||
|
||||
**Step 3: Replace `procedureSteps` derivation**
|
||||
|
||||
Currently:
|
||||
```tsx
|
||||
const procedureSteps = steps.filter((s) => s.type === 'procedure_step')
|
||||
```
|
||||
|
||||
Replace with:
|
||||
```tsx
|
||||
// runtimeSteps is the authoritative list; filter for rendering (excludes section_headers)
|
||||
const procedureSteps = runtimeSteps.filter((s) => s.type === 'procedure_step')
|
||||
```
|
||||
|
||||
Also update `estimatedTotalMinutes` — it only sums `estimated_minutes` which only exists on `ProceduralStep`. Cast safely:
|
||||
```tsx
|
||||
const estimatedTotalMinutes = procedureSteps.reduce(
|
||||
(sum, step) => sum + (('estimated_minutes' in step ? step.estimated_minutes : undefined) || 0),
|
||||
0
|
||||
)
|
||||
```
|
||||
|
||||
**Step 4: Initialize `runtimeSteps` in `startSession`**
|
||||
|
||||
After `setSession(newSession)`, add:
|
||||
```tsx
|
||||
// Initialize runtimeSteps from tree steps
|
||||
const allSteps = getStepsFromTree(tree!)
|
||||
setRuntimeSteps(allSteps)
|
||||
setSessionCustomSteps([])
|
||||
```
|
||||
|
||||
The existing step state initialization loop stays the same — custom steps will add to it when inserted.
|
||||
|
||||
**Step 5: Initialize `runtimeSteps` in `resumeSession`**
|
||||
|
||||
After loading `sessionData`, before computing `pSteps`:
|
||||
```tsx
|
||||
// Build runtimeSteps: start with tree steps, then inject custom steps
|
||||
const allSteps = getStepsFromTree(treeData)
|
||||
const customSteps = sessionData.custom_steps || []
|
||||
setSessionCustomSteps(customSteps)
|
||||
|
||||
// Inject custom steps at correct positions
|
||||
const hydrated = buildRuntimeSteps(allSteps, customSteps)
|
||||
setRuntimeSteps(hydrated)
|
||||
```
|
||||
|
||||
And update the `pSteps` / `firstIncomplete` calculation to use `hydrated` (not `allSteps`):
|
||||
```tsx
|
||||
const pSteps = hydrated.filter((s) => s.type === 'procedure_step')
|
||||
const firstIncomplete = pSteps.findIndex((s) => !initialStates.get(s.id)?.completedAt)
|
||||
setCurrentStepIndex(firstIncomplete >= 0 ? firstIncomplete : pSteps.length - 1)
|
||||
```
|
||||
|
||||
**Step 6: Add `buildRuntimeSteps` helper**
|
||||
|
||||
Add this function before the component (or as a module-level utility):
|
||||
|
||||
```ts
|
||||
function buildRuntimeSteps(baseSteps: ProceduralStep[], customSteps: CustomStep[]): RuntimeStep[] {
|
||||
const result: RuntimeStep[] = [...baseSteps]
|
||||
// Sort custom steps by timestamp so earlier insertions come first if multiple
|
||||
const sorted = [...customSteps].sort((a, b) => a.timestamp.localeCompare(b.timestamp))
|
||||
for (const cs of sorted) {
|
||||
// Find the index of the step this was inserted after
|
||||
const afterIdx = result.findIndex((s) => s.id === cs.inserted_after_node_id)
|
||||
const insertAt = afterIdx >= 0 ? afterIdx + 1 : result.length
|
||||
const runtimeCustom: CustomProceduralStep = {
|
||||
id: cs.id,
|
||||
type: 'procedure_step',
|
||||
title: cs.step_data.title,
|
||||
description: cs.step_data.content?.instructions,
|
||||
content_type: 'action',
|
||||
commands: cs.step_data.content?.commands?.map((c) => ({
|
||||
code: c.command,
|
||||
label: c.label,
|
||||
})),
|
||||
isCustom: true,
|
||||
}
|
||||
result.splice(insertAt, 0, runtimeCustom)
|
||||
}
|
||||
return result
|
||||
}
|
||||
```
|
||||
|
||||
Note: `ProceduralStep` needs to be imported in scope here. Since it's used in `getStepsFromTree` already, it's available.
|
||||
|
||||
**Step 7: Add `handleStepCreated`**
|
||||
|
||||
```tsx
|
||||
const handleStepCreated = (step: Step | CustomStepDraft, isFromLibrary: boolean) => {
|
||||
setPendingCustomStep(step)
|
||||
setPendingIsFromLibrary(isFromLibrary)
|
||||
setShowCustomStepModal(false)
|
||||
setShowPostStepModal(true)
|
||||
}
|
||||
```
|
||||
|
||||
**Step 8: Add `handleInsertCustomStep`**
|
||||
|
||||
```tsx
|
||||
const handleInsertCustomStep = async (step: Step | CustomStepDraft) => {
|
||||
if (!session) return
|
||||
|
||||
const id = crypto.randomUUID()
|
||||
const currentStep = procedureSteps[currentStepIndex]
|
||||
const insertedAfterId = currentStep?.id ?? ''
|
||||
|
||||
// Build the runtime representation
|
||||
const runtimeCustom: CustomProceduralStep = {
|
||||
id,
|
||||
type: 'procedure_step',
|
||||
title: step.title,
|
||||
description: step.content?.instructions,
|
||||
content_type: 'action',
|
||||
commands: step.content?.commands?.map((c) => ({
|
||||
code: c.command,
|
||||
label: c.label,
|
||||
})),
|
||||
isCustom: true,
|
||||
}
|
||||
|
||||
// Insert after currentStepIndex in runtimeSteps
|
||||
setRuntimeSteps((prev) => {
|
||||
const next = [...prev]
|
||||
// Find the global index of the current procedureStep in runtimeSteps
|
||||
const globalIdx = next.findIndex((s) => s.id === insertedAfterId)
|
||||
const insertAt = globalIdx >= 0 ? globalIdx + 1 : next.length
|
||||
next.splice(insertAt, 0, runtimeCustom)
|
||||
return next
|
||||
})
|
||||
|
||||
// Initialize step state for the new step
|
||||
setStepStates((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(id, { notes: '', verificationValue: '', completedAt: null })
|
||||
return next
|
||||
})
|
||||
|
||||
// Persist to session
|
||||
const newCustomStep: CustomStep = {
|
||||
id,
|
||||
inserted_after_node_id: insertedAfterId,
|
||||
step_data: step,
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
const newCustomSteps = [...sessionCustomSteps, newCustomStep]
|
||||
setSessionCustomSteps(newCustomSteps)
|
||||
|
||||
try {
|
||||
await sessionsApi.update(session.id, { custom_steps: newCustomSteps })
|
||||
} catch {
|
||||
toast.error('Failed to save custom step')
|
||||
}
|
||||
|
||||
// Advance to the new step (it's now at currentStepIndex + 1)
|
||||
setCurrentStepIndex(currentStepIndex + 1)
|
||||
}
|
||||
```
|
||||
|
||||
**Step 9: Add `handleSaveForLater`, `handleUseNow`, `handleBoth`**
|
||||
|
||||
```tsx
|
||||
const handleSaveForLater = async () => {
|
||||
if (!pendingCustomStep || pendingIsFromLibrary) return
|
||||
setIsSavingStep(true)
|
||||
try {
|
||||
await stepsApi.create({
|
||||
title: pendingCustomStep.title,
|
||||
step_type: pendingCustomStep.step_type,
|
||||
content: pendingCustomStep.content,
|
||||
visibility: 'private',
|
||||
})
|
||||
toast.success('Step saved to library')
|
||||
} catch {
|
||||
toast.error('Failed to save step')
|
||||
} finally {
|
||||
setIsSavingStep(false)
|
||||
setShowPostStepModal(false)
|
||||
setPendingCustomStep(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUseNow = async () => {
|
||||
if (!pendingCustomStep) return
|
||||
setShowPostStepModal(false)
|
||||
await handleInsertCustomStep(pendingCustomStep)
|
||||
setPendingCustomStep(null)
|
||||
}
|
||||
|
||||
const handleBoth = async () => {
|
||||
if (!pendingCustomStep || pendingIsFromLibrary) return
|
||||
setIsSavingStep(true)
|
||||
try {
|
||||
await stepsApi.create({
|
||||
title: pendingCustomStep.title,
|
||||
step_type: pendingCustomStep.step_type,
|
||||
content: pendingCustomStep.content,
|
||||
visibility: 'private',
|
||||
})
|
||||
} catch {
|
||||
toast.error('Failed to save step to library')
|
||||
} finally {
|
||||
setIsSavingStep(false)
|
||||
}
|
||||
setShowPostStepModal(false)
|
||||
await handleInsertCustomStep(pendingCustomStep)
|
||||
setPendingCustomStep(null)
|
||||
}
|
||||
```
|
||||
|
||||
Add `stepsApi` import at the top: `import { stepsApi } from '@/api/steps'`
|
||||
|
||||
**Step 10: Update `handleMarkComplete` to use `runtimeSteps`-based `procedureSteps`**
|
||||
|
||||
The existing `handleMarkComplete` reads `procedureSteps[currentStepIndex]` — since `procedureSteps` is now derived from `runtimeSteps`, this Just Works. But the completion check `currentStepIndex >= procedureSteps.length - 1` also works correctly.
|
||||
|
||||
No changes needed to `handleMarkComplete` itself.
|
||||
|
||||
**Step 11: Update `StepChecklist` call in JSX**
|
||||
|
||||
Change `steps={steps}` to `steps={runtimeSteps}`:
|
||||
```tsx
|
||||
<StepChecklist
|
||||
steps={runtimeSteps}
|
||||
currentStepIndex={currentStepIndex}
|
||||
completedStepIds={completedStepIds}
|
||||
onStepClick={setCurrentStepIndex}
|
||||
/>
|
||||
```
|
||||
|
||||
**Step 12: Update `StepDetail` call + add "Add Step" button**
|
||||
|
||||
Change `step={currentStep}` prop type is now `RuntimeStep` (TypeScript satisfied since `procedureSteps` is `RuntimeStep[]`).
|
||||
|
||||
Change `totalSteps={procedureSteps.length}` — still correct.
|
||||
|
||||
After the `StepDetail` closing tag and before `StepFeedback`, add the "Add Step" button:
|
||||
|
||||
```tsx
|
||||
{/* Add Custom Step button — only on current active (incomplete) step, not on custom steps */}
|
||||
{currentStep && !completedStepIds.has(currentStep.id) && !('isCustom' in currentStep && currentStep.isCustom) && (
|
||||
<div className="mt-4">
|
||||
<button
|
||||
onClick={() => setShowCustomStepModal(true)}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg border border-dashed border-border px-4 py-2.5 text-sm text-muted-foreground transition-colors hover:border-primary/50 hover:text-foreground"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Step
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
**Step 13: Add modals at the bottom of the JSX (before closing `</div>`)**
|
||||
|
||||
After the `ConfirmDialog` and parameters popover, add:
|
||||
|
||||
```tsx
|
||||
{/* Custom Step Modal */}
|
||||
<CustomStepModal
|
||||
isOpen={showCustomStepModal}
|
||||
onClose={() => setShowCustomStepModal(false)}
|
||||
onInsertStep={handleStepCreated}
|
||||
/>
|
||||
|
||||
{/* Post Step Action Modal */}
|
||||
{pendingCustomStep && (
|
||||
<PostStepActionModal
|
||||
isOpen={showPostStepModal}
|
||||
onClose={() => { setShowPostStepModal(false); setPendingCustomStep(null) }}
|
||||
step={pendingCustomStep}
|
||||
onSaveForLater={handleSaveForLater}
|
||||
onUseNow={handleUseNow}
|
||||
onBoth={handleBoth}
|
||||
isFromLibrary={pendingIsFromLibrary}
|
||||
isSaving={isSavingStep}
|
||||
/>
|
||||
)}
|
||||
```
|
||||
|
||||
**Step 14: ProgressBar total count update**
|
||||
|
||||
The `ProgressBar` already uses `procedureSteps.length` as `totalSteps` which is derived from `runtimeSteps` — so the progress bar total automatically updates when custom steps are inserted.
|
||||
|
||||
**Step 15: Build check**
|
||||
|
||||
Run: `cd frontend && npm run build 2>&1 | tail -30`
|
||||
Expected: no errors. Watch especially for:
|
||||
- `CustomStep` import (it's in `@/types/session`, not `@/types` — import directly if needed)
|
||||
- `buildRuntimeSteps` needing `ProceduralStep` type explicitly imported
|
||||
|
||||
**Step 16: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/pages/ProceduralNavigationPage.tsx
|
||||
git commit -m "feat: custom step insertion in procedural flow sessions
|
||||
|
||||
Engineers can add custom steps inline during execution. Steps are
|
||||
persisted to session.custom_steps and restored on resume.
|
||||
|
||||
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Manual verification checklist
|
||||
|
||||
**No code changes — verification only.**
|
||||
|
||||
Start a procedural session (or maintenance session). Verify:
|
||||
|
||||
1. **Add Step button appears** below the current step detail on an active, incomplete, non-custom step
|
||||
2. **Add Step button is hidden** on completed steps
|
||||
3. **Clicking Add Step** opens `CustomStepModal` with Create + Browse Library tabs
|
||||
4. **Creating a step** → `PostStepActionModal` appears
|
||||
5. **"Use Now"** → closes modal, custom step appears as next step in checklist with amber "Custom" badge, current view advances to it
|
||||
6. **"Save for Later"** → closes modal, nothing inserted, no crash
|
||||
7. **"Do Both"** → saves to library AND inserts
|
||||
8. **Mark Complete on custom step** → advances to next step normally
|
||||
9. **Custom step in StepDetail** → shows "Custom Step" amber badge instead of content_type badge
|
||||
10. **Progress bar total** increments when custom step is inserted
|
||||
11. **Resume session** (navigate away and back with `{ state: { sessionId } }`) → custom step reappears at correct position in checklist
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Final build validation
|
||||
|
||||
Run: `cd frontend && npm run build`
|
||||
Expected: build succeeds with zero errors (pre-existing chunk-size warnings are fine).
|
||||
|
||||
Commit build validation result if no issues:
|
||||
|
||||
```bash
|
||||
git add -p # stage any incidental fixes
|
||||
git commit -m "chore: final build validation for procedural custom steps
|
||||
|
||||
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
Only commit if there were actual changes to stage.
|
||||
175
docs/plans/archive/2026-02-24-step-library-page-design.md
Normal file
175
docs/plans/archive/2026-02-24-step-library-page-design.md
Normal file
@@ -0,0 +1,175 @@
|
||||
# Step Library Page — Design
|
||||
|
||||
> **Date:** February 24, 2026
|
||||
> **Status:** Approved
|
||||
> **Phase:** 2.5 — Feature 1 of 3
|
||||
|
||||
---
|
||||
|
||||
## What We're Building
|
||||
|
||||
Replace the "Coming Soon" stub in `StepLibraryPage` with a fully functional standalone Step Library. Users can browse, search, create, edit, delete, preview, and save steps to their own library.
|
||||
|
||||
All backend endpoints and frontend components are already built. This is primarily a wiring and UX integration task.
|
||||
|
||||
---
|
||||
|
||||
## Scope
|
||||
|
||||
**In scope:**
|
||||
- Full-page Step Library with header, filters, grouped results
|
||||
- Create step flow (modal with `StepForm`)
|
||||
- Edit step flow (same modal, pre-filled)
|
||||
- Delete step with confirmation
|
||||
- "Save to My Library" for steps you don't own (copies step as private)
|
||||
- Preview via `StepDetailModal`
|
||||
- `StepCard` actions adapted for library context (vs. session insert context)
|
||||
|
||||
**Out of scope:**
|
||||
- Rating/reviewing steps from this page (deferred — will come when steps are used in sessions)
|
||||
- Admin category management (already exists in Admin Panel)
|
||||
- Session custom step insertion (Feature 2 of 3)
|
||||
|
||||
---
|
||||
|
||||
## Card Actions by Context
|
||||
|
||||
| Ownership | Actions |
|
||||
|-----------|---------|
|
||||
| Your own step | Preview · Edit · Delete |
|
||||
| Team or public step | Preview · Save to My Library |
|
||||
|
||||
"Save to My Library" POSTs a new step copying the title, step_type, content, category, and tags — with `visibility: 'private'` and the current user as owner.
|
||||
|
||||
---
|
||||
|
||||
## Page Layout
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Step Library [+ Create Step] │
|
||||
│ Reusable steps you can insert into any flow │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ [Search...] [Category ▼] [Type ▼] [Rating ▼] [Sort▼]│
|
||||
│ Popular Tags: [powershell] [dns] [quick-fix] ... │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ MY STEPS (3) [▲] │
|
||||
│ ┌────────────┐ ┌────────────┐ │
|
||||
│ │ StepCard │ │ StepCard │ (Preview · Edit · Del) │
|
||||
│ └────────────┘ └────────────┘ │
|
||||
│ │
|
||||
│ TEAM STEPS (12) [▲] │
|
||||
│ ┌────────────┐ ┌────────────┐ │
|
||||
│ │ StepCard │ │ StepCard │ (Preview · Save) │
|
||||
│ └────────────┘ └────────────┘ │
|
||||
│ │
|
||||
│ COMMUNITY (47) [▲] │
|
||||
│ ┌────────────┐ ┌────────────┐ │
|
||||
│ │ StepCard │ │ StepCard │ (Preview · Save) │
|
||||
│ └────────────┘ └────────────┘ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Component Architecture
|
||||
|
||||
### StepLibraryPage (rewritten)
|
||||
- Owns modal state: `createOpen`, `editingStep`, `deletingStepId`
|
||||
- Renders page header with "+ Create Step" button
|
||||
- Renders `StepLibraryBrowser` with page-context props
|
||||
- Renders `StepFormModal` (create/edit)
|
||||
- Renders delete confirmation dialog
|
||||
|
||||
### StepLibraryBrowser (extend props)
|
||||
Add props:
|
||||
```ts
|
||||
interface StepLibraryBrowserProps {
|
||||
onInsert: (step: Step) => void // existing (used in session context)
|
||||
onEdit?: (step: Step) => void // new — library page only
|
||||
onDelete?: (stepId: string) => void // new — library page only
|
||||
onSave?: (step: Step) => void // new — save to my library
|
||||
onCreateNew?: () => void // existing
|
||||
showCreateButton?: boolean // existing
|
||||
currentUserId?: string // new — to determine ownership
|
||||
}
|
||||
```
|
||||
|
||||
### StepCard (extend props)
|
||||
Add props mirroring the browser:
|
||||
```ts
|
||||
interface StepCardProps {
|
||||
step: StepListItem
|
||||
onPreview: (step: StepListItem) => void
|
||||
onInsert?: (step: StepListItem) => void // optional — session context
|
||||
onEdit?: (step: StepListItem) => void // library context
|
||||
onDelete?: (stepId: string) => void // library context
|
||||
onSave?: (step: StepListItem) => void // library context
|
||||
currentUserId?: string
|
||||
}
|
||||
```
|
||||
|
||||
`StepCard` renders actions based on which callbacks are present and whether `step.created_by === currentUserId`.
|
||||
|
||||
### StepFormModal (new wrapper component)
|
||||
Thin modal shell around the existing `StepForm`:
|
||||
- Fixed header ("Create Step" or "Edit Step")
|
||||
- Scrollable body with `StepForm`
|
||||
- Handles create (`stepsApi.create`) and update (`stepsApi.update`) API calls
|
||||
- Calls `onSuccess(step)` so the browser list can refresh
|
||||
|
||||
---
|
||||
|
||||
## Data Flow
|
||||
|
||||
### Create
|
||||
1. User clicks "+ Create Step"
|
||||
2. `StepFormModal` opens in create mode
|
||||
3. On submit → `stepsApi.create(data)` → `onSuccess` → reload steps
|
||||
|
||||
### Edit
|
||||
1. User clicks Edit on their own step card
|
||||
2. `stepsApi.get(stepId)` fetches full step (need content fields)
|
||||
3. `StepFormModal` opens pre-filled with step data
|
||||
4. On submit → `stepsApi.update(id, data)` → `onSuccess` → reload steps
|
||||
|
||||
### Delete
|
||||
1. User clicks Delete on their own step card
|
||||
2. Confirmation dialog: "Delete '[title]'? This cannot be undone."
|
||||
3. On confirm → `stepsApi.delete(id)` → remove from local state
|
||||
|
||||
### Save to My Library
|
||||
1. User clicks "Save to My Library" on a team/public step
|
||||
2. No confirmation needed — silent copy
|
||||
3. `stepsApi.create({ ...step fields, visibility: 'private' })` → toast "Saved to My Steps"
|
||||
4. Reload steps so the copy appears under "My Steps"
|
||||
|
||||
---
|
||||
|
||||
## Files to Change
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `pages/StepLibraryPage.tsx` | Rewrite — wire up browser + modals |
|
||||
| `components/step-library/StepCard.tsx` | Add `onEdit`, `onDelete`, `onSave`, `currentUserId` props; render contextual buttons |
|
||||
| `components/step-library/StepLibraryBrowser.tsx` | Add `onEdit`, `onDelete`, `onSave`, `currentUserId` props; pass through to cards; expose refresh trigger |
|
||||
| `components/step-library/StepFormModal.tsx` | Create — thin modal wrapper around `StepForm` |
|
||||
|
||||
No backend changes required.
|
||||
|
||||
---
|
||||
|
||||
## StepCard Button Layout
|
||||
|
||||
**Own step:**
|
||||
```
|
||||
[Preview] [Edit] [🗑]
|
||||
```
|
||||
Edit and Preview are full-width-ish buttons; delete is an icon-only button in red on hover.
|
||||
|
||||
**Others' step:**
|
||||
```
|
||||
[Preview] [Save to My Library]
|
||||
```
|
||||
|
||||
Both full-width buttons, same pattern as current Insert layout.
|
||||
705
docs/plans/archive/2026-02-24-step-library-page-plan.md
Normal file
705
docs/plans/archive/2026-02-24-step-library-page-plan.md
Normal file
@@ -0,0 +1,705 @@
|
||||
# Step Library Page Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Wire up the existing Step Library components into a fully functional standalone page — replacing the "Coming Soon" stub — with create, edit, delete, preview, and save-to-library actions.
|
||||
|
||||
**Architecture:** `StepLibraryPage` owns all modal state and orchestrates four components: `StepLibraryBrowser` (list + filters), `StepFormModal` (new wrapper for create/edit), `StepDetailModal` (already exists), and a delete confirmation dialog. `StepCard` and `StepLibraryBrowser` get new optional props for library-page-specific actions. No new API calls beyond what already exists in `stepsApi`.
|
||||
|
||||
**Tech Stack:** React 19, TypeScript, Tailwind CSS, Zustand (`useAuthStore` for current user ID), `stepsApi` + `stepCategoriesApi` (all endpoints already wired to backend).
|
||||
|
||||
---
|
||||
|
||||
## Key Files Reference
|
||||
|
||||
- `frontend/src/pages/StepLibraryPage.tsx` — currently a "Coming Soon" stub; will be rewritten
|
||||
- `frontend/src/components/step-library/StepLibraryBrowser.tsx` — list + filters component
|
||||
- `frontend/src/components/step-library/StepCard.tsx` — individual step card
|
||||
- `frontend/src/components/step-library/StepDetailModal.tsx` — preview modal (already complete)
|
||||
- `frontend/src/components/step-library/StepForm.tsx` — create/edit form (already complete)
|
||||
- `frontend/src/api/steps.ts` — `stepsApi.create`, `.get`, `.update`, `.delete` already implemented
|
||||
- `frontend/src/types/step.ts` — `Step`, `StepListItem`, `StepCreate`, `StepUpdate` types
|
||||
- `frontend/src/store/authStore.ts` — use `useAuthStore((s) => s.user)` to get current user
|
||||
- `frontend/src/hooks/usePermissions.ts` — `canCreateSteps` already defined
|
||||
|
||||
---
|
||||
|
||||
## How to Get Current User ID
|
||||
|
||||
```tsx
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
const user = useAuthStore((s) => s.user)
|
||||
// user.id is the current user's UUID string
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Extend StepCard with library-page actions
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/step-library/StepCard.tsx`
|
||||
|
||||
This task adds `onEdit`, `onDelete`, `onSave`, and `currentUserId` props. When on the library page (these props are present), the action buttons change based on ownership.
|
||||
|
||||
**Step 1: Read the current file**
|
||||
|
||||
Already read above. Current interface:
|
||||
```ts
|
||||
interface StepCardProps {
|
||||
step: StepListItem
|
||||
onPreview: (step: StepListItem) => void
|
||||
onInsert: (step: StepListItem) => void
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Update the interface and button logic**
|
||||
|
||||
Replace the `StepCardProps` interface and the Actions section at the bottom of `StepCard.tsx`.
|
||||
|
||||
New interface (all new props are optional so existing `CustomStepModal` usage is unchanged):
|
||||
```tsx
|
||||
interface StepCardProps {
|
||||
step: StepListItem
|
||||
onPreview: (step: StepListItem) => void
|
||||
onInsert?: (step: StepListItem) => void // session context (existing)
|
||||
onEdit?: (step: StepListItem) => void // library page
|
||||
onDelete?: (stepId: string) => void // library page
|
||||
onSave?: (step: StepListItem) => void // library page (save copy)
|
||||
currentUserId?: string // to determine ownership
|
||||
}
|
||||
```
|
||||
|
||||
Replace the Actions section (the `<div className="flex gap-2">` at the bottom) with:
|
||||
|
||||
```tsx
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2">
|
||||
{/* Library page context */}
|
||||
{(onEdit || onDelete || onSave) ? (
|
||||
isOwn ? (
|
||||
// Own step: Preview + Edit + Delete icon
|
||||
<>
|
||||
<button
|
||||
onClick={() => onPreview(step)}
|
||||
className="flex flex-1 items-center justify-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
Preview
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onEdit?.(step)}
|
||||
className="flex flex-1 items-center justify-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDelete?.(step.id)}
|
||||
className="flex items-center justify-center rounded-md border border-border px-3 py-2 text-muted-foreground hover:bg-red-400/10 hover:text-red-400 hover:border-red-400/30 transition-colors"
|
||||
aria-label="Delete step"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
// Others' step: Preview + Save
|
||||
<>
|
||||
<button
|
||||
onClick={() => onPreview(step)}
|
||||
className="flex flex-1 items-center justify-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
Preview
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onSave?.(step)}
|
||||
className="flex flex-1 items-center justify-center gap-2 rounded-md bg-gradient-brand text-white shadow-lg shadow-primary/20 px-3 py-2 text-sm font-medium hover:opacity-90 transition-colors"
|
||||
>
|
||||
<Bookmark className="h-4 w-4" />
|
||||
Save
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
// Session context (original): Preview + Insert
|
||||
<>
|
||||
<button
|
||||
onClick={() => onPreview(step)}
|
||||
className="flex flex-1 items-center justify-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
Preview
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onInsert?.(step)}
|
||||
className="flex flex-1 items-center justify-center gap-2 rounded-md bg-gradient-brand text-white shadow-lg shadow-primary/20 px-3 py-2 text-sm font-medium hover:opacity-90 transition-colors"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Insert
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
```
|
||||
|
||||
Add `isOwn` derived value near top of the component function (before the return):
|
||||
```tsx
|
||||
const isOwn = currentUserId ? step.created_by === currentUserId : false
|
||||
```
|
||||
|
||||
Add new imports at top of file:
|
||||
```tsx
|
||||
import { Star, User, Calendar, TrendingUp, Eye, Plus, HelpCircle, Zap, CheckCircle, Pencil, Trash2, Bookmark } from 'lucide-react'
|
||||
```
|
||||
|
||||
**Step 3: Verify TypeScript compiles**
|
||||
|
||||
```bash
|
||||
cd /home/michaelchihlas/dev/patherly/frontend && npx tsc --noEmit 2>&1 | head -30
|
||||
```
|
||||
|
||||
Expected: no errors related to StepCard.
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
cd /home/michaelchihlas/dev/patherly
|
||||
git add frontend/src/components/step-library/StepCard.tsx
|
||||
git commit -m "feat: add library-page action props to StepCard (edit/delete/save)
|
||||
|
||||
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Extend StepLibraryBrowser with library-page props
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/step-library/StepLibraryBrowser.tsx`
|
||||
|
||||
Pass the new `onEdit`, `onDelete`, `onSave`, `currentUserId` props through from browser to each `StepCard`. Also expose a `refreshKey` prop so the page can trigger a reload after create/edit/delete/save.
|
||||
|
||||
**Step 1: Update the interface**
|
||||
|
||||
Current interface:
|
||||
```ts
|
||||
interface StepLibraryBrowserProps {
|
||||
onInsert: (step: Step) => void
|
||||
onCreateNew?: () => void
|
||||
showCreateButton?: boolean
|
||||
}
|
||||
```
|
||||
|
||||
New interface:
|
||||
```ts
|
||||
interface StepLibraryBrowserProps {
|
||||
onInsert?: (step: Step) => void // now optional (not needed on library page)
|
||||
onCreateNew?: () => void
|
||||
showCreateButton?: boolean
|
||||
onEdit?: (step: StepListItem) => void
|
||||
onDelete?: (stepId: string) => void
|
||||
onSave?: (step: StepListItem) => void
|
||||
currentUserId?: string
|
||||
refreshKey?: number // increment to trigger reload
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Wire refreshKey into the steps useEffect**
|
||||
|
||||
In the existing `useEffect` that calls `loadSteps`, add `refreshKey` to the dependency array:
|
||||
|
||||
```tsx
|
||||
useEffect(() => {
|
||||
const loadSteps = async () => { ... }
|
||||
loadSteps()
|
||||
}, [searchQuery, selectedCategoryId, selectedStepType, minRating, sortBy, selectedTag, refreshKey])
|
||||
```
|
||||
|
||||
**Step 3: Pass new props to StepCard**
|
||||
|
||||
In all three `groupedSteps.private/team/public` map blocks, update `StepCard` usage:
|
||||
|
||||
```tsx
|
||||
<StepCard
|
||||
key={step.id}
|
||||
step={step}
|
||||
onPreview={handlePreview}
|
||||
onInsert={onInsert ? handleInsertFromCard : undefined}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onSave={onSave}
|
||||
currentUserId={currentUserId}
|
||||
/>
|
||||
```
|
||||
|
||||
**Step 4: Verify TypeScript compiles**
|
||||
|
||||
```bash
|
||||
cd /home/michaelchihlas/dev/patherly/frontend && npx tsc --noEmit 2>&1 | head -30
|
||||
```
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
cd /home/michaelchihlas/dev/patherly
|
||||
git add frontend/src/components/step-library/StepLibraryBrowser.tsx
|
||||
git commit -m "feat: pass library-page action props through StepLibraryBrowser + refreshKey
|
||||
|
||||
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Create StepFormModal
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/components/step-library/StepFormModal.tsx`
|
||||
|
||||
A thin modal wrapper around the existing `StepForm`. Handles both create and edit modes.
|
||||
|
||||
**Step 1: Create the file**
|
||||
|
||||
```tsx
|
||||
import { useState } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { stepsApi } from '@/api/steps'
|
||||
import { StepForm } from './StepForm'
|
||||
import type { Step, StepCreate, StepListItem } from '@/types/step'
|
||||
|
||||
interface StepFormModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onSuccess: (step: Step) => void
|
||||
editingStep?: StepListItem | null // if set, edit mode; if null/undefined, create mode
|
||||
}
|
||||
|
||||
export function StepFormModal({ isOpen, onClose, onSuccess, editingStep }: StepFormModalProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const isEditMode = !!editingStep
|
||||
|
||||
const handleSubmit = async (data: StepCreate) => {
|
||||
setIsSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
let result: Step
|
||||
if (isEditMode && editingStep) {
|
||||
result = await stepsApi.update(editingStep.id, data)
|
||||
} else {
|
||||
result = await stepsApi.create(data)
|
||||
}
|
||||
onSuccess(result)
|
||||
} catch (err) {
|
||||
console.error('Failed to save step:', err)
|
||||
setError('Failed to save step. Please try again.')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Build initialData from editingStep for edit mode
|
||||
// StepListItem doesn't have `content`, so for edit we need to fetch full step
|
||||
// This is handled by the parent (StepLibraryPage fetches full step before opening modal)
|
||||
const initialData = editingStep ? {
|
||||
title: editingStep.title,
|
||||
step_type: editingStep.step_type as 'decision' | 'action' | 'solution',
|
||||
visibility: editingStep.visibility as 'private' | 'team' | 'public',
|
||||
category_id: editingStep.category_id,
|
||||
tags: editingStep.tags,
|
||||
} : undefined
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4">
|
||||
<div className="relative flex h-[90vh] w-full max-w-2xl flex-col bg-card border border-border rounded-2xl shadow-lg">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-border p-6 pb-4">
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
{isEditMode ? 'Edit Step' : 'Create Step'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={isSubmitting}
|
||||
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="mx-6 mt-4 rounded-lg border border-red-400/20 bg-red-400/10 p-3 text-sm text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<StepForm
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={onClose}
|
||||
initialData={initialData}
|
||||
submitLabel={isEditMode ? 'Save Changes' : 'Create Step'}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Update StepForm to accept `submitLabel` and `isSubmitting` props**
|
||||
|
||||
`StepForm` currently has a hardcoded submit button label ("Insert Step") and no loading state. Add these two optional props:
|
||||
|
||||
```ts
|
||||
interface StepFormProps {
|
||||
onSubmit: (data: StepCreate) => void
|
||||
onCancel: () => void
|
||||
initialData?: Partial<StepCreate>
|
||||
submitLabel?: string // default: 'Insert Step'
|
||||
isSubmitting?: boolean // default: false
|
||||
}
|
||||
```
|
||||
|
||||
In `StepForm`, use them on the submit button:
|
||||
```tsx
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="flex-1 rounded-md bg-gradient-brand text-white shadow-lg shadow-primary/20 px-4 py-2 text-sm font-medium hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? 'Saving...' : (submitLabel ?? 'Insert Step')}
|
||||
</button>
|
||||
```
|
||||
|
||||
**Step 3: Verify TypeScript compiles**
|
||||
|
||||
```bash
|
||||
cd /home/michaelchihlas/dev/patherly/frontend && npx tsc --noEmit 2>&1 | head -30
|
||||
```
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
cd /home/michaelchihlas/dev/patherly
|
||||
git add frontend/src/components/step-library/StepFormModal.tsx \
|
||||
frontend/src/components/step-library/StepForm.tsx
|
||||
git commit -m "feat: StepFormModal wrapper + submitLabel/isSubmitting props on StepForm
|
||||
|
||||
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Rewrite StepLibraryPage
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/pages/StepLibraryPage.tsx`
|
||||
|
||||
This is the main wiring task. Replace the stub with the full page.
|
||||
|
||||
**Step 1: Write the new page**
|
||||
|
||||
```tsx
|
||||
import { useState } from 'react'
|
||||
import { Bookmark, Trash2 } from 'lucide-react'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { usePermissions } from '@/hooks/usePermissions'
|
||||
import { stepsApi } from '@/api/steps'
|
||||
import { StepLibraryBrowser } from '@/components/step-library/StepLibraryBrowser'
|
||||
import { StepFormModal } from '@/components/step-library/StepFormModal'
|
||||
import type { Step, StepListItem } from '@/types/step'
|
||||
|
||||
export default function StepLibraryPage() {
|
||||
const user = useAuthStore((s) => s.user)
|
||||
const { canCreateSteps } = usePermissions()
|
||||
|
||||
// Modal state
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [editingStep, setEditingStep] = useState<StepListItem | null>(null)
|
||||
const [deletingStep, setDeletingStep] = useState<StepListItem | null>(null)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null)
|
||||
const [saveToast, setSaveToast] = useState<string | null>(null)
|
||||
|
||||
// Increment to trigger StepLibraryBrowser reload
|
||||
const [refreshKey, setRefreshKey] = useState(0)
|
||||
const refresh = () => setRefreshKey(k => k + 1)
|
||||
|
||||
const handleEdit = (step: StepListItem) => {
|
||||
setEditingStep(step)
|
||||
}
|
||||
|
||||
const handleDeleteRequest = (stepId: string) => {
|
||||
// Find the step in order to show its title in the confirmation
|
||||
// We store the StepListItem via the browser's onDelete callback
|
||||
// The step object is passed from StepCard which has the full StepListItem
|
||||
setDeletingStep({ id: stepId } as StepListItem)
|
||||
}
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!deletingStep) return
|
||||
setIsDeleting(true)
|
||||
setDeleteError(null)
|
||||
try {
|
||||
await stepsApi.delete(deletingStep.id)
|
||||
setDeletingStep(null)
|
||||
refresh()
|
||||
} catch (err) {
|
||||
console.error('Failed to delete step:', err)
|
||||
setDeleteError('Failed to delete step. Please try again.')
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async (step: StepListItem) => {
|
||||
try {
|
||||
// Fetch full step to get content fields
|
||||
const full = await stepsApi.get(step.id)
|
||||
await stepsApi.create({
|
||||
title: full.title,
|
||||
step_type: full.step_type,
|
||||
content: full.content,
|
||||
visibility: 'private',
|
||||
category_id: full.category_id,
|
||||
tags: full.tags,
|
||||
})
|
||||
setSaveToast(`"${full.title}" saved to My Steps`)
|
||||
setTimeout(() => setSaveToast(null), 3000)
|
||||
refresh()
|
||||
} catch (err) {
|
||||
console.error('Failed to save step:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFormSuccess = (_step: Step) => {
|
||||
setCreateOpen(false)
|
||||
setEditingStep(null)
|
||||
refresh()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Page Header */}
|
||||
<div className="flex items-center justify-between border-b border-border px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span title="Step Library">
|
||||
<Bookmark className="h-6 w-6 text-muted-foreground" />
|
||||
</span>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold font-heading text-foreground">Step Library</h1>
|
||||
<p className="text-sm text-muted-foreground">Reusable steps you can insert into any flow</p>
|
||||
</div>
|
||||
</div>
|
||||
{canCreateSteps && (
|
||||
<button
|
||||
onClick={() => setCreateOpen(true)}
|
||||
className="rounded-md bg-gradient-brand px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90"
|
||||
>
|
||||
+ Create Step
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Browser fills remaining height */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<StepLibraryBrowser
|
||||
onEdit={handleEdit}
|
||||
onDelete={(stepId) => {
|
||||
// We need the full StepListItem for the confirmation title.
|
||||
// Pass a minimal object; the title will show as "this step" if not available.
|
||||
setDeletingStep({ id: stepId, title: '' } as StepListItem)
|
||||
}}
|
||||
onSave={handleSave}
|
||||
currentUserId={user?.id}
|
||||
refreshKey={refreshKey}
|
||||
showCreateButton={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Create / Edit Modal */}
|
||||
<StepFormModal
|
||||
isOpen={createOpen || !!editingStep}
|
||||
onClose={() => { setCreateOpen(false); setEditingStep(null) }}
|
||||
onSuccess={handleFormSuccess}
|
||||
editingStep={editingStep}
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
{deletingStep && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4">
|
||||
<div className="w-full max-w-sm rounded-xl bg-card border border-border p-6 shadow-lg">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<div className="rounded-full bg-red-400/10 p-2">
|
||||
<Trash2 className="h-5 w-5 text-red-400" />
|
||||
</div>
|
||||
<h2 className="text-base font-semibold text-foreground">Delete Step</h2>
|
||||
</div>
|
||||
<p className="mb-2 text-sm text-muted-foreground">
|
||||
{deletingStep.title
|
||||
? <>Are you sure you want to delete <span className="font-medium text-foreground">"{deletingStep.title}"</span>?</>
|
||||
: 'Are you sure you want to delete this step?'
|
||||
}
|
||||
</p>
|
||||
<p className="mb-6 text-xs text-muted-foreground">This cannot be undone.</p>
|
||||
{deleteError && (
|
||||
<p className="mb-4 text-sm text-red-400">{deleteError}</p>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => { setDeletingStep(null); setDeleteError(null) }}
|
||||
disabled={isDeleting}
|
||||
className="flex-1 rounded-md border border-border px-4 py-2 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDeleteConfirm}
|
||||
disabled={isDeleting}
|
||||
className="flex-1 rounded-md bg-red-500 px-4 py-2 text-sm font-medium text-white hover:bg-red-600 disabled:opacity-50"
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Save Toast */}
|
||||
{saveToast && (
|
||||
<div className="fixed bottom-6 left-1/2 z-50 -translate-x-1/2 rounded-lg border border-border bg-card px-4 py-2 text-sm text-foreground shadow-lg">
|
||||
{saveToast}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**NOTE on delete title:** The `onDelete` callback from `StepCard` only passes `stepId: string`, not the full `StepListItem`. To show the step title in the confirmation dialog, change the `StepLibraryBrowser`'s `onDelete` prop type to pass the full `StepListItem` instead:
|
||||
|
||||
In `StepLibraryBrowser.tsx`, change:
|
||||
```ts
|
||||
onDelete?: (stepId: string) => void
|
||||
```
|
||||
to:
|
||||
```ts
|
||||
onDelete?: (step: StepListItem) => void
|
||||
```
|
||||
|
||||
And update where it calls `onDelete` from cards — pass the full `step` object. Update `StepCard` similarly: change `onDelete?: (stepId: string) => void` to `onDelete?: (step: StepListItem) => void` and call `onDelete?.(step)` instead of `onDelete?.(step.id)`.
|
||||
|
||||
Then in `StepLibraryPage`, use `handleDeleteRequest(step: StepListItem)` and set `setDeletingStep(step)` directly — no need to pass a minimal object.
|
||||
|
||||
**Step 2: Run TypeScript check**
|
||||
|
||||
```bash
|
||||
cd /home/michaelchihlas/dev/patherly/frontend && npx tsc --noEmit 2>&1 | head -40
|
||||
```
|
||||
|
||||
Fix any type errors before proceeding.
|
||||
|
||||
**Step 3: Run build**
|
||||
|
||||
```bash
|
||||
cd /home/michaelchihlas/dev/patherly/frontend && npm run build 2>&1 | tail -20
|
||||
```
|
||||
|
||||
Expected: build succeeds with no errors.
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
cd /home/michaelchihlas/dev/patherly
|
||||
git add frontend/src/pages/StepLibraryPage.tsx \
|
||||
frontend/src/components/step-library/StepLibraryBrowser.tsx \
|
||||
frontend/src/components/step-library/StepCard.tsx
|
||||
git commit -m "feat: Step Library page — create, edit, delete, save-to-library
|
||||
|
||||
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Manual verification checklist
|
||||
|
||||
Start the dev server and verify these flows work end-to-end:
|
||||
|
||||
```bash
|
||||
docker start patherly_postgres
|
||||
cd /home/michaelchihlas/dev/patherly/backend && source venv/bin/activate && uvicorn app.main:app --reload &
|
||||
cd /home/michaelchihlas/dev/patherly/frontend && npm run dev
|
||||
```
|
||||
|
||||
Navigate to `http://localhost:5173/step-library` and verify:
|
||||
|
||||
- [ ] Page loads without errors (not "Coming Soon")
|
||||
- [ ] "+ Create Step" button appears (login as engineer or admin)
|
||||
- [ ] Creating a step via the modal saves it and it appears under "My Steps" on reload
|
||||
- [ ] "Edit" button appears on your own step cards
|
||||
- [ ] Editing a step opens the form pre-filled (note: `content` fields won't pre-fill since `StepListItem` doesn't have content — this is acceptable for now; see note below)
|
||||
- [ ] "Delete" button appears on your own step cards
|
||||
- [ ] Delete confirmation shows step title; confirming removes it from the list
|
||||
- [ ] "Save" button appears on team/community step cards
|
||||
- [ ] Saving a step copies it to "My Steps" and shows toast
|
||||
- [ ] "Preview" opens `StepDetailModal` correctly on all card types
|
||||
- [ ] Filters (category, type, rating, sort) work
|
||||
- [ ] Popular tags clickable and filter results
|
||||
|
||||
**Note on edit pre-fill:** `StepListItem` does not include `content`. The `StepFormModal` passes `initialData` from `editingStep`, but `content` will be missing. For a full pre-fill, `StepLibraryPage.handleEdit` should fetch the full step via `stepsApi.get(step.id)` before opening the modal, and store the result as a `Step` (not `StepListItem`) in `editingStep` state. Update `editingStep` state type to `Step | null` and fetch in `handleEdit`:
|
||||
|
||||
```tsx
|
||||
const [editingStep, setEditingStep] = useState<Step | null>(null)
|
||||
|
||||
const handleEdit = async (step: StepListItem) => {
|
||||
try {
|
||||
const full = await stepsApi.get(step.id)
|
||||
setEditingStep(full)
|
||||
} catch (err) {
|
||||
console.error('Failed to load step for edit:', err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Update `StepFormModal`'s `editingStep` prop type to accept `Step | null` and build `initialData` from the full `Step` including `content`:
|
||||
|
||||
```tsx
|
||||
editingStep?: Step | null
|
||||
|
||||
const initialData = editingStep ? {
|
||||
title: editingStep.title,
|
||||
step_type: editingStep.step_type,
|
||||
content: editingStep.content,
|
||||
visibility: editingStep.visibility,
|
||||
category_id: editingStep.category_id,
|
||||
tags: editingStep.tags,
|
||||
} : undefined
|
||||
```
|
||||
|
||||
This should be done as part of Task 4 before verifying.
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Final build validation and commit
|
||||
|
||||
```bash
|
||||
cd /home/michaelchihlas/dev/patherly/frontend && npm run build 2>&1 | tail -20
|
||||
```
|
||||
|
||||
Expected: clean build, no TypeScript errors, no warnings about missing exports.
|
||||
|
||||
If clean:
|
||||
```bash
|
||||
cd /home/michaelchihlas/dev/patherly
|
||||
git add -A
|
||||
git status # confirm only expected files changed
|
||||
git commit -m "chore: step library page final build validation
|
||||
|
||||
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
|
||||
```
|
||||
108
docs/plans/archive/2026-02-24-tree-fork-ui-design.md
Normal file
108
docs/plans/archive/2026-02-24-tree-fork-ui-design.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# Tree Forking UI Design
|
||||
|
||||
> **Date:** 2026-02-24
|
||||
> **Feature:** Personal tree forking — explicit modal, reason capture, fork badge
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Add a proper fork UX to the flow library. The backend is fully complete (POST `/trees/:id/fork`, fork fields in API responses, tests passing). The frontend needs: a `ForkModal` component with a "Reason for Forking" field, updated fork handlers in `TreeLibraryPage` and `MyTreesPage`, fork field types on `Tree`, and a "Fork" chip on tree cards.
|
||||
|
||||
---
|
||||
|
||||
## What's Being Built
|
||||
|
||||
### 1. Types — `frontend/src/types/tree.ts`
|
||||
|
||||
Add `ForkInfo` interface and fork fields to `Tree`:
|
||||
|
||||
```ts
|
||||
export interface ForkInfo {
|
||||
parent_tree_id: string
|
||||
parent_tree_name: string | null
|
||||
fork_depth: number
|
||||
fork_reason: string | null
|
||||
has_parent_updates: boolean
|
||||
}
|
||||
```
|
||||
|
||||
Add to `Tree`:
|
||||
```ts
|
||||
fork_info?: ForkInfo | null
|
||||
parent_tree_id?: string | null
|
||||
fork_depth?: number
|
||||
```
|
||||
|
||||
Add to `TreeCreate`:
|
||||
```ts
|
||||
fork_reason?: string
|
||||
```
|
||||
|
||||
### 2. `ForkModal` Component — `frontend/src/components/library/ForkModal.tsx`
|
||||
|
||||
A focused dialog with:
|
||||
- **Name field** — pre-filled with `"Copy of <original name>"`
|
||||
- **"Reason for Forking"** — optional textarea (placeholder: "e.g. customizing for a specific client…")
|
||||
- **Cancel** (secondary) + **Fork** (gradient) buttons
|
||||
- Calls `treesApi.fork(treeId, { name, fork_reason })` on submit
|
||||
- Success: shows toast, navigates to `/my-trees`
|
||||
- Error: shows inline error, stays open
|
||||
|
||||
### 3. Update Fork Handlers
|
||||
|
||||
In `TreeLibraryPage` and `MyTreesPage`, replace the current silent `handleForkTree` (which calls `treesApi.fork()` directly) with a handler that:
|
||||
1. Sets the selected tree to fork
|
||||
2. Opens `ForkModal`
|
||||
|
||||
The modal handles the actual API call and navigation.
|
||||
|
||||
### 4. "Fork" Badge on Tree Cards
|
||||
|
||||
In `TreeGridView`, `TreeListView`, and `TreeTableView`, render a small chip when `fork_depth > 0` (or `parent_tree_id` is set on `TreeListItem`):
|
||||
|
||||
```tsx
|
||||
{tree.fork_depth > 0 && (
|
||||
<span className="rounded-full bg-violet-400/15 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wide text-violet-400">
|
||||
Fork
|
||||
</span>
|
||||
)}
|
||||
```
|
||||
|
||||
`fork_depth` needs to be added to `TreeListItem` (it comes from the backend list response).
|
||||
|
||||
---
|
||||
|
||||
## What's NOT Being Built
|
||||
|
||||
- Lineage tree view / "forked from" link — out of scope
|
||||
- "Has updates available" notification — out of scope
|
||||
- Fork management / ancestry tracking UI — out of scope
|
||||
|
||||
---
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
User clicks "Fork" on a card
|
||||
→ onForkTree(tree) called
|
||||
→ parent sets forkTarget state + opens ForkModal
|
||||
→ user fills Name + optional Reason
|
||||
→ ForkModal calls treesApi.fork(treeId, { name, fork_reason })
|
||||
→ on success: toast "Flow forked!" + navigate('/my-trees')
|
||||
→ My Trees page loads, forked flow shows "Fork" badge
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `frontend/src/types/tree.ts` | Add `ForkInfo`, fork fields on `Tree`, `fork_depth` on `TreeListItem` |
|
||||
| `frontend/src/components/library/ForkModal.tsx` | New component |
|
||||
| `frontend/src/pages/TreeLibraryPage.tsx` | Open modal instead of silent fork |
|
||||
| `frontend/src/pages/MyTreesPage.tsx` | Open modal instead of silent fork |
|
||||
| `frontend/src/components/library/TreeGridView.tsx` | Fork badge |
|
||||
| `frontend/src/components/library/TreeListView.tsx` | Fork badge |
|
||||
| `frontend/src/components/library/TreeTableView.tsx` | Fork badge |
|
||||
@@ -0,0 +1,686 @@
|
||||
# Visibility Model, Dashboard Tabs & Fork UI — Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Fix the dashboard stale-data bug, wire up the existing `visibility` column into access control, replace the single-list "My Flows" dashboard with a tabbed All / My Team / Public / My Flows view, and add a Fork button to public flow cards.
|
||||
|
||||
**Architecture:** Backend: rewrite `build_tree_access_filter` to use `visibility` column, add `visibility` query param to `GET /trees`, add `author_name` to `TreeListResponse`. Frontend: convert `MyTreesPage` into a tabbed page with per-tab API calls; add Fork button on public/team cards with a minimal confirmation modal. No new migrations needed — all columns exist. `is_public` stays in sync with `visibility='public'` for backward compat.
|
||||
|
||||
**Tech Stack:** Python FastAPI, SQLAlchemy 2.0 async, Pydantic v2, React 19, TypeScript, Zustand, Tailwind CSS v3, Lucide React.
|
||||
|
||||
**Key files:**
|
||||
- `backend/app/core/filters.py` — access filter (currently ignores `visibility`)
|
||||
- `backend/app/api/endpoints/trees.py` — list endpoint (add `visibility` param, `author_name` in response)
|
||||
- `backend/app/schemas/tree.py` — add `author_name`, `visibility` to `TreeListResponse`
|
||||
- `backend/tests/test_trees.py` — add visibility filter tests
|
||||
- `frontend/src/types/tree.ts` — add `visibility`, `author_name` to `TreeListItem`
|
||||
- `frontend/src/api/trees.ts` — add `visibility` param to `list()`
|
||||
- `frontend/src/pages/MyTreesPage.tsx` — full rewrite with tabs + stale data fix + fork button
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Fix `build_tree_access_filter` to enforce `visibility`
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/core/filters.py`
|
||||
|
||||
### Background
|
||||
|
||||
Currently the filter uses `is_public` boolean and `author_id`/`account_id` equality. The `visibility` column (`private | team | link | public`) is completely ignored. This means:
|
||||
- `visibility='private'` trees are still visible to team members (wrong)
|
||||
- The column is dead code
|
||||
|
||||
We keep `is_public` in sync (`is_public = visibility == 'public'`) since other code may read it. The new filter logic is:
|
||||
|
||||
| Condition | Sees tree |
|
||||
|---|---|
|
||||
| `is_default == True` | Everyone |
|
||||
| `visibility == 'public'` | Everyone |
|
||||
| `author_id == me` | Always (regardless of visibility) |
|
||||
| `visibility == 'team' AND account_id == mine` | Team members (not private ones) |
|
||||
|
||||
`visibility='private'` trees are only ever visible to their author.
|
||||
`visibility='link'` trees are accessible via share token (already handled by share endpoints); they don't appear in list queries unless you are the author.
|
||||
|
||||
**Step 1: Rewrite `build_tree_access_filter`**
|
||||
|
||||
Replace the function body in `backend/app/core/filters.py`:
|
||||
|
||||
```python
|
||||
def build_tree_access_filter(current_user: User):
|
||||
"""Build the access filter for trees based on user permissions.
|
||||
|
||||
Visibility rules:
|
||||
- super_admin: sees everything
|
||||
- is_default: visible to all authenticated users
|
||||
- visibility='public': visible to all authenticated users
|
||||
- author_id == me: always visible (regardless of visibility setting)
|
||||
- visibility='team' AND account_id == mine: visible to account members
|
||||
- visibility='private': only visible to author (covered by author_id check above)
|
||||
- visibility='link': only visible to author (share token access is handled separately)
|
||||
"""
|
||||
from app.models.tree import Tree
|
||||
|
||||
if current_user.is_super_admin:
|
||||
return sa_true()
|
||||
|
||||
conditions = [
|
||||
Tree.is_default == True,
|
||||
Tree.visibility == 'public',
|
||||
Tree.author_id == current_user.id,
|
||||
]
|
||||
if current_user.account_id:
|
||||
conditions.append(
|
||||
and_(
|
||||
Tree.visibility == 'team',
|
||||
Tree.account_id == current_user.account_id
|
||||
)
|
||||
)
|
||||
return or_(*conditions)
|
||||
```
|
||||
|
||||
**Step 2: Verify no existing tests break**
|
||||
|
||||
```bash
|
||||
cd /path/to/worktree && backend/venv/bin/python -m pytest backend/tests/test_trees.py -x -q --override-ini="addopts="
|
||||
```
|
||||
|
||||
Expected: all existing tests pass (the change narrows visibility but test trees are created with default `visibility='team'` and are owned by test user so they pass via `author_id` match).
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/core/filters.py
|
||||
git commit -m "fix: enforce visibility column in tree access filter
|
||||
|
||||
Previously build_tree_access_filter used is_public boolean and ignored the
|
||||
visibility column entirely. Now private/link trees are only visible to their
|
||||
author, team trees require matching account_id, and public trees are open to all.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Add `visibility` query param and `author_name` to list endpoint
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/api/endpoints/trees.py`
|
||||
- Modify: `backend/app/schemas/tree.py`
|
||||
- Modify: `backend/app/models/user.py` (confirm `full_name` or `email` field name)
|
||||
|
||||
### Background
|
||||
|
||||
The frontend tabs need to filter by visibility scope:
|
||||
- "My Flows" tab: `author_id=<me>` (existing)
|
||||
- "My Team" tab: `visibility=team` (new)
|
||||
- "Public" tab: `visibility=public` (new)
|
||||
- "All" tab: no visibility filter (existing default behavior)
|
||||
|
||||
We also want to show "Created by X" on cards that don't belong to the current user. The `TreeListResponse` needs an `author_name` field (email or display name).
|
||||
|
||||
**Step 1: Add `author_name` to `TreeListResponse` in `backend/app/schemas/tree.py`**
|
||||
|
||||
Add to the `TreeListResponse` class after `author_id`:
|
||||
```python
|
||||
author_name: Optional[str] = None # Display name or email of author
|
||||
```
|
||||
|
||||
**Step 2: Add `visibility` to `TreeListResponse` in `backend/app/schemas/tree.py`**
|
||||
|
||||
Add to `TreeListResponse` after `is_default`:
|
||||
```python
|
||||
visibility: str = 'team'
|
||||
```
|
||||
|
||||
**Step 3: Update `build_tree_response` in `backend/app/api/endpoints/trees.py`**
|
||||
|
||||
The `build_tree_response` function needs to include `author_name` and `visibility`. However, since it receives a `Tree` object (not a joined query), we need to either:
|
||||
a) Eagerly load the `author` relationship, or
|
||||
b) Set `author_name` from a pre-built map
|
||||
|
||||
The cleanest approach is to update `list_trees` to eagerly load the `author` relationship and pass it through.
|
||||
|
||||
In `list_trees`, update the query to load author:
|
||||
```python
|
||||
query = select(Tree).options(
|
||||
selectinload(Tree.category_rel),
|
||||
selectinload(Tree.tags),
|
||||
selectinload(Tree.author), # ADD THIS
|
||||
)
|
||||
```
|
||||
|
||||
Update `build_tree_response` signature and body:
|
||||
```python
|
||||
def build_tree_response(tree: Tree) -> TreeListResponse:
|
||||
"""Build TreeListResponse with category_info, tags, author_name, and visibility."""
|
||||
category_info = None
|
||||
if tree.category_rel:
|
||||
category_info = CategoryInfo(
|
||||
id=tree.category_rel.id,
|
||||
name=tree.category_rel.name,
|
||||
slug=tree.category_rel.slug
|
||||
)
|
||||
|
||||
# Author display: prefer full_name, fall back to email
|
||||
author_name = None
|
||||
if tree.author:
|
||||
author_name = getattr(tree.author, 'full_name', None) or tree.author.email
|
||||
|
||||
return TreeListResponse(
|
||||
id=tree.id,
|
||||
name=tree.name,
|
||||
description=tree.description,
|
||||
tree_type=tree.tree_type,
|
||||
category=tree.category,
|
||||
category_id=tree.category_id,
|
||||
category_info=category_info,
|
||||
tags=tree.tag_names,
|
||||
author_id=tree.author_id,
|
||||
author_name=author_name,
|
||||
account_id=tree.account_id,
|
||||
is_active=tree.is_active,
|
||||
is_public=tree.is_public,
|
||||
is_default=tree.is_default,
|
||||
visibility=tree.visibility,
|
||||
status=tree.status,
|
||||
version=tree.version,
|
||||
usage_count=tree.usage_count,
|
||||
created_at=tree.created_at,
|
||||
updated_at=tree.updated_at
|
||||
)
|
||||
```
|
||||
|
||||
**Step 4: Add `visibility` query param to `list_trees`**
|
||||
|
||||
In the function signature, add:
|
||||
```python
|
||||
visibility: Optional[str] = Query(None, description="Filter by visibility: private, team, link, public"),
|
||||
```
|
||||
|
||||
In the filter section (after the `is_public` filter block):
|
||||
```python
|
||||
if visibility:
|
||||
query = query.where(Tree.visibility == visibility)
|
||||
```
|
||||
|
||||
**Step 5: Write tests**
|
||||
|
||||
In `backend/tests/test_trees.py`, add a new test class `TestVisibilityFilter`:
|
||||
|
||||
```python
|
||||
class TestVisibilityFilter:
|
||||
"""Test that visibility filtering works correctly."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_private_tree_only_visible_to_author(
|
||||
self, client: AsyncClient, auth_headers: dict, test_user: dict
|
||||
):
|
||||
"""A private tree should NOT appear in another user's list."""
|
||||
# Create a private tree as test_user
|
||||
tree_data = {
|
||||
"name": "Private Flow",
|
||||
"tree_structure": {"id": "root", "type": "decision", "question": "Q?", "options": [], "children": []},
|
||||
}
|
||||
create_resp = await client.post("/api/v1/trees", json=tree_data, headers=auth_headers)
|
||||
assert create_resp.status_code == 201
|
||||
tree_id = create_resp.json()["id"]
|
||||
|
||||
# Set visibility to private
|
||||
vis_resp = await client.patch(
|
||||
f"/api/v1/trees/{tree_id}/visibility",
|
||||
json={"visibility": "private"},
|
||||
headers=auth_headers
|
||||
)
|
||||
assert vis_resp.status_code == 200
|
||||
|
||||
# Verify it appears for the author
|
||||
list_resp = await client.get("/api/v1/trees", headers=auth_headers)
|
||||
assert list_resp.status_code == 200
|
||||
ids = [t["id"] for t in list_resp.json()]
|
||||
assert tree_id in ids
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_visibility_query_param_filters_correctly(
|
||||
self, client: AsyncClient, auth_headers: dict
|
||||
):
|
||||
"""?visibility=public should only return public trees."""
|
||||
resp = await client.get("/api/v1/trees?visibility=public", headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
trees = resp.json()
|
||||
for tree in trees:
|
||||
assert tree["visibility"] == "public"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_author_name_present_in_list_response(
|
||||
self, client: AsyncClient, auth_headers: dict, test_tree: dict
|
||||
):
|
||||
"""TreeListResponse should include author_name."""
|
||||
resp = await client.get("/api/v1/trees", headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
trees = resp.json()
|
||||
assert len(trees) >= 1
|
||||
# author_name should be present (may be None for system trees)
|
||||
assert "author_name" in trees[0]
|
||||
```
|
||||
|
||||
**Step 6: Run tests**
|
||||
|
||||
```bash
|
||||
cd /path/to/worktree && backend/venv/bin/python -m pytest backend/tests/test_trees.py::TestVisibilityFilter -v --override-ini="addopts="
|
||||
```
|
||||
|
||||
Expected: all 3 new tests pass.
|
||||
|
||||
**Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/schemas/tree.py backend/app/api/endpoints/trees.py backend/tests/test_trees.py
|
||||
git commit -m "feat: add visibility filter param and author_name to tree list endpoint
|
||||
|
||||
GET /trees now accepts ?visibility=private|team|link|public to scope results.
|
||||
TreeListResponse includes author_name (full_name or email) and visibility.
|
||||
Author relationship eagerly loaded to avoid N+1 queries.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Update frontend types and API client
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/types/tree.ts`
|
||||
- Modify: `frontend/src/api/trees.ts`
|
||||
|
||||
### Background
|
||||
|
||||
`TreeListItem` needs `visibility` and `author_name`. The `trees.list()` API method needs a `visibility` parameter.
|
||||
|
||||
**Step 1: Add fields to `TreeListItem` in `frontend/src/types/tree.ts`**
|
||||
|
||||
After `author_id: string | null`, add:
|
||||
```typescript
|
||||
author_name: string | null
|
||||
visibility: 'private' | 'team' | 'link' | 'public'
|
||||
```
|
||||
|
||||
**Step 2: Add `visibility` to `TreeListParams` in `frontend/src/api/trees.ts`**
|
||||
|
||||
Find the params type/interface for `list()` and add:
|
||||
```typescript
|
||||
visibility?: 'private' | 'team' | 'link' | 'public'
|
||||
```
|
||||
|
||||
**Step 3: Verify build passes**
|
||||
|
||||
```bash
|
||||
cd /path/to/worktree/frontend && npm run build 2>&1 | tail -10
|
||||
```
|
||||
|
||||
Expected: no TypeScript errors. There may be type errors in `MyTreesPage.tsx` if it accesses `tree.visibility` — they'll be fixed in Task 4.
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/types/tree.ts frontend/src/api/trees.ts
|
||||
git commit -m "feat: add visibility and author_name to TreeListItem type and list API params
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Rewrite `MyTreesPage` with tabs and fork button
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/pages/MyTreesPage.tsx`
|
||||
|
||||
### Background
|
||||
|
||||
**Current state:**
|
||||
- Single list of `author_id=me` trees
|
||||
- Data loads on mount only (stale after navigating back from editor)
|
||||
- Has a "Create New" dropdown in the header
|
||||
- Has a fork badge on cards that have `parent_tree_id`
|
||||
|
||||
**New state:**
|
||||
- Four tabs: **My Flows** | **My Team** | **Public** | **All**
|
||||
- "My Team" tab hidden when `user.account_id` is null (solo user)
|
||||
- Data reloads when the active tab changes AND when the window regains focus (fixes stale data)
|
||||
- "Create New" button moves into the **My Flows** tab header area (only shown on that tab)
|
||||
- Cards on **Public** and **All** tabs (and **My Team** for other users' flows) show a **Fork** button
|
||||
- Fork button opens a minimal inline modal with optional reason field
|
||||
|
||||
### Tab → API call mapping
|
||||
|
||||
| Tab | `treesApi.list()` params |
|
||||
|-----|--------------------------|
|
||||
| My Flows | `{ author_id: user.id, sort_by: 'updated_at' }` |
|
||||
| My Team | `{ visibility: 'team', sort_by: 'updated_at' }` |
|
||||
| Public | `{ visibility: 'public', sort_by: 'usage_count' }` |
|
||||
| All | `{ sort_by: 'updated_at' }` |
|
||||
|
||||
**Step 1: Read the current `MyTreesPage.tsx` in full before editing.**
|
||||
|
||||
The file is at `frontend/src/pages/MyTreesPage.tsx`. Read it completely before making any changes.
|
||||
|
||||
**Step 2: Rewrite `MyTreesPage.tsx`**
|
||||
|
||||
Key structural changes:
|
||||
|
||||
```tsx
|
||||
type Tab = 'mine' | 'team' | 'public' | 'all'
|
||||
|
||||
export function MyTreesPage() {
|
||||
const { user } = useAuthStore()
|
||||
const { canEditTree, canCreateTrees } = usePermissions()
|
||||
const navigate = useNavigate()
|
||||
const hasTeam = Boolean(user?.account_id)
|
||||
|
||||
// Active tab state — default to 'mine'
|
||||
const [activeTab, setActiveTab] = useState<Tab>('mine')
|
||||
const [trees, setTrees] = useState<TreeWithStats[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
// Fork modal state
|
||||
const [forkTarget, setForkTarget] = useState<TreeWithStats | null>(null)
|
||||
const [forkReason, setForkReason] = useState('')
|
||||
const [isForking, setIsForking] = useState(false)
|
||||
|
||||
// ... existing modal state (delete, share, AI builder)
|
||||
|
||||
// Load trees whenever the active tab changes
|
||||
useEffect(() => {
|
||||
loadTrees()
|
||||
}, [activeTab, user?.id])
|
||||
|
||||
// Reload on window focus (fixes stale data after navigating back from editor)
|
||||
useEffect(() => {
|
||||
const onFocus = () => loadTrees()
|
||||
window.addEventListener('focus', onFocus)
|
||||
return () => window.removeEventListener('focus', onFocus)
|
||||
}, [activeTab, user?.id])
|
||||
|
||||
const loadTrees = async () => {
|
||||
if (!user?.id) return
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const params: Parameters<typeof treesApi.list>[0] = {
|
||||
sort_by: activeTab === 'public' ? 'usage_count' : 'updated_at',
|
||||
}
|
||||
if (activeTab === 'mine') params.author_id = user.id
|
||||
if (activeTab === 'team') params.visibility = 'team'
|
||||
if (activeTab === 'public') params.visibility = 'public'
|
||||
|
||||
const [userTrees, recentSessions] = await Promise.all([
|
||||
treesApi.list(params),
|
||||
activeTab === 'mine' ? sessionsApi.list({ size: 100 }) : Promise.resolve([]),
|
||||
])
|
||||
|
||||
// Build lastUsed map (only for mine tab)
|
||||
const lastUsedMap = new Map<string, string>()
|
||||
for (const session of recentSessions) {
|
||||
const existing = lastUsedMap.get(session.tree_id)
|
||||
if (!existing || new Date(session.started_at) > new Date(existing)) {
|
||||
lastUsedMap.set(session.tree_id, session.started_at)
|
||||
}
|
||||
}
|
||||
|
||||
setTrees(userTrees.map((tree) => ({
|
||||
...tree,
|
||||
lastUsed: lastUsedMap.get(tree.id),
|
||||
sessionCount: tree.usage_count ?? 0,
|
||||
})))
|
||||
} catch {
|
||||
toast.error('Failed to load flows')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFork = async () => {
|
||||
if (!forkTarget) return
|
||||
setIsForking(true)
|
||||
try {
|
||||
const forked = await treesApi.fork(forkTarget.id, {
|
||||
fork_reason: forkReason.trim() || undefined,
|
||||
})
|
||||
toast.success(`"${forked.name}" added to your flows`)
|
||||
setForkTarget(null)
|
||||
setForkReason('')
|
||||
// Switch to My Flows tab so they can see it
|
||||
setActiveTab('mine')
|
||||
} catch {
|
||||
toast.error('Failed to fork flow')
|
||||
} finally {
|
||||
setIsForking(false)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Tab bar UI** — render above the tree grid:
|
||||
```tsx
|
||||
{/* Tab bar */}
|
||||
<div className="flex items-center gap-1 border-b border-border">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={cn(
|
||||
'px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px',
|
||||
activeTab === tab.id
|
||||
? 'border-primary text-foreground'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Create button — only on My Flows tab */}
|
||||
{activeTab === 'mine' && canCreateTrees && (
|
||||
<div className="ml-auto pb-1.5">
|
||||
{/* existing CreateMenu / AI builder button */}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
```
|
||||
|
||||
Tabs array (built once, team tab conditionally included):
|
||||
```tsx
|
||||
const tabs = [
|
||||
{ id: 'mine' as Tab, label: 'My Flows' },
|
||||
...(hasTeam ? [{ id: 'team' as Tab, label: 'My Team' }] : []),
|
||||
{ id: 'public' as Tab, label: 'Public' },
|
||||
{ id: 'all' as Tab, label: 'All' },
|
||||
]
|
||||
```
|
||||
|
||||
**Fork button on cards** — shown when `tree.author_id !== user?.id` OR when on the Public tab:
|
||||
|
||||
```tsx
|
||||
{/* Show Fork button for flows you don't own */}
|
||||
{tree.author_id !== user?.id && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setForkTarget(tree); setForkReason('') }}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<GitBranch className="h-3.5 w-3.5" />
|
||||
Fork
|
||||
</button>
|
||||
)}
|
||||
```
|
||||
|
||||
**Fork confirmation modal** — simple inline modal (not a full-screen dialog):
|
||||
```tsx
|
||||
{forkTarget && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm">
|
||||
<div className="w-full max-w-sm rounded-xl border border-border bg-card p-5 shadow-xl">
|
||||
<h3 className="mb-1 text-sm font-semibold text-foreground">Fork this flow?</h3>
|
||||
<p className="mb-4 text-xs text-muted-foreground">
|
||||
Creates a copy of “{forkTarget.name}” under your account that you can edit freely.
|
||||
</p>
|
||||
<label className="mb-1 block text-xs text-muted-foreground">
|
||||
Why are you forking? <span className="opacity-60">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={forkReason}
|
||||
onChange={(e) => setForkReason(e.target.value)}
|
||||
placeholder="e.g. Adding Cisco Meraki steps for our network"
|
||||
maxLength={255}
|
||||
className="mb-4 w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleFork()}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleFork}
|
||||
disabled={isForking}
|
||||
className="flex flex-1 items-center justify-center gap-1.5 rounded-lg bg-gradient-brand py-2 text-sm font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
<GitBranch className="h-3.5 w-3.5" />
|
||||
{isForking ? 'Forking...' : 'Fork Flow'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForkTarget(null)}
|
||||
className="rounded-lg border border-border px-4 py-2 text-sm text-muted-foreground hover:bg-accent"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
**Author attribution on cards** — in the card header area, when `tree.author_id !== user?.id && tree.author_name`:
|
||||
```tsx
|
||||
{tree.author_id !== user?.id && tree.author_name && (
|
||||
<p className="text-[10px] font-label text-muted-foreground">
|
||||
by {tree.author_name}
|
||||
</p>
|
||||
)}
|
||||
```
|
||||
|
||||
**Step 3: Verify build passes**
|
||||
|
||||
```bash
|
||||
cd /path/to/worktree/frontend && npm run build 2>&1 | tail -10
|
||||
```
|
||||
|
||||
Expected: no TypeScript errors.
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/pages/MyTreesPage.tsx
|
||||
git commit -m "feat: add tabbed dashboard with My Flows/My Team/Public/All views and fork UI
|
||||
|
||||
- Tabs filter by visibility scope; My Team hidden for solo users
|
||||
- Data reloads on tab change and window focus (fixes stale-after-editor bug)
|
||||
- Create button moves into My Flows tab header
|
||||
- Fork button on flows not owned by current user; opens reason modal
|
||||
- Author attribution shown on cards from other users
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Keep `is_public` in sync when visibility changes
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/api/endpoints/trees.py` — the `update_tree_visibility` endpoint
|
||||
|
||||
### Background
|
||||
|
||||
The existing `PATCH /trees/{id}/visibility` endpoint sets the `visibility` column. But `is_public` is a separate boolean that some code may still read. We need to keep them in sync so `is_public = (visibility == 'public')`.
|
||||
|
||||
Find the visibility update endpoint (around line 1025–1077) and add the sync:
|
||||
|
||||
**Step 1: Add `is_public` sync in the visibility endpoint**
|
||||
|
||||
Locate the line that sets `tree.visibility = visibility_data.visibility` and add directly after it:
|
||||
```python
|
||||
tree.is_public = (visibility_data.visibility == 'public')
|
||||
```
|
||||
|
||||
Also do the same in `update_tree` (the PUT endpoint) — find where `is_public` is set from the update data and add a corresponding `visibility` update:
|
||||
```python
|
||||
# Keep visibility and is_public in sync
|
||||
if tree_data.is_public is not None:
|
||||
tree.is_public = tree_data.is_public
|
||||
if tree_data.is_public and tree.visibility not in ('public',):
|
||||
tree.visibility = 'public'
|
||||
elif not tree_data.is_public and tree.visibility == 'public':
|
||||
tree.visibility = 'team' # downgrade from public to team
|
||||
```
|
||||
|
||||
**Step 2: Run existing tests**
|
||||
|
||||
```bash
|
||||
cd /path/to/worktree && backend/venv/bin/python -m pytest backend/tests/test_trees.py -x -q --override-ini="addopts="
|
||||
```
|
||||
|
||||
Expected: all tests pass.
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/api/endpoints/trees.py
|
||||
git commit -m "fix: keep is_public and visibility in sync on updates
|
||||
|
||||
When visibility changes to 'public', is_public=True. When it changes away
|
||||
from 'public', is_public=False. When is_public is set via TreeUpdate,
|
||||
visibility column is updated to match.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Push and verify
|
||||
|
||||
**Step 1: Push branch**
|
||||
|
||||
```bash
|
||||
git push
|
||||
```
|
||||
|
||||
**Step 2: Check PR CI**
|
||||
|
||||
```bash
|
||||
gh pr checks 88 2>&1 | head -20
|
||||
```
|
||||
|
||||
**Step 3: Manual smoke test checklist**
|
||||
|
||||
- [ ] Create a new AI flow → "Open in Editor" → publish → navigate back to dashboard. Flow should appear immediately (focus trigger reloads).
|
||||
- [ ] Dashboard has tabs: My Flows, My Team (if account), Public, All.
|
||||
- [ ] My Flows tab shows only flows you authored. Create button is here.
|
||||
- [ ] My Team tab shows team-visibility flows from your account (other team members' flows appear here).
|
||||
- [ ] Public tab shows `visibility='public'` flows from all users. Fork button visible on flows you don't own.
|
||||
- [ ] Fork a public flow → reason modal appears → confirm → toast "Added to your flows" → switches to My Flows tab → fork appears there.
|
||||
- [ ] Solo user (no account_id) sees no My Team tab.
|
||||
- [ ] Cards for other users' flows show "by [author name]" attribution.
|
||||
- [ ] Changing a flow's visibility to "private" via editor makes it disappear from team members' My Team tab (requires two user accounts to verify).
|
||||
|
||||
---
|
||||
|
||||
## Implementation Notes for Claude
|
||||
|
||||
**Working directory:** `/home/michaelchihlas/dev/patherly/.worktrees/feat-ai-flow-builder`
|
||||
**Branch:** `frontend-standardization` (PR #88)
|
||||
**Run backend tests from:** `backend/venv/bin/python -m pytest ...` (worktrees share the main venv)
|
||||
**Frontend build:** `cd frontend && npm run build`
|
||||
|
||||
**Before Task 4:** Read `MyTreesPage.tsx` in FULL before making any edits. The file is ~410 lines and has important modal state, the AI builder button, delete/share handlers that must be preserved exactly.
|
||||
|
||||
**Task 4 note on `TreeWithStats`:** The existing interface extension adds `lastUsed` and `sessionCount`. Also add `parent_tree_id` and `parent_tree_name` which are already being used in the fork badge rendering. Keep those. Also add `visibility` and `author_name` since `TreeListItem` now includes them (inherited from the spread `...tree`).
|
||||
|
||||
**User model field name:** Check `backend/app/models/user.py` for the display name field — it may be `full_name`, `name`, or just `email`. Use `getattr(tree.author, 'full_name', None) or tree.author.email` as a safe fallback.
|
||||
187
docs/plans/archive/2026-02-25-flow-to-library-sync-design.md
Normal file
187
docs/plans/archive/2026-02-25-flow-to-library-sync-design.md
Normal file
@@ -0,0 +1,187 @@
|
||||
# Flow-to-Library Step Sync Design
|
||||
|
||||
> **Date:** 2026-02-25
|
||||
> **Feature:** Automatically sync steps from published flows into the step library
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
When a flow is published, its steps are extracted and written into the `step_library` table so engineers can discover and reuse them when building new flows or inserting ad-hoc steps during a live session. Library entries are flow-owned and read-only — forking creates a personal copy for customization.
|
||||
|
||||
**What gets synced:**
|
||||
- `procedural` / `maintenance` flows → each `procedure_step` node → `step_type: 'action'`
|
||||
- `troubleshooting` flows → each `action` node → `step_type: 'action'`; each `solution` node → `step_type: 'solution'`
|
||||
- `section_header` and `procedure_end` nodes are NOT synced as library entries
|
||||
|
||||
**Sync trigger:** `PUT /trees/{tree_id}` when `status` transitions to `'published'`
|
||||
|
||||
**Sync model:** Upsert keyed on `(source_tree_id, source_node_id)` — subsequent publishes update existing entries without losing usage counts or ratings.
|
||||
|
||||
---
|
||||
|
||||
## Section 1: Data Model
|
||||
|
||||
### New columns on `step_library` (one migration)
|
||||
|
||||
| Column | Type | Default | Notes |
|
||||
|--------|------|---------|-------|
|
||||
| `source_tree_id` | UUID FK → `trees.id` | NULL | SET NULL on tree delete |
|
||||
| `source_node_id` | String(255) | NULL | Node `id` within `tree_structure` JSONB |
|
||||
| `is_flow_synced` | Boolean | `false` | Distinguishes synced from manually created entries |
|
||||
| `last_synced_at` | DateTime(timezone=True) | NULL | Timestamp of last sync |
|
||||
|
||||
### New optional field on `StepContent` schema
|
||||
|
||||
Add `group_label: Optional[str] = None` to `StepContent` in `backend/app/schemas/step_library.py`. For procedural steps that belong to a section, this stores the section header title so steps are browsable/filterable by section in the library.
|
||||
|
||||
### Per-step visibility override on procedural step nodes
|
||||
|
||||
Add optional `library_visibility` field to individual step nodes in `tree_structure` JSONB:
|
||||
- Type: `'team' | 'public'` (no `'private'` — synced steps are always at minimum team-visible)
|
||||
- If absent: inherits visibility from the flow (default behavior)
|
||||
- Stored directly on the step node in `tree_structure` — no schema migration needed (JSONB is flexible)
|
||||
|
||||
### Visibility inheritance mapping
|
||||
|
||||
| Flow state | Resolved step visibility |
|
||||
|-----------|--------------------------|
|
||||
| `is_public=True` | `'public'` |
|
||||
| `is_public=False`, has `account_id` | `'team'` |
|
||||
| `is_public=False`, no `account_id` | `'team'` |
|
||||
| Step has `library_visibility` set | Use that value (overrides above) |
|
||||
|
||||
### On flow deactivation / deletion
|
||||
|
||||
When `is_active` is set to `False` on a tree, or the tree is deleted, soft-delete all synced library entries for that tree: `UPDATE step_library SET is_active=False WHERE source_tree_id=:tree_id AND is_flow_synced=True`.
|
||||
|
||||
Forked copies (`is_flow_synced=False`, different `created_by`) are unaffected.
|
||||
|
||||
---
|
||||
|
||||
## Section 2: Sync Logic (Backend)
|
||||
|
||||
### Trigger location
|
||||
|
||||
`backend/app/api/endpoints/trees.py` — `update_tree()` function, after the block at line ~587 where `status` is confirmed to be transitioning to `'published'`.
|
||||
|
||||
### Extraction logic
|
||||
|
||||
**For procedural/maintenance flows:**
|
||||
```
|
||||
steps = tree_structure.get('steps', [])
|
||||
for node in steps:
|
||||
if node['type'] != 'procedure_step':
|
||||
continue
|
||||
# find the most recent section_header preceding this step
|
||||
group_label = last_seen_section_header_title
|
||||
yield StepLibraryUpsert(
|
||||
title=node['title'],
|
||||
step_type='action',
|
||||
content=StepContent(
|
||||
instructions=node.get('description') or node['title'],
|
||||
help_text=node.get('expected_outcome'),
|
||||
commands=[StepCommand(label=c.get('label',''), command=c['code'], command_type=c.get('language'))
|
||||
for c in normalize_commands(node.get('commands'))],
|
||||
group_label=group_label,
|
||||
),
|
||||
visibility=node.get('library_visibility') or resolve_visibility(tree),
|
||||
source_tree_id=tree.id,
|
||||
source_node_id=node['id'],
|
||||
)
|
||||
```
|
||||
|
||||
**For troubleshooting flows:**
|
||||
```
|
||||
walk all nodes recursively
|
||||
for node with type in ('action', 'solution'):
|
||||
yield StepLibraryUpsert(
|
||||
title=node['title'],
|
||||
step_type='action' if node['type']=='action' else 'solution',
|
||||
content=StepContent(
|
||||
instructions=node.get('description') or node['title'],
|
||||
),
|
||||
visibility=resolve_visibility(tree),
|
||||
source_tree_id=tree.id,
|
||||
source_node_id=node['id'],
|
||||
)
|
||||
```
|
||||
|
||||
**Command normalization:** `node.commands` can be a plain string or an array of `{language, code, label}` objects. Normalize both into `StepCommand` list.
|
||||
|
||||
### Upsert query
|
||||
|
||||
```sql
|
||||
INSERT INTO step_library (id, title, step_type, content, visibility, created_by,
|
||||
account_id, is_flow_synced, source_tree_id, source_node_id, last_synced_at, ...)
|
||||
VALUES (...)
|
||||
ON CONFLICT (source_tree_id, source_node_id)
|
||||
DO UPDATE SET
|
||||
title = EXCLUDED.title,
|
||||
content = EXCLUDED.content,
|
||||
visibility = EXCLUDED.visibility,
|
||||
last_synced_at = EXCLUDED.last_synced_at,
|
||||
is_active = true -- re-activate if previously soft-deleted
|
||||
```
|
||||
|
||||
Requires a unique constraint on `(source_tree_id, source_node_id)`.
|
||||
|
||||
### `created_by` for synced entries
|
||||
|
||||
Set to the tree's `author_id`. This gives the flow author "ownership" of the entry, consistent with the flow-owned model. Permissions in `core/permissions.py` already allow the creator to see their own private steps — no change needed.
|
||||
|
||||
---
|
||||
|
||||
## Section 3: Per-Step Visibility Override (Editor)
|
||||
|
||||
### Where
|
||||
|
||||
`frontend/src/components/procedural-editor/StepEditor.tsx` — inside the existing "More Options" collapsible section.
|
||||
|
||||
### What
|
||||
|
||||
A **"Library Visibility"** select field, shown only for `procedure_step` nodes (not section headers, not end nodes):
|
||||
|
||||
```
|
||||
Library Visibility
|
||||
[ Inherit from flow ▼ ] (options: Inherit from flow / Team only / Public)
|
||||
```
|
||||
|
||||
- Default (no `library_visibility` on node): renders as "Inherit from flow"
|
||||
- Selecting "Team only" or "Public" writes `library_visibility: 'team'` or `library_visibility: 'public'` to the node
|
||||
- Selecting "Inherit from flow" removes the `library_visibility` key from the node
|
||||
|
||||
Only rendered when `tree_type` is `'procedural'` or `'maintenance'`. Troubleshooting flows have no per-node override (they inherit the flow visibility always).
|
||||
|
||||
---
|
||||
|
||||
## Section 4: Frontend — Step Library Browser
|
||||
|
||||
### Changes to `StepLibraryBrowser` / step list
|
||||
|
||||
- **"From Flow" badge** on synced entries (`is_flow_synced: true`): small chip — same style as existing type badges. Shows source flow name.
|
||||
- **Step detail/preview panel**: add "Sourced from: [Flow Name]" line with a link to the flow's navigate/edit page.
|
||||
- **Read-only indicator**: for `is_flow_synced` entries, replace the Edit button with a lock icon + tooltip: "Managed by source flow — fork to customize."
|
||||
- **Fork behavior**: existing "Save to Library" copy mechanism unchanged. Forked copy gets `is_flow_synced=false`, `source_tree_id=null`, `created_by=current_user`.
|
||||
|
||||
### API response changes
|
||||
|
||||
`StepLibraryResponse` needs two new fields:
|
||||
- `is_flow_synced: bool`
|
||||
- `source_tree_name: Optional[str]` — joined from `trees.name` at query time
|
||||
|
||||
---
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `backend/alembic/versions/030_add_step_library_sync_fields.py` | New migration — add 4 columns + unique constraint |
|
||||
| `backend/app/models/step_library.py` | Add 4 new columns + FK relationship to Tree |
|
||||
| `backend/app/schemas/step_library.py` | Add `group_label` to `StepContent`; add `is_flow_synced` + `source_tree_name` to response schema |
|
||||
| `backend/app/api/endpoints/trees.py` | Add sync logic after publish transition |
|
||||
| `backend/app/core/step_sync.py` | New module — extraction + upsert logic (keeps trees.py clean) |
|
||||
| `backend/tests/test_step_sync.py` | New test file |
|
||||
| `frontend/src/types/step.ts` | Add `is_flow_synced`, `source_tree_name` to `Step` type |
|
||||
| `frontend/src/components/procedural-editor/StepEditor.tsx` | Add Library Visibility select in More Options |
|
||||
| `frontend/src/components/step-library/StepLibraryBrowser.tsx` | From Flow badge, read-only indicator, source flow link |
|
||||
1065
docs/plans/archive/2026-02-25-flow-to-library-sync.md
Normal file
1065
docs/plans/archive/2026-02-25-flow-to-library-sync.md
Normal file
File diff suppressed because it is too large
Load Diff
493
docs/plans/archive/2026-02-25-tree-fork-ui.md
Normal file
493
docs/plans/archive/2026-02-25-tree-fork-ui.md
Normal file
@@ -0,0 +1,493 @@
|
||||
# Tree Fork UI Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Add an explicit `ForkModal` with a "Reason for Forking" field to replace the silent fork flow, and show a "Fork" chip badge on forked tree cards in the library and My Trees views.
|
||||
|
||||
**Architecture:** The backend is fully complete (POST `/trees/:id/fork` accepts `{ name, fork_reason }`). The frontend `treesApi.fork()` already accepts these params. We need: (1) `ForkInfo` types added to `tree.ts`, (2) a new `ForkModal` component, (3) updated fork handlers in `TreeLibraryPage` and `MyTreesPage` to open the modal instead of forking silently, (4) a "Fork" chip in all three card views (grid, list, table).
|
||||
|
||||
**Tech Stack:** React 19, TypeScript, Tailwind CSS, Lucide React, `treesApi.fork(id, { name, fork_reason })` already wired.
|
||||
|
||||
---
|
||||
|
||||
## Context for the Implementer
|
||||
|
||||
- `treesApi.fork(id, data?)` is at `frontend/src/api/trees.ts:42` — already accepts `{ fork_reason?, name? }`
|
||||
- `onForkTree` prop exists on all three card views and currently passes only `treeId: string`
|
||||
- `TreeLibraryPage` has `handleForkTree(treeId: string)` at line ~247 that calls `treesApi.fork(treeId)` silently
|
||||
- `MyTreesPage` does NOT currently have a fork handler — the "Fork" UI there is an informational message (line ~215), not a button wired to `onForkTree`
|
||||
- `TreeListItem` (used by all three views) does NOT yet have `fork_depth` or `parent_tree_id` — must add these
|
||||
- `MyTreesPage` already uses `tree.parent_tree_id` at line ~283 for a "Forked from" display block — this field must be on the type for that to compile cleanly after our changes
|
||||
- All three card views are in `frontend/src/components/library/`
|
||||
- Design system: `bg-violet-400/15 text-violet-400` for the Fork chip; `bg-gradient-brand` for the Fork submit button; modal structure uses `bg-card border-border rounded-xl`
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add `ForkInfo` type and fork fields to `TreeListItem` and `Tree`
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/types/tree.ts:142-190`
|
||||
|
||||
This is a pure type change — no runtime behavior changes.
|
||||
|
||||
**Step 1: Add `ForkInfo` interface and fork fields**
|
||||
|
||||
In `frontend/src/types/tree.ts`, after line 141 (the `ProceduralTreeStructure` closing brace), add `ForkInfo` then update `Tree` and `TreeListItem`:
|
||||
|
||||
```typescript
|
||||
export interface ForkInfo {
|
||||
parent_tree_id: string
|
||||
parent_tree_name: string | null
|
||||
fork_depth: number
|
||||
fork_reason: string | null
|
||||
has_parent_updates: boolean
|
||||
}
|
||||
```
|
||||
|
||||
Add to `Tree` interface (after `usage_count: number`):
|
||||
```typescript
|
||||
fork_info?: ForkInfo | null
|
||||
parent_tree_id?: string | null
|
||||
fork_depth?: number
|
||||
```
|
||||
|
||||
Add to `TreeListItem` interface (after `visibility` field):
|
||||
```typescript
|
||||
fork_depth?: number
|
||||
parent_tree_id?: string | null
|
||||
```
|
||||
|
||||
**Step 2: Verify TypeScript compiles cleanly**
|
||||
|
||||
```bash
|
||||
cd /home/michaelchihlas/dev/patherly/frontend && npm run build 2>&1 | tail -20
|
||||
```
|
||||
|
||||
Expected: Clean build, no errors.
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
cd /home/michaelchihlas/dev/patherly
|
||||
git add frontend/src/types/tree.ts
|
||||
git commit -m "feat: add ForkInfo type and fork fields to Tree/TreeListItem
|
||||
|
||||
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Create `ForkModal` component
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/components/library/ForkModal.tsx`
|
||||
|
||||
**Step 1: Create the component file**
|
||||
|
||||
Create `frontend/src/components/library/ForkModal.tsx` with this exact content:
|
||||
|
||||
```tsx
|
||||
import { useState } from 'react'
|
||||
import { GitBranch, X } from 'lucide-react'
|
||||
import { treesApi } from '@/api/trees'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
interface ForkModalProps {
|
||||
treeId: string
|
||||
treeName: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function ForkModal({ treeId, treeName, onClose }: ForkModalProps) {
|
||||
const navigate = useNavigate()
|
||||
const [name, setName] = useState(`Copy of ${treeName}`)
|
||||
const [forkReason, setForkReason] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!name.trim()) return
|
||||
setIsSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
await treesApi.fork(treeId, {
|
||||
name: name.trim(),
|
||||
fork_reason: forkReason.trim() || undefined,
|
||||
})
|
||||
toast.success('Flow forked successfully')
|
||||
onClose()
|
||||
navigate('/my-trees')
|
||||
} catch (err) {
|
||||
console.error('Failed to fork flow:', err)
|
||||
setError('Failed to fork flow. Please try again.')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
|
||||
<div className="w-full max-w-md rounded-xl border border-border bg-card shadow-xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-border px-5 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<GitBranch className="h-4 w-4 text-muted-foreground" />
|
||||
<h2 className="text-sm font-semibold text-foreground">Fork Flow</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<form onSubmit={handleSubmit} className="space-y-4 px-5 py-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-muted-foreground">
|
||||
Name <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
className={cn(
|
||||
'w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground',
|
||||
'placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-muted-foreground">
|
||||
Reason for Forking{' '}
|
||||
<span className="text-muted-foreground/60">(optional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={forkReason}
|
||||
onChange={(e) => setForkReason(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="e.g. customizing for a specific client…"
|
||||
className={cn(
|
||||
'w-full resize-none rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground',
|
||||
'placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-red-400">{error}</p>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-lg border border-border px-4 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !name.trim()}
|
||||
className="bg-gradient-brand flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90 disabled:opacity-40"
|
||||
>
|
||||
{isSubmitting ? 'Forking…' : 'Fork Flow'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Verify TypeScript compiles cleanly**
|
||||
|
||||
```bash
|
||||
cd /home/michaelchihlas/dev/patherly/frontend && npm run build 2>&1 | tail -20
|
||||
```
|
||||
|
||||
Expected: Clean build, no errors.
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
cd /home/michaelchihlas/dev/patherly
|
||||
git add frontend/src/components/library/ForkModal.tsx
|
||||
git commit -m "feat: add ForkModal component with name and reason fields
|
||||
|
||||
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Update `TreeLibraryPage` to open `ForkModal`
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/pages/TreeLibraryPage.tsx`
|
||||
|
||||
The current `handleForkTree` at ~line 247 calls `treesApi.fork(treeId)` silently. Replace it with state that opens `ForkModal`.
|
||||
|
||||
**Step 1: Add import for `ForkModal` and `TreeListItem`**
|
||||
|
||||
At the top of `TreeLibraryPage.tsx`, the file already imports `TreeListItem` from `@/types`. Add `ForkModal` to the library component imports. Find the line that imports from `@/components/library/...` and add:
|
||||
|
||||
```tsx
|
||||
import { ForkModal } from '@/components/library/ForkModal'
|
||||
```
|
||||
|
||||
**Step 2: Replace fork state**
|
||||
|
||||
Find (around line 76):
|
||||
```tsx
|
||||
// Fork state
|
||||
const [isForkingTree, setIsForkingTree] = useState(false)
|
||||
```
|
||||
|
||||
Replace with:
|
||||
```tsx
|
||||
// Fork modal state
|
||||
const [forkTarget, setForkTarget] = useState<TreeListItem | null>(null)
|
||||
```
|
||||
|
||||
**Step 3: Replace `handleForkTree`**
|
||||
|
||||
Find (around line 247):
|
||||
```tsx
|
||||
const handleForkTree = async (treeId: string) => {
|
||||
if (isForkingTree) return
|
||||
setIsForkingTree(true)
|
||||
try {
|
||||
await treesApi.fork(treeId)
|
||||
toast.success('Flow forked successfully')
|
||||
navigate('/my-trees')
|
||||
} catch (err) {
|
||||
console.error('Failed to fork flow:', err)
|
||||
toast.error('Failed to fork flow')
|
||||
} finally {
|
||||
setIsForkingTree(false)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Replace with:
|
||||
```tsx
|
||||
const handleForkTree = (treeId: string) => {
|
||||
const tree = trees.find((t) => t.id === treeId)
|
||||
if (tree) setForkTarget(tree)
|
||||
}
|
||||
```
|
||||
|
||||
Note: `trees` is the existing state variable holding the fetched tree list. If the variable is named differently in context, use the correct name.
|
||||
|
||||
**Step 4: Add `ForkModal` to JSX**
|
||||
|
||||
Find the closing `</div>` of the page's root element (near the end of the return statement, after all the other modals like `FolderEditModal`, `ConfirmDialog`). Add before the root closing tag:
|
||||
|
||||
```tsx
|
||||
{forkTarget && (
|
||||
<ForkModal
|
||||
treeId={forkTarget.id}
|
||||
treeName={forkTarget.name}
|
||||
onClose={() => setForkTarget(null)}
|
||||
/>
|
||||
)}
|
||||
```
|
||||
|
||||
**Step 5: Verify TypeScript compiles cleanly**
|
||||
|
||||
```bash
|
||||
cd /home/michaelchihlas/dev/patherly/frontend && npm run build 2>&1 | tail -20
|
||||
```
|
||||
|
||||
Expected: Clean build, no errors. If there are unused import errors for `treesApi` (if it was only used by the old `handleForkTree`), check whether `treesApi` is still used elsewhere on the page; if not, remove it from imports.
|
||||
|
||||
**Step 6: Commit**
|
||||
|
||||
```bash
|
||||
cd /home/michaelchihlas/dev/patherly
|
||||
git add frontend/src/pages/TreeLibraryPage.tsx
|
||||
git commit -m "feat: open ForkModal on fork action in TreeLibraryPage
|
||||
|
||||
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Update `MyTreesPage` to open `ForkModal`
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/pages/MyTreesPage.tsx`
|
||||
|
||||
`MyTreesPage` does NOT currently pass `onForkTree` to any view components — the page is a custom hand-rolled list, not using the three card views. The fork action is not wired here. However, `tree.parent_tree_id` is already rendered (line ~283), so we just need to add a `ForkModal` trigger for any fork buttons that may be present.
|
||||
|
||||
**Step 1: Read the MyTreesPage fork section carefully**
|
||||
|
||||
Read lines 200–300 of `frontend/src/pages/MyTreesPage.tsx` to understand the exact current fork UI and whether there's a fork button.
|
||||
|
||||
```bash
|
||||
sed -n '200,300p' /home/michaelchihlas/dev/patherly/frontend/src/pages/MyTreesPage.tsx
|
||||
```
|
||||
|
||||
**Step 2: Add import for `ForkModal`**
|
||||
|
||||
Add to the imports:
|
||||
```tsx
|
||||
import { ForkModal } from '@/components/library/ForkModal'
|
||||
```
|
||||
|
||||
**Step 3: Add fork modal state**
|
||||
|
||||
Find the state declarations section. Add:
|
||||
```tsx
|
||||
const [forkTarget, setForkTarget] = useState<TreeListItem | null>(null)
|
||||
```
|
||||
|
||||
**Step 4: Add a "Fork" button to each tree row (if not already present)**
|
||||
|
||||
In the tree list rendering, find the action buttons area for each tree (look for the edit/delete buttons). Add a Fork button next to them:
|
||||
|
||||
```tsx
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForkTarget(tree)}
|
||||
className="rounded-md border border-border p-2 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
title="Fork flow"
|
||||
>
|
||||
<GitBranch className="h-4 w-4" />
|
||||
</button>
|
||||
```
|
||||
|
||||
Note: `GitBranch` is already imported in `MyTreesPage` (line 3).
|
||||
|
||||
**Step 5: Add `ForkModal` to JSX**
|
||||
|
||||
Find the end of the return statement. Before the root closing tag, add:
|
||||
```tsx
|
||||
{forkTarget && (
|
||||
<ForkModal
|
||||
treeId={forkTarget.id}
|
||||
treeName={forkTarget.name}
|
||||
onClose={() => setForkTarget(null)}
|
||||
/>
|
||||
)}
|
||||
```
|
||||
|
||||
**Step 6: Verify TypeScript compiles cleanly**
|
||||
|
||||
```bash
|
||||
cd /home/michaelchihlas/dev/patherly/frontend && npm run build 2>&1 | tail -20
|
||||
```
|
||||
|
||||
Expected: Clean build, no errors.
|
||||
|
||||
**Step 7: Commit**
|
||||
|
||||
```bash
|
||||
cd /home/michaelchihlas/dev/patherly
|
||||
git add frontend/src/pages/MyTreesPage.tsx
|
||||
git commit -m "feat: add ForkModal to MyTreesPage
|
||||
|
||||
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Add "Fork" chip badge to all three card views
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/library/TreeGridView.tsx`
|
||||
- Modify: `frontend/src/components/library/TreeListView.tsx`
|
||||
- Modify: `frontend/src/components/library/TreeTableView.tsx`
|
||||
|
||||
Show a small violet chip when `tree.fork_depth > 0` (or `tree.parent_tree_id` is set). Place it near the tree name or alongside other metadata chips.
|
||||
|
||||
**Step 1: Add Fork chip to `TreeGridView`**
|
||||
|
||||
Read `frontend/src/components/library/TreeGridView.tsx` lines 60–100 to find where tree name and category badge are rendered.
|
||||
|
||||
In the name/header area of each card (near where `tree.category_info` chip is rendered), add:
|
||||
|
||||
```tsx
|
||||
{(tree.fork_depth ?? 0) > 0 && (
|
||||
<span className="shrink-0 rounded-full bg-violet-400/15 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wide text-violet-400">
|
||||
Fork
|
||||
</span>
|
||||
)}
|
||||
```
|
||||
|
||||
Place this chip alongside or just after the tree name `<span>`, or next to the category badge — wherever fits the card layout (read the file to confirm exact placement).
|
||||
|
||||
**Step 2: Add Fork chip to `TreeListView`**
|
||||
|
||||
Read `frontend/src/components/library/TreeListView.tsx` lines 60–130 to find the name + metadata row.
|
||||
|
||||
Add the same chip in the same relative position:
|
||||
|
||||
```tsx
|
||||
{(tree.fork_depth ?? 0) > 0 && (
|
||||
<span className="shrink-0 rounded-full bg-violet-400/15 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wide text-violet-400">
|
||||
Fork
|
||||
</span>
|
||||
)}
|
||||
```
|
||||
|
||||
**Step 3: Add Fork chip to `TreeTableView`**
|
||||
|
||||
Read `frontend/src/components/library/TreeTableView.tsx` lines 80–150 to find the name column cell.
|
||||
|
||||
Add the same chip inline after the tree name in the name column:
|
||||
|
||||
```tsx
|
||||
{(tree.fork_depth ?? 0) > 0 && (
|
||||
<span className="shrink-0 rounded-full bg-violet-400/15 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wide text-violet-400">
|
||||
Fork
|
||||
</span>
|
||||
)}
|
||||
```
|
||||
|
||||
**Step 4: Verify TypeScript compiles cleanly**
|
||||
|
||||
```bash
|
||||
cd /home/michaelchihlas/dev/patherly/frontend && npm run build 2>&1 | tail -20
|
||||
```
|
||||
|
||||
Expected: Clean build, no errors.
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
cd /home/michaelchihlas/dev/patherly
|
||||
git add frontend/src/components/library/TreeGridView.tsx \
|
||||
frontend/src/components/library/TreeListView.tsx \
|
||||
frontend/src/components/library/TreeTableView.tsx
|
||||
git commit -m "feat: show Fork chip badge on forked tree cards
|
||||
|
||||
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Manual Verification Checklist
|
||||
|
||||
After all tasks are complete:
|
||||
|
||||
1. **Fork flow (Library):** Go to Flow Library → click GitBranch icon on any published flow → `ForkModal` opens with name pre-filled as "Copy of <name>" → enter a reason → click "Fork Flow" → toast appears → redirected to My Trees.
|
||||
|
||||
2. **Fork flow (My Trees):** Go to My Trees → find a flow → click Fork button → same modal + behavior.
|
||||
|
||||
3. **Fork badge:** Fork a flow → go to My Trees → forked flow shows violet "Fork" chip in card header.
|
||||
|
||||
4. **Badge in Library views:** In Flow Library, switch to grid/list/table view — forked flows (your own) show "Fork" chip.
|
||||
|
||||
5. **Reason is optional:** Fork a flow without entering a reason → still works.
|
||||
|
||||
6. **Cancel:** Open ForkModal → click Cancel → modal closes, nothing forked.
|
||||
209
docs/plans/archive/2026-02-26-ai-autofix-gemini-design.md
Normal file
209
docs/plans/archive/2026-02-26-ai-autofix-gemini-design.md
Normal file
@@ -0,0 +1,209 @@
|
||||
# AI Auto-Fix & Gemini Flash Provider Design
|
||||
|
||||
> **Date:** 2026-02-26
|
||||
> **Status:** Approved
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Two combined features:
|
||||
|
||||
1. **AI Provider Abstraction** — Add Gemini 2.5 Flash as the default AI provider with Claude as fallback, behind a unified interface.
|
||||
2. **AI Auto-Fix for Validation Errors** — When a flow fails validation, offer an AI-powered "Fix with AI" button that generates structural fixes for review.
|
||||
|
||||
---
|
||||
|
||||
## Section 1: AI Provider Abstraction
|
||||
|
||||
### Design
|
||||
|
||||
New `backend/app/core/ai_provider.py` with a unified interface:
|
||||
|
||||
```python
|
||||
class AIProvider(ABC):
|
||||
async def generate_json(
|
||||
self,
|
||||
system_prompt: str,
|
||||
messages: list[dict],
|
||||
max_tokens: int = 4096,
|
||||
) -> tuple[str, int, int]:
|
||||
"""Returns (text, input_tokens, output_tokens)"""
|
||||
```
|
||||
|
||||
Two implementations:
|
||||
|
||||
| Provider | Model | SDK | Role |
|
||||
|----------|-------|-----|------|
|
||||
| `GeminiProvider` | `gemini-2.5-flash` | `google-genai` | Default |
|
||||
| `AnthropicProvider` | `claude-haiku-4-5-20251001` | `anthropic` | Fallback |
|
||||
|
||||
### Provider Selection
|
||||
|
||||
- `get_ai_provider()` factory reads `AI_PROVIDER` env var (default: `"gemini"`)
|
||||
- Falls back to Anthropic if Gemini key is missing
|
||||
- Existing `ai_tree_generator_service.py` swaps direct Anthropic calls for `get_ai_provider()`
|
||||
|
||||
### New Environment Variables
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|----------|---------|---------|
|
||||
| `AI_PROVIDER` | `"gemini"` | Which provider to use (`gemini` or `anthropic`) |
|
||||
| `GOOGLE_AI_API_KEY` | — | Gemini API key |
|
||||
|
||||
Existing `ANTHROPIC_API_KEY` remains for fallback.
|
||||
|
||||
### Config Changes (`core/config.py`)
|
||||
|
||||
```python
|
||||
AI_PROVIDER: str = "gemini"
|
||||
GOOGLE_AI_API_KEY: str | None = None
|
||||
AI_MODEL_GEMINI: str = "gemini-2.5-flash"
|
||||
AI_MODEL_ANTHROPIC: str = "claude-haiku-4-5-20251001"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Section 2: AI Auto-Fix Feature
|
||||
|
||||
### Backend Endpoint
|
||||
|
||||
**`POST /api/v1/ai/fix-tree`**
|
||||
|
||||
Request:
|
||||
```json
|
||||
{
|
||||
"tree_structure": { /* full tree */ },
|
||||
"tree_name": "Router Troubleshooting",
|
||||
"tree_type": "troubleshooting",
|
||||
"validation_errors": [
|
||||
{
|
||||
"node_id": "node_abc",
|
||||
"message": "Decision node must have at least 2 children (branches)"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"fixes": [
|
||||
{
|
||||
"target_node_id": "node_abc",
|
||||
"error_message": "Decision node must have at least 2 children (branches)",
|
||||
"description": "Added second branch 'Check firmware version' with solution node",
|
||||
"original_node": { /* snapshot before fix */ },
|
||||
"fixed_node": { /* replacement node with corrected subtree */ }
|
||||
}
|
||||
],
|
||||
"tokens_used": { "input": 1200, "output": 800 }
|
||||
}
|
||||
```
|
||||
|
||||
### How It Works
|
||||
|
||||
1. For each validation error tied to a `node_id`, extract that node + its parent + siblings from the tree.
|
||||
2. Build a prompt with:
|
||||
- The **full tree structure** serialized as a simplified outline (node titles + types + structure) for context
|
||||
- The **specific failing node** highlighted with full JSON detail
|
||||
- The **validation error message**
|
||||
- Instructions: "Fix ONLY this node's structural issue. Keep all existing content. Generate domain-relevant additions that fit the flow's topic."
|
||||
3. AI returns a corrected version of that node (with children/options adjusted).
|
||||
4. Backend re-validates the fixed node before returning it.
|
||||
5. If re-validation fails, retry once with the error fed back (corrective prompt pattern).
|
||||
|
||||
### Prompt Strategy
|
||||
|
||||
The prompt gives the AI the full tree as a compact outline, then zooms into the failing node:
|
||||
|
||||
```
|
||||
You are fixing a validation error in a troubleshooting flow called "Router Troubleshooting".
|
||||
|
||||
FULL FLOW OUTLINE:
|
||||
- [decision] Is the router powered on?
|
||||
- [action] Check power cable → [solution] Power restored
|
||||
- [decision] Are lights blinking? ← ERROR HERE
|
||||
- [solution] Contact ISP
|
||||
|
||||
ERROR: Decision node "Are lights blinking?" must have at least 2 children (branches).
|
||||
|
||||
FAILING NODE (full detail):
|
||||
{...json...}
|
||||
|
||||
Fix this node by adding the minimum structure needed to resolve the error.
|
||||
Return ONLY the fixed node as JSON.
|
||||
```
|
||||
|
||||
### Frontend UX
|
||||
|
||||
1. **Trigger**: "Fix with AI" button in `ValidationSummary` — appears when there are fixable errors (structural errors with a `node_id`).
|
||||
2. **Loading state**: Button shows spinner + "Generating fixes..." — disabled during request.
|
||||
3. **Review modal** (`AIFixReviewModal`): Shows each proposed fix as a card:
|
||||
- Error message at top
|
||||
- Before/after view of the node change
|
||||
- "Apply" / "Skip" buttons per fix
|
||||
- "Apply All" button in footer
|
||||
4. **Apply**: Each accepted fix calls `updateNode(targetNodeId, fixedNode)` in the tree editor store.
|
||||
5. **Re-validate**: After applying fixes, auto-run `validate()` to confirm resolution.
|
||||
|
||||
---
|
||||
|
||||
## Section 3: Scope & Constraints
|
||||
|
||||
### Fixable Errors (Auto-Fix Scope)
|
||||
|
||||
Only structural validation errors with a `node_id`:
|
||||
- Decision node missing children/branches
|
||||
- Decision node missing options
|
||||
- Action node missing `next_node_id`
|
||||
- Dead-end decision nodes (no children)
|
||||
|
||||
### NOT Fixable
|
||||
|
||||
- Global checks (tree too small/large, not enough solutions) — require rethinking the whole tree
|
||||
- Content quality issues — out of scope
|
||||
- Errors without a `node_id` (root-level issues)
|
||||
|
||||
Non-fixable errors still show in ValidationSummary but without the "Fix with AI" option.
|
||||
|
||||
### Token Budget
|
||||
|
||||
- Tree outline: ~50-100 tokens for a typical 15-node tree
|
||||
- Failing node detail: ~100-200 tokens
|
||||
- System prompt + instructions: ~300 tokens
|
||||
- **Total input per fix: ~500-600 tokens**
|
||||
- One API call per failing node (not batched)
|
||||
|
||||
### Error Handling
|
||||
|
||||
- Provider failure (rate limit, network): toast error, user can retry
|
||||
- Fix fails re-validation: "AI couldn't generate a valid fix" with retry option
|
||||
- Max 1 retry with corrective prompt per attempt
|
||||
- Both provider and fallback fail: surface error to user
|
||||
|
||||
### Auth
|
||||
|
||||
- Requires `engineer` role or above (`require_engineer_or_admin`)
|
||||
|
||||
---
|
||||
|
||||
## New Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `backend/app/core/ai_provider.py` | Provider abstraction + Gemini/Anthropic implementations |
|
||||
| `backend/app/core/ai_fix_service.py` | Fix generation logic + prompt building |
|
||||
| `backend/app/api/endpoints/ai.py` | `POST /ai/fix-tree` endpoint |
|
||||
| `backend/app/schemas/ai.py` | Request/response schemas for AI endpoints |
|
||||
| `frontend/src/components/tree-editor/AIFixReviewModal.tsx` | Review modal for proposed fixes |
|
||||
|
||||
## Modified Files
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `backend/app/core/config.py` | Add Gemini config vars |
|
||||
| `backend/app/core/ai_tree_generator_service.py` | Swap Anthropic calls for provider abstraction |
|
||||
| `backend/app/api/router.py` | Register `/ai` routes |
|
||||
| `frontend/src/api/trees.ts` | Add `fixTree()` API call |
|
||||
| `frontend/src/components/tree-editor/ValidationSummary.tsx` | Add "Fix with AI" button |
|
||||
1707
docs/plans/archive/2026-02-26-ai-autofix-gemini-plan.md
Normal file
1707
docs/plans/archive/2026-02-26-ai-autofix-gemini-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
314
docs/plans/archive/2026-02-27-ai-chat-builder-design.md
Normal file
314
docs/plans/archive/2026-02-27-ai-chat-builder-design.md
Normal file
@@ -0,0 +1,314 @@
|
||||
# AI Chat Builder — Design Document
|
||||
|
||||
> **Date:** 2026-02-27
|
||||
> **Status:** Approved
|
||||
> **Relationship to existing wizard:** Coexists. Wizard stays as "Quick Build," this becomes "Build with AI" with a separate page and entry point.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
A conversational AI flow builder where the AI acts as a senior MSP engineer, conducting a multi-phase interview to collaboratively build troubleshooting or procedural flows. The user chats naturally; the AI demonstrates domain expertise, suggests diagnostic steps, challenges assumptions, and progressively constructs a TreeStructure that imports into the Tree Editor.
|
||||
|
||||
This is distinct from the existing wizard-based AI builder (scaffold → branch detail → assemble). The wizard is fast and automated; the chat builder is collaborative and guided.
|
||||
|
||||
---
|
||||
|
||||
## Key Decisions
|
||||
|
||||
| Decision | Choice | Rationale |
|
||||
|----------|--------|-----------|
|
||||
| Relationship to wizard | Keep both, separate entry points | Different use cases: quick generation vs. guided interview |
|
||||
| SSE streaming | Not in MVP | Full-message responses with loading spinner. Add streaming later. |
|
||||
| Database model | New `ai_chat_sessions` table | Different workflow/state shape than wizard's `ai_conversations` |
|
||||
| Backend organization | Follow existing `core/ai_*.py` pattern | Consistent with codebase. No new `services/` directory. |
|
||||
| Provider failover | Skip for MVP | `get_ai_provider()` selects configured provider. Failover later if needed. |
|
||||
|
||||
---
|
||||
|
||||
## Backend Data Model
|
||||
|
||||
### New Table: `ai_chat_sessions`
|
||||
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| `id` | UUID PK | `default=uuid4` |
|
||||
| `user_id` | UUID FK → users | `ondelete=CASCADE` |
|
||||
| `account_id` | UUID FK → accounts | `ondelete=CASCADE` |
|
||||
| `status` | VARCHAR | `active`, `completed`, `abandoned` |
|
||||
| `current_phase` | VARCHAR | `scoping`, `discovery`, `enrichment`, `review`, `generation` |
|
||||
| `conversation_history` | JSONB | `[{role, content, timestamp}]` |
|
||||
| `working_tree` | JSONB nullable | Progressive TreeStructure, updated as AI builds |
|
||||
| `tree_metadata` | JSONB | `{name, description, category_id, tags}` extracted during conversation |
|
||||
| `flow_type` | VARCHAR | `troubleshooting` or `procedural` |
|
||||
| `provider_used` | VARCHAR nullable | Which AI provider served this session |
|
||||
| `message_count` | INTEGER | Per-session exchange cap (prevents runaway token spend) |
|
||||
| `total_input_tokens` | INTEGER | Running total for cost tracking |
|
||||
| `total_output_tokens` | INTEGER | Running total |
|
||||
| `generated_tree_id` | UUID FK → trees nullable | Set when user imports to editor |
|
||||
| `expires_at` | DateTime(tz) | 24-hour TTL |
|
||||
| `created_at` | DateTime(tz) | |
|
||||
| `updated_at` | DateTime(tz) | |
|
||||
|
||||
### Quota Tracking
|
||||
|
||||
Reuses existing `ai_usage` table with new generation types:
|
||||
- `chat_message` — each exchange, `counts_toward_quota=False`
|
||||
- `chat_generate` — final tree generation, `counts_toward_quota=True`
|
||||
|
||||
**Required change:** Update `ai_quota_service.py` daily limit query to include chat builder types in the `generation_type.in_(...)` filter.
|
||||
|
||||
### Enforcement Layers
|
||||
|
||||
1. **Account monthly** — `ai_usage` with `counts_toward_quota=True` (existing, works across both builders)
|
||||
2. **Account daily** — `ai_usage` by generation type (update filter to include chat types)
|
||||
3. **Per-session** — `message_count` cap on session table (prevents runaway single-session spend)
|
||||
|
||||
---
|
||||
|
||||
## Backend API Endpoints
|
||||
|
||||
Router: `api/endpoints/ai_chat.py`, prefix `/ai/chat`, tag `ai-chat-builder`
|
||||
|
||||
| Method | Endpoint | Purpose |
|
||||
|--------|----------|---------|
|
||||
| `POST` | `/ai/chat/sessions` | Start session. Check quota, create session, return AI greeting. |
|
||||
| `POST` | `/ai/chat/sessions/{id}/messages` | Send message, get AI response. Updates working_tree if AI includes tree update. |
|
||||
| `GET` | `/ai/chat/sessions/{id}` | Get full session state. For resume after page reload. |
|
||||
| `POST` | `/ai/chat/sessions/{id}/generate` | Final TreeStructure JSON generation. Validates, one retry on failure. |
|
||||
| `POST` | `/ai/chat/sessions/{id}/import` | Create Tree record from generated tree. Returns tree ID. |
|
||||
| `DELETE` | `/ai/chat/sessions/{id}` | Abandon session (soft — sets status to `abandoned`). |
|
||||
|
||||
**Rate limiting:** `@limiter.limit("10/minute")` on all mutating endpoints.
|
||||
|
||||
**Timeout handling for `/generate`:**
|
||||
- AI provider timeout → 504 with clear "Generation timed out, please try again" message
|
||||
- Failed attempts recorded with `counts_toward_quota=False`
|
||||
- Session stays in `review` phase so user can retry
|
||||
- Guard: if session already has a completed generation, return cached result (prevents duplicate costs from double-clicks)
|
||||
|
||||
**Ownership:** All endpoints verify `session.user_id == current_user.id`.
|
||||
|
||||
---
|
||||
|
||||
## Backend Service Layer
|
||||
|
||||
New file: `core/ai_chat_service.py`
|
||||
|
||||
### Functions
|
||||
|
||||
**`start_chat_session(flow_type, user_id, account_id, db) → (session, greeting_message)`**
|
||||
- Creates session record
|
||||
- Builds system prompt
|
||||
- Sends initial prime to AI, returns greeting that demonstrates domain knowledge
|
||||
|
||||
**`send_message(session, user_message, db) → (ai_response, working_tree_update, phase)`**
|
||||
- Appends user message to history
|
||||
- Sends system prompt + full conversation history to AI
|
||||
- Parses response: extracts conversational text, `[TREE_UPDATE]`, `[PHASE]`, `[METADATA]` markers
|
||||
- Strips markers before storing the user-visible response
|
||||
- Updates working_tree and phase if present
|
||||
- Records usage via `record_ai_usage()`
|
||||
|
||||
**`generate_final_tree(session, db) → (tree_structure, metadata)`**
|
||||
- Sends generation prompt with full conversation context
|
||||
- Validates output (orphans, circular refs, missing terminals, valid next_node_id refs)
|
||||
- One retry with correction prompt if validation fails
|
||||
- Returns validated TreeStructure + metadata
|
||||
|
||||
### System Prompt Architecture
|
||||
|
||||
Four sections assembled programmatically:
|
||||
|
||||
1. **Role & Persona** — Senior MSP engineer, 15+ years, collegial and direct. Domain expertise: Windows Server, AD, Entra ID, M365, networking, virtualization, security, cloud.
|
||||
|
||||
2. **Schema Context** — TreeStructure node types with field definitions. Generated from Python dict to stay in sync with actual types.
|
||||
|
||||
3. **Interview Protocol** — Five-phase structure with behavioral rules:
|
||||
- One focused question at a time
|
||||
- Demonstrate domain knowledge immediately
|
||||
- Challenge assumptions constructively
|
||||
- Capture specific commands with exact syntax
|
||||
- Surface edge cases proactively
|
||||
- Explain WHY diagnostic order matters
|
||||
|
||||
4. **Response Format** — Structured output markers:
|
||||
- Conversational text (always present, this is what the user sees)
|
||||
- `[TREE_UPDATE]{...json...}[/TREE_UPDATE]` — only when tree structure changes
|
||||
- `[PHASE:phase_name]` — when transitioning phases
|
||||
- `[METADATA]{...json...}[/METADATA]` — when capturing tree name/description/tags
|
||||
|
||||
### Tree Update Rules (Critical for Prompt Quality)
|
||||
|
||||
The system prompt must be explicit about when to emit `[TREE_UPDATE]`:
|
||||
|
||||
- **Scoping phase:** Never. Still understanding the problem space.
|
||||
- **Discovery phase:** Only when a concrete node is established — a decision with clear options, or an action with a specific command. Not during exploratory questions.
|
||||
- **Enrichment phase:** When enriching existing nodes or adding edge case branches.
|
||||
- **Review phase:** Only if user requests structural changes.
|
||||
- **General rule:** "If you're asking a question, you're not updating the tree."
|
||||
|
||||
> **Known tuning area:** The tree update prompt rules will need iteration. First version will likely be too eager or too conservative. Test with Section 7 scenarios from the implementation guide and adjust.
|
||||
|
||||
---
|
||||
|
||||
## Frontend Architecture
|
||||
|
||||
### Page Layout
|
||||
|
||||
Split-panel inside existing AppLayout shell:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Toolbar: [phase indicator] [Import] [Abandon] │
|
||||
├──────────────────────────┬──────────────────────────┤
|
||||
│ Chat Panel (60%) │ Tree Preview (40%) │
|
||||
│ │ │
|
||||
│ AI greeting message │ Empty state until │
|
||||
│ User message │ discovery phase │
|
||||
│ AI response │ │
|
||||
│ ... │ TreePreviewPanel │
|
||||
│ │ (reused component) │
|
||||
│ [Type a message...] │ │
|
||||
└──────────────────────────┴──────────────────────────┘
|
||||
```
|
||||
|
||||
Responsive: Below 1024px, stack vertically. Preview collapses to expandable panel.
|
||||
|
||||
### Route
|
||||
|
||||
`/ai/chat` → `AIChatBuilderPage` — under protected AppLayout children in `router.tsx`.
|
||||
|
||||
### Zustand Store: `store/aiChatStore.ts`
|
||||
|
||||
Plain store (no immer/temporal — conversation is append-only):
|
||||
|
||||
```typescript
|
||||
interface AIChatState {
|
||||
sessionId: string | null
|
||||
status: 'idle' | 'active' | 'completed' | 'abandoned'
|
||||
currentPhase: InterviewPhase
|
||||
flowType: 'troubleshooting' | 'procedural' | null
|
||||
messages: ChatMessage[]
|
||||
isResponding: boolean
|
||||
workingTree: TreeStructure | null
|
||||
treeMetadata: { name?: string; description?: string; tags?: string[]; category_id?: string } | null
|
||||
generatedTree: TreeStructure | null
|
||||
isGenerating: boolean
|
||||
importedTreeId: string | null
|
||||
error: string | null
|
||||
|
||||
startSession: (flowType) => Promise<void>
|
||||
sendMessage: (content: string) => Promise<void>
|
||||
generateTree: () => Promise<void>
|
||||
importToEditor: () => Promise<string>
|
||||
abandonSession: () => Promise<void>
|
||||
resumeSession: (sessionId: string) => Promise<void>
|
||||
reset: () => void
|
||||
}
|
||||
```
|
||||
|
||||
No localStorage persistence. Session state lives server-side. Page reload → `resumeSession()` rehydrates from API.
|
||||
|
||||
### New Components (`components/ai-chat/`)
|
||||
|
||||
| Component | Purpose |
|
||||
|-----------|---------|
|
||||
| `ChatPanel.tsx` | Scrollable message list + input. Auto-scroll on new messages. |
|
||||
| `ChatMessage.tsx` | Message bubble. AI uses `MarkdownContent`, user messages plain. |
|
||||
| `ChatInput.tsx` | Text input. Enter to send, Shift+Enter for newline. Disabled while AI responds. |
|
||||
| `PhaseIndicator.tsx` | Horizontal breadcrumb: Scoping → Discovery → Enrichment → Review → Generate |
|
||||
| `ChatToolbar.tsx` | Top bar with phase indicator + action buttons |
|
||||
| `EmptyPreview.tsx` | Placeholder before tree data exists |
|
||||
|
||||
### Reused Components
|
||||
|
||||
- `TreePreviewPanel` — right panel tree visualization
|
||||
- `MarkdownContent` — AI message rendering
|
||||
- `PageLoader` / `Spinner` — loading states
|
||||
|
||||
### Entry Point
|
||||
|
||||
"Build with AI" button on `TreeLibraryPage.tsx` alongside existing Create menu. Lucide `Sparkles` icon. Routes to `/ai/chat`.
|
||||
|
||||
---
|
||||
|
||||
## Files
|
||||
|
||||
### New Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `backend/app/models/ai_chat_session.py` | SQLAlchemy model |
|
||||
| `backend/app/schemas/ai_chat.py` | Pydantic schemas |
|
||||
| `backend/app/core/ai_chat_service.py` | Conversation loop, system prompt, parsing |
|
||||
| `backend/app/api/endpoints/ai_chat.py` | API endpoints |
|
||||
| `backend/alembic/versions/XXX_add_ai_chat_sessions.py` | Migration |
|
||||
| `frontend/src/types/ai-chat.ts` | TypeScript interfaces |
|
||||
| `frontend/src/api/aiChat.ts` | API client module |
|
||||
| `frontend/src/store/aiChatStore.ts` | Zustand store |
|
||||
| `frontend/src/pages/AIChatBuilderPage.tsx` | Main page |
|
||||
| `frontend/src/components/ai-chat/ChatPanel.tsx` | Chat message list |
|
||||
| `frontend/src/components/ai-chat/ChatMessage.tsx` | Single message bubble |
|
||||
| `frontend/src/components/ai-chat/ChatInput.tsx` | Text input + send |
|
||||
| `frontend/src/components/ai-chat/PhaseIndicator.tsx` | Phase breadcrumb |
|
||||
| `frontend/src/components/ai-chat/ChatToolbar.tsx` | Top bar |
|
||||
| `frontend/src/components/ai-chat/EmptyPreview.tsx` | Preview placeholder |
|
||||
|
||||
### Modified Files
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `backend/app/api/router.py` | Include `ai_chat.router` |
|
||||
| `backend/app/models/__init__.py` | Import `AIChatSession` |
|
||||
| `backend/app/core/ai_quota_service.py` | Add chat builder types to daily limit query |
|
||||
| `frontend/src/types/index.ts` | Export from `ai-chat.ts` |
|
||||
| `frontend/src/api/index.ts` | Export `aiChat` module |
|
||||
| `frontend/src/router.tsx` | Add `/ai/chat` route |
|
||||
| `frontend/src/pages/TreeLibraryPage.tsx` | Add "Build with AI" button |
|
||||
|
||||
---
|
||||
|
||||
## Conflicts with Implementation Guide
|
||||
|
||||
| Guide Says | Resolution |
|
||||
|-----------|------------|
|
||||
| `services/ai/` directory structure | Follow existing `core/ai_*.py` pattern |
|
||||
| `AIRouter` with automatic failover | Skip for MVP. Single configured provider. |
|
||||
| SSE streaming | Skip for MVP. Full-message responses. |
|
||||
| Separate `prompts/` and `validators/` modules | Prompt in service file. Validation reuses existing helpers. |
|
||||
| `flow_builder_sessions` table name | `ai_chat_sessions` for naming consistency |
|
||||
| Subscription tier table (Free: 2, Pro: 20, Team: unlimited) | Existing `plan_limits` handles this already |
|
||||
| Caching for similar problem domains | Skip. Premature optimization. |
|
||||
| Guide's Phase 4 polish items | Session resume included. Mobile is basic stacking. Full accessibility post-MVP. |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Phases (from guide, adapted)
|
||||
|
||||
**Phase 1: Backend foundation** — Model, migration, service, endpoints, system prompt, validation. Milestone: can POST a message and get a domain-knowledgeable AI response.
|
||||
|
||||
**Phase 2: Frontend chat UI** — Page, store, components, routing, entry point. Milestone: full conversation flow with AI, no tree preview yet.
|
||||
|
||||
**Phase 3: Tree preview & import** — Wire up TreePreviewPanel with working_tree updates, generate action, import to editor. Milestone: end-to-end flow from conversation to tree in editor.
|
||||
|
||||
**Phase 4: Polish** — Error recovery, session resume on reload, prompt tuning with test scenarios, mobile layout.
|
||||
|
||||
---
|
||||
|
||||
## Test Scenarios (from guide Section 7)
|
||||
|
||||
Use after each phase to verify quality:
|
||||
1. Azure AD Connect Sync Failures (identity/hybrid cloud)
|
||||
2. Intermittent Site-to-Site VPN Drops (networking)
|
||||
3. Exchange Online Mail Flow Issues (M365/email)
|
||||
4. Slow Computer Troubleshooting (Tier 1 desktop support)
|
||||
|
||||
---
|
||||
|
||||
## Open Items / Known Tuning Areas
|
||||
|
||||
- System prompt `[TREE_UPDATE]` emission rules will need iteration with real conversations
|
||||
- System prompt persona/tone will need tuning to feel like a colleague, not a chatbot
|
||||
- Final generation prompt may need a more capable model than conversation phase
|
||||
- Message count cap per session needs a specific number (guide suggests 25 for paid tiers, 10 for free)
|
||||
2626
docs/plans/archive/2026-02-27-ai-chat-builder-implementation.md
Normal file
2626
docs/plans/archive/2026-02-27-ai-chat-builder-implementation.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,80 @@
|
||||
# Cross-Reference / Loop-Back Support — Design
|
||||
|
||||
**Goal:** Allow tree nodes to reference any other node in the tree (not just direct children), enabling loop-back patterns like "remediate → re-verify from earlier checkpoint."
|
||||
|
||||
**Architecture:** Ghost references on existing tree structure. No schema change, no migration. A cross-reference is any `next_node_id` that points outside the current node's `children` array. The canvas renders these as dashed SVG overlay arrows. Navigation already supports this.
|
||||
|
||||
**Approach chosen:** Approach 1 — "Ghost references" (keep tree structure, add visual cross-ref edges)
|
||||
|
||||
---
|
||||
|
||||
## 1. Data Model — No Changes
|
||||
|
||||
The `TreeStructure` type and database stay as-is. The distinction is semantic:
|
||||
|
||||
- **Local link:** `next_node_id` → direct child → normal tree edge
|
||||
- **Cross-reference:** `next_node_id` → node elsewhere in tree → dashed overlay arrow
|
||||
|
||||
No new fields, no new node types, no migration.
|
||||
|
||||
## 2. Validation Changes
|
||||
|
||||
### Backend (`ai_tree_validator.py`)
|
||||
|
||||
- Relax decision option validation: `option.next_node_id` can reference any node in the tree (not just children). Check existence only, same as action nodes.
|
||||
|
||||
### Frontend circular reference detector (`treeEditorStore.ts`)
|
||||
|
||||
- Change loop detection from **error** to **warning**. Loops are now intentional. Warning text: "This path loops back to [node title]."
|
||||
|
||||
### Frontend orphan detection
|
||||
|
||||
- Keep as-is. Orphaned nodes still flagged as warnings.
|
||||
|
||||
## 3. Canvas Rendering — Cross-Reference Edges
|
||||
|
||||
- **SVG overlay** layer on top of the canvas (absolute positioned)
|
||||
- **Dashed line** with **arrowhead** pointing at target node
|
||||
- **Purple/primary color** to distinguish from normal gray tree connectors
|
||||
- Small label on the arrow (option label or "loops back")
|
||||
- After dagre layout, scan all nodes for `next_node_id` values not matching a direct child
|
||||
- Look up source/target positions from layout, draw curved SVG bezier path
|
||||
- Target node gets a subtle badge/indicator for inbound cross-references
|
||||
- Hovering the badge highlights source nodes
|
||||
|
||||
## 4. Editor UX — Creating Cross-References
|
||||
|
||||
### A. Node picker dropdown (in node form)
|
||||
|
||||
- Action nodes and decision option rows get "Link to existing node" dropdown
|
||||
- Lists all nodes by title/question, grouped by type
|
||||
- Selecting sets `next_node_id`; orphaned answer stubs cleaned up
|
||||
- "Clear link" option to remove
|
||||
|
||||
### B. Canvas drag-to-link
|
||||
|
||||
- Small output port (dot) at bottom of each node
|
||||
- Drag from port starts a dashed line following cursor
|
||||
- Drop on any node creates cross-reference
|
||||
- Drop on empty space cancels
|
||||
- Existing answer stubs cleaned up if replaced
|
||||
|
||||
### Visual feedback
|
||||
|
||||
- Node form: "Linked to: [node title]" with navigate + remove actions
|
||||
- Canvas: dashed arrow (Section 3)
|
||||
|
||||
## 5. AI Flow Assist — Prompt Changes
|
||||
|
||||
- Update system prompt STRUCTURAL RULES: "Action nodes can set `next_node_id` to any node in the tree, including ancestors, for loop-backs."
|
||||
- Add SSH loop example to schema context
|
||||
- No changes to generation or progressive validation
|
||||
|
||||
## 6. Navigation — No Changes
|
||||
|
||||
`findNode` already searches the full tree. `handleSelectOption` and `handleContinue` follow `next_node_id` without hierarchy checks. Session `pathTaken` will contain repeated IDs for loops — this is correct behavior.
|
||||
|
||||
## 7. Testing
|
||||
|
||||
- Backend: extend validator tests for cross-references
|
||||
- Frontend: `npm run build` after each piece, manual testing of editor + navigation loops
|
||||
656
docs/plans/archive/2026-02-28-cross-reference-loopback.md
Normal file
656
docs/plans/archive/2026-02-28-cross-reference-loopback.md
Normal file
@@ -0,0 +1,656 @@
|
||||
# Cross-Reference / Loop-Back Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Enable tree nodes to reference any other node in the tree (not just direct children), supporting loop-back patterns like "remediate → re-verify from earlier checkpoint."
|
||||
|
||||
**Architecture:** Ghost references on existing tree structure. No schema change, no migration. A cross-reference is any `next_node_id` that points outside the current node's `children` array. The canvas renders these as dashed overlay arrows. Navigation already supports this pattern.
|
||||
|
||||
**Tech Stack:** Python FastAPI (backend validation), React + @xyflow/react (canvas rendering), Zustand (store validation), TypeScript
|
||||
|
||||
**Design Doc:** `docs/plans/2026-02-28-cross-reference-loopback-design.md`
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Backend — Relax Decision Option Validation
|
||||
|
||||
Allow decision option `next_node_id` to reference ANY node in the tree, not just direct children.
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/core/ai_tree_validator.py:111-118`
|
||||
- Test: `backend/tests/test_ai_tree_validator.py`
|
||||
|
||||
**Step 1: Write the failing test — cross-reference option passes validation**
|
||||
|
||||
Add a new test class `TestCrossReferenceSupport` at the bottom of `test_ai_tree_validator.py`:
|
||||
|
||||
```python
|
||||
class TestCrossReferenceSupport:
|
||||
def test_option_referencing_non_child_node_in_tree_is_valid(self):
|
||||
"""A decision option can reference any node in the tree, not just direct children."""
|
||||
tree = _make_valid_tree()
|
||||
# Make root option point to a grandchild (not a direct child) — cross-reference
|
||||
tree["options"][0]["next_node_id"] = "fix-errors" # grandchild of root
|
||||
errors = validate_generated_tree(tree)
|
||||
# Should NOT have the "non-existent child" error for this reference
|
||||
assert not any("non-existent child" in e for e in errors)
|
||||
|
||||
def test_option_referencing_nonexistent_node_still_fails(self):
|
||||
"""Cross-references must still point to nodes that exist in the tree."""
|
||||
tree = _make_valid_tree()
|
||||
tree["options"][0]["next_node_id"] = "totally-fake-id"
|
||||
errors = validate_generated_tree(tree)
|
||||
assert any("does not exist" in e for e in errors)
|
||||
|
||||
def test_action_next_node_id_to_ancestor_is_valid(self):
|
||||
"""Action node can loop back to an ancestor node (the whole point of cross-refs)."""
|
||||
tree = _make_valid_tree()
|
||||
# Make the action node loop back to root
|
||||
tree["children"][1]["next_node_id"] = "root"
|
||||
errors = validate_generated_tree(tree)
|
||||
assert not any("does not exist" in e for e in errors)
|
||||
```
|
||||
|
||||
**Step 2: Run the tests to verify they fail**
|
||||
|
||||
Run: `cd backend && python -m pytest tests/test_ai_tree_validator.py::TestCrossReferenceSupport -v`
|
||||
Expected: `test_option_referencing_non_child_node_in_tree_is_valid` FAILS (currently raises "non-existent child" error). The other two should already pass.
|
||||
|
||||
**Step 3: Update validator — check global existence, not just children**
|
||||
|
||||
In `backend/app/core/ai_tree_validator.py`, replace lines 111-118 (the decision option next_node_id check):
|
||||
|
||||
Old code (lines 111-118):
|
||||
```python
|
||||
next_id = opt.get("next_node_id")
|
||||
if next_id:
|
||||
all_referenced_ids.add(next_id)
|
||||
if child_ids and next_id not in child_ids:
|
||||
errors.append(
|
||||
f"Option '{opt.get('label', '?')}' in node '{node_id}' "
|
||||
f"references non-existent child '{next_id}'"
|
||||
)
|
||||
```
|
||||
|
||||
New code:
|
||||
```python
|
||||
next_id = opt.get("next_node_id")
|
||||
if next_id:
|
||||
all_referenced_ids.add(next_id)
|
||||
```
|
||||
|
||||
Then add a new global check after line 145 (after the action next_node_id existence check). This checks ALL option references exist anywhere in the tree:
|
||||
|
||||
After the existing `for ref_id in action_next_ids:` block, add:
|
||||
```python
|
||||
# Check that all option next_node_ids exist in the tree (allows cross-references)
|
||||
for ref_id in all_referenced_ids:
|
||||
if ref_id not in all_ids:
|
||||
errors.append(
|
||||
f"Option next_node_id '{ref_id}' references a node that does not exist in the tree"
|
||||
)
|
||||
```
|
||||
|
||||
**Step 4: Run all validator tests to verify they pass**
|
||||
|
||||
Run: `cd backend && python -m pytest tests/test_ai_tree_validator.py -v`
|
||||
Expected: ALL tests pass. The old `test_option_references_nonexistent_child` test in `TestReferenceIntegrity` will now fail because the error message changed from "non-existent child" to "does not exist in the tree". Update that test:
|
||||
|
||||
In `TestReferenceIntegrity.test_option_references_nonexistent_child`, change:
|
||||
```python
|
||||
def test_option_references_nonexistent_child(self):
|
||||
tree = _make_valid_tree()
|
||||
tree["options"][0]["next_node_id"] = "nonexistent"
|
||||
errors = validate_generated_tree(tree)
|
||||
assert any("does not exist" in e for e in errors)
|
||||
```
|
||||
|
||||
Run again: `cd backend && python -m pytest tests/test_ai_tree_validator.py -v`
|
||||
Expected: ALL PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/core/ai_tree_validator.py backend/tests/test_ai_tree_validator.py
|
||||
git commit -m "feat: relax decision option validation — allow cross-references to any node in tree"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Frontend — Change Circular Reference Detection From Error to Warning
|
||||
|
||||
Loop-backs are now intentional. The circular reference detector should warn instead of error.
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/store/treeEditorStore.ts:791-824`
|
||||
|
||||
**Step 1: Update severity from 'error' to 'warning' and improve messages**
|
||||
|
||||
In `frontend/src/store/treeEditorStore.ts`, find the `detectCircularRefs` function (lines 792-824). Change both `severity: 'error'` to `severity: 'warning'` and update messages:
|
||||
|
||||
Replace line 803-807:
|
||||
```typescript
|
||||
errors.push({
|
||||
nodeId: node.id,
|
||||
message: `Circular reference detected: "${opt.label}" creates a loop`,
|
||||
severity: 'error'
|
||||
})
|
||||
```
|
||||
With:
|
||||
```typescript
|
||||
errors.push({
|
||||
nodeId: node.id,
|
||||
message: `This path loops back to an earlier node via "${opt.label}"`,
|
||||
severity: 'warning'
|
||||
})
|
||||
```
|
||||
|
||||
Replace lines 815-819:
|
||||
```typescript
|
||||
errors.push({
|
||||
nodeId: node.id,
|
||||
message: `Circular reference detected in node "${node.title || node.id}"`,
|
||||
severity: 'error'
|
||||
})
|
||||
```
|
||||
With:
|
||||
```typescript
|
||||
errors.push({
|
||||
nodeId: node.id,
|
||||
message: `This node loops back to an earlier node ("${node.title || node.id}")`,
|
||||
severity: 'warning'
|
||||
})
|
||||
```
|
||||
|
||||
**Step 2: Build to verify**
|
||||
|
||||
Run: `cd frontend && npm run build`
|
||||
Expected: Build succeeds with no errors.
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/store/treeEditorStore.ts
|
||||
git commit -m "feat: change circular reference detection from error to warning — loops are intentional"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Canvas — Render Cross-Reference Edges as Dashed Arrows
|
||||
|
||||
Add dashed purple overlay edges for any `next_node_id` pointing outside the current node's children.
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/tree-editor/useTreeLayout.ts`
|
||||
|
||||
**Step 1: Add cross-reference edge collection to the `walk` function**
|
||||
|
||||
In `useTreeLayout.ts`, inside the `useMemo` callback (line 57), after the `walk(treeStructure, null)` call on line 129, add a second pass to collect cross-reference edges.
|
||||
|
||||
Add this helper function before the `useTreeLayout` export (around line 40):
|
||||
|
||||
```typescript
|
||||
/** Collect all node IDs in the tree. */
|
||||
function collectAllIds(root: TreeStructure): Set<string> {
|
||||
const ids = new Set<string>()
|
||||
function walk(node: TreeStructure) {
|
||||
ids.add(node.id)
|
||||
node.children?.forEach(walk)
|
||||
}
|
||||
walk(root)
|
||||
return ids
|
||||
}
|
||||
|
||||
/** Find all cross-reference edges (next_node_id pointing outside children). */
|
||||
function collectCrossRefEdges(root: TreeStructure): Array<{ source: string; target: string; label?: string }> {
|
||||
const refs: Array<{ source: string; target: string; label?: string }> = []
|
||||
const allIds = collectAllIds(root)
|
||||
|
||||
function walk(node: TreeStructure) {
|
||||
const childIds = new Set(node.children?.map(c => c.id) ?? [])
|
||||
|
||||
// Decision options pointing outside children
|
||||
if (node.type === 'decision' && node.options) {
|
||||
for (const opt of node.options) {
|
||||
if (opt.next_node_id && !childIds.has(opt.next_node_id) && allIds.has(opt.next_node_id)) {
|
||||
refs.push({ source: node.id, target: opt.next_node_id, label: opt.label })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Action next_node_id pointing to non-child (always a cross-ref since actions use next_node_id not children)
|
||||
if (node.type === 'action' && node.next_node_id && allIds.has(node.next_node_id) && !childIds.has(node.next_node_id)) {
|
||||
refs.push({ source: node.id, target: node.next_node_id, label: 'loops back' })
|
||||
}
|
||||
|
||||
node.children?.forEach(walk)
|
||||
}
|
||||
|
||||
walk(root)
|
||||
return refs
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Add cross-reference edges to the edges array**
|
||||
|
||||
In the `useMemo` callback, after `walk(treeStructure, null)` (line 129) and before the return (line 131), add:
|
||||
|
||||
```typescript
|
||||
// Add cross-reference edges (dashed, purple)
|
||||
if (treeStructure) {
|
||||
const crossRefs = collectCrossRefEdges(treeStructure)
|
||||
for (const ref of crossRefs) {
|
||||
// Only add if both source and target nodes are visible (not collapsed away)
|
||||
const sourceVisible = nodes.some(n => n.id === ref.source)
|
||||
const targetVisible = nodes.some(n => n.id === ref.target)
|
||||
if (sourceVisible && targetVisible) {
|
||||
edges.push({
|
||||
id: `xref-${ref.source}->${ref.target}`,
|
||||
source: ref.source,
|
||||
target: ref.target,
|
||||
type: 'smoothstep',
|
||||
animated: true,
|
||||
label: ref.label ? truncateLabel(ref.label) : undefined,
|
||||
labelStyle: { fill: 'hsl(var(--primary))', fontSize: 10, fontWeight: 500 },
|
||||
labelBgStyle: { fill: 'hsl(var(--card))', fillOpacity: 0.95 },
|
||||
labelBgPadding: [4, 2] as [number, number],
|
||||
style: {
|
||||
stroke: 'hsl(var(--primary))',
|
||||
strokeWidth: 2,
|
||||
strokeDasharray: '6 3',
|
||||
},
|
||||
markerEnd: {
|
||||
type: 'arrowclosed' as const,
|
||||
color: 'hsl(var(--primary))',
|
||||
width: 16,
|
||||
height: 16,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Build to verify**
|
||||
|
||||
Run: `cd frontend && npm run build`
|
||||
Expected: Build succeeds. Cross-reference edges render as animated, dashed purple arrows with arrowheads.
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/components/tree-editor/useTreeLayout.ts
|
||||
git commit -m "feat: render cross-reference edges as dashed purple arrows on canvas"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Editor UX — Node Picker Dropdown for Action Nodes
|
||||
|
||||
Add a "Link to existing node" dropdown to `NodeFormAction.tsx`.
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/tree-editor/NodeFormAction.tsx`
|
||||
- Modify: `frontend/src/store/treeEditorStore.ts` (add helper to collect all nodes)
|
||||
|
||||
**Step 1: Add `collectAllNodes` helper to the tree editor store**
|
||||
|
||||
In `frontend/src/store/treeEditorStore.ts`, add a standalone exported helper function (near the top of the file, after imports, or as a utility):
|
||||
|
||||
Find the `findNodeInTree` helper function. Near it, add:
|
||||
|
||||
```typescript
|
||||
/** Collect all nodes in the tree as a flat list with depth info. */
|
||||
export function collectAllNodesFlat(
|
||||
root: TreeStructure | null
|
||||
): Array<{ id: string; label: string; type: string; depth: number }> {
|
||||
if (!root) return []
|
||||
const result: Array<{ id: string; label: string; type: string; depth: number }> = []
|
||||
|
||||
function walk(node: TreeStructure, depth: number) {
|
||||
const label = node.type === 'decision'
|
||||
? (node.question || 'Untitled Decision')
|
||||
: (node.title || `Untitled ${node.type}`)
|
||||
result.push({ id: node.id, label, type: node.type, depth })
|
||||
node.children?.forEach(child => walk(child, depth + 1))
|
||||
}
|
||||
|
||||
walk(root, 0)
|
||||
return result
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Update NodeFormAction to include the node picker**
|
||||
|
||||
Replace the "Next step hint" section at the bottom of `NodeFormAction.tsx` (lines 161-170) with a full node picker:
|
||||
|
||||
```tsx
|
||||
import { Link2, X } from 'lucide-react'
|
||||
import { collectAllNodesFlat } from '@/store/treeEditorStore'
|
||||
```
|
||||
|
||||
(Add these to existing imports at top of file)
|
||||
|
||||
Replace lines 161-170 (the `{hasNextNode ? ... : ...}` block):
|
||||
|
||||
```tsx
|
||||
{/* Link to existing node */}
|
||||
<div>
|
||||
<label className="flex items-center gap-1.5 text-sm font-medium text-foreground">
|
||||
<Link2 className="h-3.5 w-3.5" />
|
||||
Next Step
|
||||
</label>
|
||||
{hasNextNode ? (
|
||||
<div className="mt-1 flex items-center gap-2 rounded-md border border-primary/30 bg-primary/5 px-3 py-2">
|
||||
<span className="flex-1 truncate text-sm text-foreground">
|
||||
Linked to: {(() => {
|
||||
const treeStructure = useTreeEditorStore.getState().treeStructure
|
||||
const allNodes = collectAllNodesFlat(treeStructure)
|
||||
const target = allNodes.find(n => n.id === node.next_node_id)
|
||||
return target ? target.label : node.next_node_id
|
||||
})()}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onUpdate({ next_node_id: undefined })}
|
||||
className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
title="Remove link"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
value=""
|
||||
onChange={(e) => {
|
||||
if (e.target.value) {
|
||||
onUpdate({ next_node_id: e.target.value })
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'mt-1 block w-full rounded-md border border-border px-3 py-2 text-sm',
|
||||
'bg-card text-foreground',
|
||||
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20'
|
||||
)}
|
||||
>
|
||||
<option value="">Link to existing node...</option>
|
||||
{(() => {
|
||||
const treeStructure = useTreeEditorStore.getState().treeStructure
|
||||
const allNodes = collectAllNodesFlat(treeStructure)
|
||||
return allNodes
|
||||
.filter(n => n.id !== node.id && n.type !== 'answer')
|
||||
.map(n => (
|
||||
<option key={n.id} value={n.id}>
|
||||
{' '.repeat(n.depth)}{n.type === 'decision' ? '?' : n.type === 'action' ? '>' : '*'} {n.label}
|
||||
</option>
|
||||
))
|
||||
})()}
|
||||
</select>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{hasNextNode
|
||||
? 'This action will navigate to the linked node.'
|
||||
: 'Select a node to navigate to after this action, or save to create a new placeholder.'}
|
||||
</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Step 3: Build to verify**
|
||||
|
||||
Run: `cd frontend && npm run build`
|
||||
Expected: Build succeeds.
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/components/tree-editor/NodeFormAction.tsx frontend/src/store/treeEditorStore.ts
|
||||
git commit -m "feat: add node picker dropdown to action node form for cross-references"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Editor UX — Node Picker for Decision Option Rows
|
||||
|
||||
Add "Link to existing node" capability to each decision option row.
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/tree-editor/NodeFormDecision.tsx`
|
||||
|
||||
**Step 1: Add link icon and dropdown per option row**
|
||||
|
||||
Add imports at top of `NodeFormDecision.tsx`:
|
||||
```tsx
|
||||
import { Link2 } from 'lucide-react'
|
||||
import { collectAllNodesFlat } from '@/store/treeEditorStore'
|
||||
```
|
||||
|
||||
In the option render callback (inside `renderItem` around line 161), after the label input and its error message, add a cross-reference link indicator per option. After the closing `</div>` of the `flex-1` wrapper (around line 197), add:
|
||||
|
||||
```tsx
|
||||
{/* Cross-reference link indicator */}
|
||||
{option.next_node_id && (() => {
|
||||
const treeStructure = useTreeEditorStore.getState().treeStructure
|
||||
const childIds = new Set(node.children?.map(c => c.id) ?? [])
|
||||
// Only show if it's a cross-reference (points outside children)
|
||||
if (childIds.has(option.next_node_id)) return null
|
||||
const allNodes = collectAllNodesFlat(treeStructure)
|
||||
const target = allNodes.find(n => n.id === option.next_node_id)
|
||||
if (!target) return null
|
||||
return (
|
||||
<div className="flex items-center gap-1 text-xs text-primary" title={`Links to: ${target.label}`}>
|
||||
<Link2 className="h-3 w-3" />
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
```
|
||||
|
||||
**Step 2: Add "Link to node" option below the options list**
|
||||
|
||||
After the `DynamicArrayField` closing tag (line 201, before the root tip), add:
|
||||
|
||||
```tsx
|
||||
{/* Quick-link: assign an option to an existing node */}
|
||||
<details className="mt-2">
|
||||
<summary className="cursor-pointer text-xs text-muted-foreground hover:text-foreground">
|
||||
<Link2 className="inline h-3 w-3 mr-1" />
|
||||
Link an option to an existing node (cross-reference)
|
||||
</summary>
|
||||
<div className="mt-2 space-y-2 rounded-md border border-border bg-accent/30 p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Select an option, then pick a target node. This creates a loop-back or cross-reference.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
id="xref-option-select"
|
||||
className={cn(
|
||||
'flex-1 rounded-md border border-border px-2 py-1.5 text-xs',
|
||||
'bg-card text-foreground'
|
||||
)}
|
||||
defaultValue=""
|
||||
>
|
||||
<option value="">Select option...</option>
|
||||
{(node.options || []).map((opt, i) => (
|
||||
<option key={opt.id} value={i}>
|
||||
{indexToLetter(i)}: {opt.label || '(empty)'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
id="xref-target-select"
|
||||
className={cn(
|
||||
'flex-1 rounded-md border border-border px-2 py-1.5 text-xs',
|
||||
'bg-card text-foreground'
|
||||
)}
|
||||
defaultValue=""
|
||||
onChange={(e) => {
|
||||
const optSelect = document.getElementById('xref-option-select') as HTMLSelectElement
|
||||
const optIndex = parseInt(optSelect?.value, 10)
|
||||
const targetId = e.target.value
|
||||
if (!isNaN(optIndex) && targetId) {
|
||||
handleUpdateOption(optIndex, { next_node_id: targetId })
|
||||
// Reset selects
|
||||
optSelect.value = ''
|
||||
e.target.value = ''
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="">Select target node...</option>
|
||||
{(() => {
|
||||
const treeStructure = useTreeEditorStore.getState().treeStructure
|
||||
const allNodes = collectAllNodesFlat(treeStructure)
|
||||
return allNodes
|
||||
.filter(n => n.id !== node.id && n.type !== 'answer')
|
||||
.map(n => (
|
||||
<option key={n.id} value={n.id}>
|
||||
{' '.repeat(n.depth)}{n.type === 'decision' ? '?' : n.type === 'action' ? '>' : '*'} {n.label}
|
||||
</option>
|
||||
))
|
||||
})()}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
```
|
||||
|
||||
**Step 2: Build to verify**
|
||||
|
||||
Run: `cd frontend && npm run build`
|
||||
Expected: Build succeeds.
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/components/tree-editor/NodeFormDecision.tsx
|
||||
git commit -m "feat: add cross-reference node picker to decision option rows"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: AI System Prompt — Update Structural Rules for Cross-References
|
||||
|
||||
Update the AI chat system prompt to allow and encourage loop-back patterns.
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/core/ai_chat_service.py:68-75`
|
||||
|
||||
**Step 1: Update STRUCTURAL RULES in SCHEMA_CONTEXT**
|
||||
|
||||
Replace the STRUCTURAL RULES section (lines 68-75 of `ai_chat_service.py`):
|
||||
|
||||
Old:
|
||||
```python
|
||||
STRUCTURAL RULES:
|
||||
- Root node MUST be type "decision"
|
||||
- Decision nodes contain their children in the "children" array
|
||||
- Each decision option's next_node_id must reference a child node's id
|
||||
- Action nodes use next_node_id to chain to the next step (NOT children)
|
||||
- Solution nodes are terminal — no next_node_id or children
|
||||
- All IDs must be unique strings (use descriptive slugs like "check-service-status")
|
||||
```
|
||||
|
||||
New:
|
||||
```python
|
||||
STRUCTURAL RULES:
|
||||
- Root node MUST be type "decision"
|
||||
- Decision nodes contain their children in the "children" array
|
||||
- Each decision option's next_node_id typically references a child node's id, BUT can also reference ANY other node in the tree for loop-back / re-verification patterns
|
||||
- Action nodes use next_node_id to chain to the next step — this can point to any node in the tree, including ancestors, for loop-backs (e.g., "remediate → re-verify from earlier checkpoint")
|
||||
- Solution nodes are terminal — no next_node_id or children
|
||||
- All IDs must be unique strings (use descriptive slugs like "check-service-status")
|
||||
|
||||
CROSS-REFERENCE / LOOP-BACK PATTERN:
|
||||
When a troubleshooting path needs to loop back (e.g., after remediation, re-verify from an earlier checkpoint), set next_node_id to the target node's ID. Example: an action node "restart-ssh-service" can set next_node_id to "verify-ssh-connection" (an ancestor decision node) to create a re-verification loop.
|
||||
```
|
||||
|
||||
**Step 2: Build backend to verify syntax**
|
||||
|
||||
Run: `cd backend && python -c "from app.core.ai_chat_service import SCHEMA_CONTEXT; print('OK')"`
|
||||
Expected: Prints "OK" with no import errors.
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/core/ai_chat_service.py
|
||||
git commit -m "feat: update AI system prompt to allow cross-reference loop-back patterns"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Backend — Update Option Validation Error for `all_referenced_ids`
|
||||
|
||||
The `all_referenced_ids` set currently holds only option `next_node_id` values. After Task 1's change, the global existence check also needs to handle the case where `action_next_ids` and `all_referenced_ids` may overlap.
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/core/ai_tree_validator.py`
|
||||
- Test: `backend/tests/test_ai_tree_validator.py`
|
||||
|
||||
**Step 1: Verify no double-counting between action and option refs**
|
||||
|
||||
Check: `action_next_ids` are added to `all_referenced_ids` on line 128. After Task 1, we added a global check for `all_referenced_ids`. This means action refs get checked twice — once in the action-specific loop (lines 141-145) and once in the new option loop. We should only check option refs in the new loop.
|
||||
|
||||
Update the global check added in Task 1 to exclude action refs:
|
||||
|
||||
```python
|
||||
# Check that all option next_node_ids exist in the tree (allows cross-references)
|
||||
for ref_id in all_referenced_ids - action_next_ids:
|
||||
if ref_id not in all_ids:
|
||||
errors.append(
|
||||
f"Option next_node_id '{ref_id}' references a node that does not exist in the tree"
|
||||
)
|
||||
```
|
||||
|
||||
**Step 2: Run all validator tests**
|
||||
|
||||
Run: `cd backend && python -m pytest tests/test_ai_tree_validator.py -v`
|
||||
Expected: ALL PASS
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/core/ai_tree_validator.py
|
||||
git commit -m "fix: prevent double-counting action refs in global option cross-reference check"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Full Integration Test
|
||||
|
||||
Run the full backend test suite and frontend build to verify nothing is broken.
|
||||
|
||||
**Files:** (none — testing only)
|
||||
|
||||
**Step 1: Run backend tests**
|
||||
|
||||
Run: `cd backend && python -m pytest --override-ini="addopts=" -v`
|
||||
Expected: ALL PASS
|
||||
|
||||
**Step 2: Run frontend build**
|
||||
|
||||
Run: `cd frontend && npm run build`
|
||||
Expected: Build succeeds with no errors.
|
||||
|
||||
**Step 3: Manual smoke test**
|
||||
|
||||
1. Start backend: `cd backend && uvicorn app.main:app --reload`
|
||||
2. Start frontend: `cd frontend && npm run dev`
|
||||
3. Open tree editor with an existing tree
|
||||
4. Edit an action node → verify "Next Step" dropdown appears with all nodes listed
|
||||
5. Select a node from a different branch → verify dashed purple arrow appears on canvas
|
||||
6. Edit a decision node → expand "Link an option to an existing node" → create a cross-reference
|
||||
7. Verify circular reference warning (not error) appears in validation panel
|
||||
8. Navigate the tree → verify loop-back works (session follows `next_node_id`)
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Task | What | Files |
|
||||
|------|------|-------|
|
||||
| 1 | Backend: relax option validation | `ai_tree_validator.py`, `test_ai_tree_validator.py` |
|
||||
| 2 | Frontend: circular ref → warning | `treeEditorStore.ts` |
|
||||
| 3 | Canvas: dashed purple cross-ref edges | `useTreeLayout.ts` |
|
||||
| 4 | Editor: action node picker | `NodeFormAction.tsx`, `treeEditorStore.ts` |
|
||||
| 5 | Editor: decision option picker | `NodeFormDecision.tsx` |
|
||||
| 6 | AI prompt: loop-back awareness | `ai_chat_service.py` |
|
||||
| 7 | Backend: fix ref overlap check | `ai_tree_validator.py` |
|
||||
| 8 | Integration test | (testing only) |
|
||||
368
docs/plans/archive/2026-03-03-aesthetic-redesign-design.md
Normal file
368
docs/plans/archive/2026-03-03-aesthetic-redesign-design.md
Normal file
@@ -0,0 +1,368 @@
|
||||
# ResolutionFlow Aesthetic Redesign — Design Document
|
||||
|
||||
> **Date:** March 3, 2026
|
||||
> **Status:** Approved
|
||||
> **Reference Mockup:** `/tmp/mockup-j-slate-ice-modern.html`
|
||||
|
||||
---
|
||||
|
||||
## Problem Statement
|
||||
|
||||
The current purple gradient theme (`#818cf8` → `#a78bfa`) feels generic and AI-generated. It doesn't convey the professional credibility MSP engineers expect from their daily tooling. The redesign aims for a **sharp, modern** aesthetic that stands out while remaining easy on the eyes during long troubleshooting sessions.
|
||||
|
||||
## Design Direction: Slate & Ice Modern
|
||||
|
||||
Dark glassmorphism with an ice-cyan accent. Cool charcoal backgrounds, frosted-glass cards with backdrop blur, orchestrated page-load animations, and bold display typography.
|
||||
|
||||
---
|
||||
|
||||
## Color Palette
|
||||
|
||||
### Core Colors
|
||||
|
||||
| Token | Hex | Usage |
|
||||
|-------|-----|-------|
|
||||
| `--background` | `#101114` | Page background, body |
|
||||
| `--surface` | `#14161a` | Sidebar/topbar base behind blur |
|
||||
| `--card` / glass-bg | `rgba(24, 26, 31, 0.55)` | Card backgrounds (semi-transparent) |
|
||||
| `--card-hover` | `rgba(24, 26, 31, 0.7)` | Card hover state |
|
||||
| `--foreground` | `#f8fafc` | Primary text |
|
||||
| `--muted-foreground` | `#8891a0` | Secondary text, nav labels |
|
||||
| `--muted-dim` | `#5a6170` | Section labels, timestamps |
|
||||
| `--border` | `rgba(255, 255, 255, 0.06)` | Default borders |
|
||||
| `--border-hover` | `rgba(255, 255, 255, 0.12)` | Hover/active borders |
|
||||
|
||||
### Accent Colors
|
||||
|
||||
| Token | Hex | Usage |
|
||||
|-------|-----|-------|
|
||||
| `--primary` | `#06b6d4` | Accent gradient start, active indicators |
|
||||
| `--primary-light` | `#22d3ee` | Accent gradient end, highlights |
|
||||
| `--gradient-brand` | `linear-gradient(135deg, #06b6d4, #22d3ee)` | Primary buttons, avatar, active accent bar, logo "Flow" text |
|
||||
|
||||
### Functional Colors (unchanged semantics)
|
||||
|
||||
| Token | Hex | Usage |
|
||||
|-------|-----|-------|
|
||||
| `--success` | `#34d399` / emerald-400 | Completed, positive |
|
||||
| `--warning` | `#fbbf24` / amber-400 | In-progress, caution |
|
||||
| `--error` | `#f43f5e` / rose-500 | Error, critical, notification dots |
|
||||
| `--info` | `#60a5fa` / blue-400 | Informational |
|
||||
|
||||
### Accessibility Notes
|
||||
|
||||
- Cyan accent is safe for deuteranopia, protanopia, and tritanopia
|
||||
- Always pair status colors with icons (not color alone)
|
||||
- Use shape differentiation (filled vs outline icons) alongside color for colorblind users
|
||||
- `#f8fafc` on `#101114` background exceeds WCAG AAA contrast ratio
|
||||
|
||||
---
|
||||
|
||||
## Typography
|
||||
|
||||
### Font Stack
|
||||
|
||||
| Role | Font | Weights | Google Fonts |
|
||||
|------|------|---------|-------------|
|
||||
| `font-heading` | **Bricolage Grotesque** | 400, 600, 700, 800 | Yes |
|
||||
| `font-body` (default) | **IBM Plex Sans** | 400, 500, 600 | Yes |
|
||||
| `font-label` | **JetBrains Mono** | 400, 500 | Yes |
|
||||
|
||||
### Hierarchy
|
||||
|
||||
| Element | Font | Size | Weight | Color |
|
||||
|---------|------|------|--------|-------|
|
||||
| Page greeting / hero | Bricolage Grotesque | 36px | 800 | `--foreground` |
|
||||
| Stat values | Bricolage Grotesque | 30px | 800 | cyan gradient text |
|
||||
| Card titles | Bricolage Grotesque | 16px | 700 | `--foreground` |
|
||||
| Body text | IBM Plex Sans | 14px | 400-500 | `--foreground` |
|
||||
| Nav items | IBM Plex Sans | 14px | 500 | `--muted-foreground` → `--foreground` on hover/active |
|
||||
| Section labels | JetBrains Mono | 10px | 500 | `--muted-dim`, uppercase, `letter-spacing: 0.1em` |
|
||||
| Timestamps / metadata | JetBrains Mono | 11-12px | 400 | `--muted-foreground` |
|
||||
| Stat labels | IBM Plex Sans | 13px | 500 | `--muted-foreground` |
|
||||
|
||||
---
|
||||
|
||||
## Glassmorphism System
|
||||
|
||||
### Card Variants
|
||||
|
||||
**Interactive glass card** (`.glass-card`):
|
||||
```css
|
||||
background: rgba(24, 26, 31, 0.55);
|
||||
backdrop-filter: blur(16px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
/* Hover */
|
||||
transform: scale(1.02);
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.08);
|
||||
```
|
||||
|
||||
**Static glass card** (`.glass-card-static`): Same as above without hover transform.
|
||||
|
||||
### Shell Blur Levels
|
||||
|
||||
| Element | Blur | Background |
|
||||
|---------|------|-----------|
|
||||
| Sidebar | `blur(12px)` | `rgba(16, 17, 20, 0.5)` |
|
||||
| Topbar | `blur(20px)` | `rgba(16, 17, 20, 0.6)` |
|
||||
| Cards | `blur(16px)` | `rgba(24, 26, 31, 0.55)` |
|
||||
|
||||
### Ambient Atmosphere
|
||||
|
||||
Two fixed `pointer-events: none` gradient orbs behind the app shell:
|
||||
- **Cyan orb**: top-right, 600x600px, `rgba(6, 182, 212, 0.15)`, blur(60px)
|
||||
- **Purple orb**: bottom-left, 500x500px, `rgba(99, 102, 241, 0.08)`, blur(50px)
|
||||
|
||||
---
|
||||
|
||||
## Component Specifications
|
||||
|
||||
### Primary Button
|
||||
|
||||
```css
|
||||
background: linear-gradient(135deg, #06b6d4, #22d3ee);
|
||||
color: #101114;
|
||||
font-weight: 600;
|
||||
border-radius: 10px;
|
||||
padding: 10px 20px;
|
||||
/* Hover: opacity 0.9; Active: scale(0.97) */
|
||||
```
|
||||
|
||||
### Secondary Button
|
||||
|
||||
```css
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
color: #f8fafc;
|
||||
border-radius: 10px;
|
||||
/* Hover: border brightens to rgba(255, 255, 255, 0.12) */
|
||||
```
|
||||
|
||||
### Search Bar
|
||||
|
||||
- `width: 320px`, expands to `400px` on focus
|
||||
- Background: `rgba(255, 255, 255, 0.04)`, focus: `rgba(255, 255, 255, 0.06)`
|
||||
- Focus border: `rgba(6, 182, 212, 0.3)` — cyan tint
|
||||
- Rounded: `border-radius: 12px`
|
||||
|
||||
### Active Nav Item
|
||||
|
||||
- Background: `rgba(6, 182, 212, 0.1)` with scaleX reveal animation
|
||||
- Left accent bar: 3px wide, cyan gradient, `border-radius: 0 3px 3px 0`
|
||||
- Text: `--foreground` (white)
|
||||
|
||||
### Avatar
|
||||
|
||||
- 34x34px, `border-radius: 10px` (rounded square)
|
||||
- Cyan gradient background, dark text
|
||||
- Hover: `scale(1.08)`
|
||||
|
||||
### Notification Dot
|
||||
|
||||
- 8px circle, `#f43f5e` (rose), 2px solid `#101114` border
|
||||
|
||||
### Scrollbar
|
||||
|
||||
- 6px wide, transparent track
|
||||
- Thumb: `rgba(255,255,255,0.08)`, hover: `rgba(255,255,255,0.12)`
|
||||
|
||||
---
|
||||
|
||||
## Animations
|
||||
|
||||
### Page Load Sequence (orchestrated)
|
||||
|
||||
| Element | Animation | Delay | Duration |
|
||||
|---------|-----------|-------|----------|
|
||||
| Topbar | slideDown (Y: -100% → 0) | 200ms | 400ms |
|
||||
| Sidebar | slideInLeft (X: -100% → 0) | 250ms | 400ms |
|
||||
| Greeting | fadeInUp (Y: 20px → 0) | 400ms | 400ms |
|
||||
| Stat cards | fadeInUp cascade | 500ms, 570ms, 640ms, 710ms | 350ms each |
|
||||
| Activity items | fadeInUp stagger | 750ms + 40ms each | 300ms each |
|
||||
| Quick actions | fadeInRight (X: 30px → 0) | 800ms | 400ms |
|
||||
|
||||
### Micro-interactions
|
||||
|
||||
| Element | Effect |
|
||||
|---------|--------|
|
||||
| Glass cards | `scale(1.02)` + border/shadow upgrade on hover |
|
||||
| Buttons | `scale(0.97)` on `:active` |
|
||||
| Notification bell | wobble keyframe (rotate ±8° → 0) on hover |
|
||||
| First stat card | `breatheGlow` — pulsing cyan shadow, 3s infinite |
|
||||
| Nav items | Background scaleX reveal from left on hover |
|
||||
| Search bar | Width expansion 320→400px on focus |
|
||||
| Avatar | `scale(1.08)` on hover |
|
||||
|
||||
### Easing
|
||||
|
||||
- Primary: `cubic-bezier(0.4, 0, 0.2, 1)` — smooth deceleration
|
||||
- Bounce (optional): `cubic-bezier(0.34, 1.56, 0.64, 1)` — slight overshoot
|
||||
|
||||
---
|
||||
|
||||
## Dashboard Layout
|
||||
|
||||
### Grid Structure
|
||||
|
||||
```
|
||||
Row 1: Greeting + date (full width)
|
||||
Row 2: Weekly Calendar (flex-grow) + Quick Actions (fixed width) — equal height
|
||||
Row 3: My Open Sessions (flex-grow) + Stats 2x2 grid (fixed width) — equal height
|
||||
Row 4: Recent Activity (full width)
|
||||
```
|
||||
|
||||
### Weekly Calendar Panel
|
||||
|
||||
- 5 tall day columns (Mon–Fri), equal width
|
||||
- Today column: highlighted with cyan gradient top bar
|
||||
- Events appear inline within day columns with colored left border (4px)
|
||||
- Cyan left-border: default events
|
||||
- Amber left-border: maintenance events
|
||||
- Empty days show "No events" in muted text
|
||||
- Calendar and quick actions stretch to match height (`align-items: stretch`)
|
||||
- Future: Outlook/Gmail/PSA calendar sync integration
|
||||
|
||||
### Quick Actions Panel
|
||||
|
||||
4 glass cards in a vertical stack:
|
||||
1. New Flow (+ icon, cyan accent)
|
||||
2. Resume Session (play icon, emerald accent)
|
||||
3. Browse Library (book icon, amber accent)
|
||||
4. Invite Team (user-plus icon, purple accent)
|
||||
|
||||
### My Open Sessions Panel
|
||||
|
||||
- Shows 3 oldest open sessions
|
||||
- Each row: colored dot + flow name + "Step X of Y" + time ago + Resume button
|
||||
- Resume button: small cyan gradient pill
|
||||
|
||||
### Stats Panel (2x2 Grid)
|
||||
|
||||
4 stat cards:
|
||||
1. Active Flows — with `breatheGlow` animation
|
||||
2. This Week (sessions)
|
||||
3. Avg Resolution (time)
|
||||
4. Team Members
|
||||
|
||||
Each: stat value (30px Bricolage Grotesque, cyan gradient text) + label + trend indicator
|
||||
|
||||
### Recent Activity Panel
|
||||
|
||||
- Full width, 5 activity items
|
||||
- Each: icon (colored background circle) + description + JetBrains Mono timestamp
|
||||
- Staggered fadeInUp animation on page load
|
||||
|
||||
---
|
||||
|
||||
## Sidebar Structure
|
||||
|
||||
1. **Logo bar** (56px height, matches topbar): Decision-tree icon SVG + "Resolution" white + "Flow" cyan gradient
|
||||
2. **Pinned Flows**: 3 pinned items with cyan pin icons
|
||||
3. **Divider**
|
||||
4. **Navigation**:
|
||||
- Dashboard (active)
|
||||
- All Flows → Troubleshooting / Projects / Maintenance (sub-items)
|
||||
- Step Library
|
||||
- Sessions
|
||||
- Exports
|
||||
5. **Divider**
|
||||
6. **Footer** (pushed to bottom): User avatar + name + role badge
|
||||
|
||||
No categories section. No workspace switcher.
|
||||
|
||||
---
|
||||
|
||||
## Topbar Structure
|
||||
|
||||
- Left: Search bar with search icon + "Search flows..." placeholder + keyboard shortcut badge
|
||||
- Right: Help icon + Notification bell (with dot) + User avatar (rounded square, cyan gradient)
|
||||
- Subtle cyan gradient underline glow at center-bottom
|
||||
|
||||
---
|
||||
|
||||
## Logo
|
||||
|
||||
The existing decision-tree SVG icon is retained but recolored with the cyan gradient (`#06b6d4` → `#22d3ee`). Nodes have decreasing opacity down the tree (0.9 → 0.7 → 0.5). Connector lines use the gradient stroke at 0.4–0.5 opacity.
|
||||
|
||||
Wordmark: "Resolution" in `#f8fafc` + "Flow" with `background: linear-gradient(135deg, #06b6d4, #22d3ee)` + `-webkit-background-clip: text`.
|
||||
|
||||
---
|
||||
|
||||
## Shadow System
|
||||
|
||||
| Token | Value | Usage |
|
||||
|-------|-------|-------|
|
||||
| `--shadow-float` | `0 8px 32px rgba(0,0,0,0.3)` | Default card shadow |
|
||||
| `--shadow-float-hover` | `0 12px 40px rgba(0,0,0,0.4), 0 0 0 1px rgba(255,255,255,0.08)` | Hovered card |
|
||||
| `--shadow-cyan-glow` | `0 8px 32px rgba(6,182,212,0.08)` | Cyan-tinted glow |
|
||||
|
||||
---
|
||||
|
||||
## Migration Notes
|
||||
|
||||
### What Changes
|
||||
|
||||
| Current | New |
|
||||
|---------|-----|
|
||||
| Purple gradient (`#818cf8` → `#a78bfa`) | Ice cyan gradient (`#06b6d4` → `#22d3ee`) |
|
||||
| Plus Jakarta Sans (headings) | Bricolage Grotesque (headings) |
|
||||
| Inter (body) | IBM Plex Sans (body) |
|
||||
| Outfit (labels) | JetBrains Mono (labels) |
|
||||
| Flat `bg-card` cards | Glassmorphism with `backdrop-filter: blur()` |
|
||||
| No page-load animations | Orchestrated entrance sequence |
|
||||
| No hover scaling on cards | `scale(1.02)` hover lift |
|
||||
| `bg-gradient-brand` = purple | `bg-gradient-brand` = cyan |
|
||||
| `text-gradient-brand` = purple | `text-gradient-brand` = cyan |
|
||||
|
||||
### What Stays the Same
|
||||
|
||||
- CSS Grid app shell layout (sidebar + topbar + main)
|
||||
- Dark-first theme (dark only, no light mode)
|
||||
- Lucide React icons
|
||||
- Zustand state management
|
||||
- Component architecture and routing
|
||||
- Functional color semantics (green=success, amber=warning, red=error)
|
||||
- "Flows" terminology, "ResolutionFlow" branding
|
||||
- BrandLogo.tsx component structure (just recolor the SVG + gradient)
|
||||
|
||||
### New Dashboard Panels (Feature Work)
|
||||
|
||||
- **Weekly Calendar**: New component, requires date logic, event display, future calendar sync API
|
||||
- **My Open Sessions**: Queries 3 oldest open sessions (existing API with sort + limit)
|
||||
- Stat cards and Recent Activity already exist — layout rearrangement only
|
||||
|
||||
---
|
||||
|
||||
## Implementation Scope
|
||||
|
||||
### Phase 1: Design System Foundation
|
||||
- Update CSS variables in `index.css`
|
||||
- Update `tailwind.config.js` (colors, fonts, gradients)
|
||||
- Add Google Fonts imports (Bricolage Grotesque, IBM Plex Sans, JetBrains Mono)
|
||||
- Create glassmorphism utility classes
|
||||
- Create animation keyframes and stagger classes
|
||||
- Update `BrandLogo.tsx` SVG colors
|
||||
|
||||
### Phase 2: Shell & Navigation
|
||||
- Update sidebar glassmorphism + nav item styles
|
||||
- Update topbar glassmorphism + search bar
|
||||
- Update active nav indicator (purple → cyan accent bar)
|
||||
|
||||
### Phase 3: Component Updates
|
||||
- Update button variants (primary gradient, secondary)
|
||||
- Update card components to glass-card pattern
|
||||
- Update stat cards, activity items, badges
|
||||
- Update form inputs (focus states)
|
||||
|
||||
### Phase 4: Dashboard Redesign
|
||||
- Rearrange dashboard layout (greeting → calendar+actions → sessions+stats → activity)
|
||||
- Build Weekly Calendar component
|
||||
- Build My Open Sessions panel
|
||||
- Add orchestrated page-load animations
|
||||
|
||||
### Phase 5: Page-by-Page Sweep
|
||||
- Update all remaining pages to use new design tokens
|
||||
- Ensure consistency across tree editor, session pages, admin pages, etc.
|
||||
1400
docs/plans/archive/2026-03-03-aesthetic-redesign-impl.md
Normal file
1400
docs/plans/archive/2026-03-03-aesthetic-redesign-impl.md
Normal file
File diff suppressed because it is too large
Load Diff
290
docs/plans/archive/Frontend/COMPONENT_EXAMPLES.md
Normal file
290
docs/plans/archive/Frontend/COMPONENT_EXAMPLES.md
Normal file
@@ -0,0 +1,290 @@
|
||||
# Component Migration Examples
|
||||
|
||||
## Common Component Transformations
|
||||
|
||||
### 1. Card Component
|
||||
|
||||
**BEFORE (Old Design):**
|
||||
```tsx
|
||||
<div className="bg-slate-900 border border-slate-800 rounded-lg p-6">
|
||||
<h3 className="text-slate-200">Title</h3>
|
||||
<p className="text-slate-400">Content</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
**AFTER (New Design):**
|
||||
```tsx
|
||||
<div className="bg-gradient-to-br from-white/[0.04] to-white/[0.01] border border-white/8 backdrop-blur-xl rounded-2xl p-6 hover:from-white/[0.06] hover:to-white/[0.02] hover:border-white/12 transition-all">
|
||||
<h3 className="text-white font-bold">Title</h3>
|
||||
<p className="text-white/40">Content</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Active/Highlighted Card
|
||||
|
||||
**BEFORE:**
|
||||
```tsx
|
||||
<div className="bg-purple-900/20 border border-purple-700 rounded-lg p-8">
|
||||
{/* content */}
|
||||
</div>
|
||||
```
|
||||
|
||||
**AFTER (Bright Glow):**
|
||||
```tsx
|
||||
<div className="bg-gradient-to-br from-white/[0.08] to-white/[0.04] border border-white/20 rounded-2xl p-8 shadow-[0_0_40px_rgba(255,255,255,0.1)]">
|
||||
{/* content */}
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Icon in Card
|
||||
|
||||
**BEFORE:**
|
||||
```tsx
|
||||
<div className="w-10 h-10 rounded-lg bg-purple-600 flex items-center justify-center">
|
||||
<Icon className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
```
|
||||
|
||||
**AFTER (with subtle color):**
|
||||
```tsx
|
||||
<div className="w-12 h-12 rounded-xl bg-white/5 border border-white/10 flex items-center justify-center">
|
||||
<Icon className="w-6 h-6 text-blue-400" />
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. Badge/Status
|
||||
|
||||
**BEFORE:**
|
||||
```tsx
|
||||
<span className="px-3 py-1 bg-purple-900 text-purple-200 rounded-md text-sm">
|
||||
Admin
|
||||
</span>
|
||||
```
|
||||
|
||||
**AFTER:**
|
||||
```tsx
|
||||
<div className="px-4 py-2 rounded-xl bg-white/10 border border-white/20">
|
||||
<span className="text-sm text-white font-semibold">Admin</span>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. Primary Button
|
||||
|
||||
**BEFORE:**
|
||||
```tsx
|
||||
<button className="px-6 py-3 bg-purple-600 hover:bg-purple-700 text-white rounded-lg">
|
||||
Click Me
|
||||
</button>
|
||||
```
|
||||
|
||||
**AFTER:**
|
||||
```tsx
|
||||
<button className="px-6 py-3 bg-white text-black font-semibold rounded-xl hover:bg-white/90 transition-all hover:scale-105">
|
||||
Click Me
|
||||
</button>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. Secondary Button
|
||||
|
||||
**BEFORE:**
|
||||
```tsx
|
||||
<button className="px-6 py-3 border border-slate-700 text-slate-300 hover:bg-slate-800 rounded-lg">
|
||||
Cancel
|
||||
</button>
|
||||
```
|
||||
|
||||
**AFTER:**
|
||||
```tsx
|
||||
<button className="px-6 py-3 bg-white/10 border border-white/20 text-white font-medium rounded-xl hover:bg-white/20 transition-all">
|
||||
Cancel
|
||||
</button>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7. Input/Search
|
||||
|
||||
**BEFORE:**
|
||||
```tsx
|
||||
<input
|
||||
className="bg-slate-800 border border-slate-700 text-white placeholder-slate-400 rounded-lg px-4 py-3"
|
||||
placeholder="Search..."
|
||||
/>
|
||||
```
|
||||
|
||||
**AFTER:**
|
||||
```tsx
|
||||
<div className="bg-gradient-to-br from-white/[0.04] to-white/[0.01] border border-white/8 backdrop-blur-xl rounded-2xl p-1">
|
||||
<div className="flex items-center bg-black/50 rounded-xl">
|
||||
<svg className="ml-5 w-5 h-5 text-blue-400">{/* search icon */}</svg>
|
||||
<input
|
||||
className="flex-1 bg-transparent py-4 px-4 text-white placeholder:text-white/30 focus:outline-none"
|
||||
placeholder="Search..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 8. Progress Bar
|
||||
|
||||
**BEFORE:**
|
||||
```tsx
|
||||
<div className="h-2 bg-slate-800 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-purple-600 rounded-full" style={{width: '60%'}}></div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**AFTER:**
|
||||
```tsx
|
||||
<div className="h-2 bg-white/10 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-white rounded-full" style={{width: '60%'}}></div>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 9. Stat Card with Trend
|
||||
|
||||
**BEFORE:**
|
||||
```tsx
|
||||
<div className="bg-slate-900 border border-slate-800 rounded-lg p-6">
|
||||
<div className="text-slate-400 text-sm">Active Users</div>
|
||||
<div className="text-2xl font-bold text-white">1,234</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**AFTER:**
|
||||
```tsx
|
||||
<div className="bg-[rgba(20,20,25,0.5)] border border-white/[0.06] backdrop-blur-xl rounded-2xl p-6 hover:scale-105 transition-transform">
|
||||
<div className="text-sm text-white/40 mb-2 font-medium">Active Users</div>
|
||||
<div className="text-4xl font-bold text-white mb-1">1,234</div>
|
||||
<div className="flex items-center gap-1 text-xs text-emerald-400 font-medium">
|
||||
<svg className="w-3 h-3">{/* up arrow */}</svg>
|
||||
12% vs last week
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 10. Section Header
|
||||
|
||||
**BEFORE:**
|
||||
```tsx
|
||||
<div className="mb-8">
|
||||
<h2 className="text-2xl font-bold text-slate-100">Recent Trees</h2>
|
||||
<p className="text-slate-400 mt-1">Your recently accessed decision trees</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
**AFTER:**
|
||||
```tsx
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-2xl font-bold text-white">Recent Trees</h2>
|
||||
<button className="text-sm text-white/60 hover:text-white font-medium transition-colors">
|
||||
View all →
|
||||
</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Icon Color Guidelines
|
||||
|
||||
### AI/Automation Icons
|
||||
```tsx
|
||||
<svg className="w-5 h-5 text-cyan-400">{/* sparkle/star */}</svg>
|
||||
```
|
||||
|
||||
### Search Icons
|
||||
```tsx
|
||||
<svg className="w-5 h-5 text-blue-400">{/* magnifying glass */}</svg>
|
||||
```
|
||||
|
||||
### Active/Playing State
|
||||
```tsx
|
||||
<svg className="w-6 h-6 text-violet-400">{/* play button */}</svg>
|
||||
```
|
||||
|
||||
### Network Category
|
||||
```tsx
|
||||
<svg className="w-6 h-6 text-blue-400">{/* wifi/network */}</svg>
|
||||
```
|
||||
|
||||
### Printer Category
|
||||
```tsx
|
||||
<svg className="w-6 h-6 text-indigo-400">{/* printer */}</svg>
|
||||
```
|
||||
|
||||
### Email Category
|
||||
```tsx
|
||||
<svg className="w-6 h-6 text-cyan-400">{/* envelope */}</svg>
|
||||
```
|
||||
|
||||
### Success Indicators
|
||||
```tsx
|
||||
<svg className="w-4 h-4 text-emerald-400">{/* check/arrow up */}</svg>
|
||||
```
|
||||
|
||||
### Error Indicators
|
||||
```tsx
|
||||
<svg className="w-4 h-4 text-red-400">{/* x/arrow down */}</svg>
|
||||
```
|
||||
|
||||
### Time/Clock (NO COLOR - keep gray)
|
||||
```tsx
|
||||
<svg className="w-4 h-4 text-white/30">{/* clock */}</svg>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Mistakes to Avoid
|
||||
|
||||
❌ **DON'T:**
|
||||
- Use colored backgrounds on cards
|
||||
- Use gradient text
|
||||
- Color ALL icons
|
||||
- Use slate-900, slate-800 (use white/opacity instead)
|
||||
- Use purple gradients anywhere
|
||||
|
||||
✅ **DO:**
|
||||
- Use white/black with opacity for all backgrounds
|
||||
- Keep text white (with varying opacity)
|
||||
- Only color functional icons
|
||||
- Use backdrop-blur on cards
|
||||
- Use white for primary buttons
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference: Opacity Levels
|
||||
|
||||
**Text:**
|
||||
- Primary: `text-white`
|
||||
- Secondary: `text-white/70`
|
||||
- Tertiary: `text-white/40`
|
||||
- Placeholder: `text-white/30`
|
||||
|
||||
**Backgrounds:**
|
||||
- Card: `bg-white/[0.04]` to `bg-white/[0.01]`
|
||||
- Card hover: `bg-white/[0.06]` to `bg-white/[0.02]`
|
||||
- Active card: `bg-white/[0.08]` to `bg-white/[0.04]`
|
||||
- Button secondary: `bg-white/10`
|
||||
- Badge: `bg-white/10`
|
||||
|
||||
**Borders:**
|
||||
- Subtle: `border-white/8`
|
||||
- Normal: `border-white/10`
|
||||
- Prominent: `border-white/20`
|
||||
- Active: `border-white/30`
|
||||
53
docs/plans/archive/Frontend/ux_improvements.md
Normal file
53
docs/plans/archive/Frontend/ux_improvements.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# Frontend UX Improvements
|
||||
|
||||
Based on a review of the current codebase (specifically `TreeNavigationPage.tsx`, `MyTreesPage.tsx`, and `AppLayout.tsx`), here are 5 suggested UX improvements to enhance usability and engineer workflow.
|
||||
|
||||
## 1. Interactive Breadcrumbs for Non-Linear Navigation
|
||||
|
||||
**Problem:**
|
||||
Currently, the breadcrumbs in `TreeNavigationPage.tsx` are static text (`<span>`). Users can only go back one step at a time using the "Back" button or browser history, which is tedious if they realize a mistake 3-4 steps ago.
|
||||
|
||||
**Solution:**
|
||||
Make breadcrumb items clickable links.
|
||||
- **Action:** Convert breadcrumb items to clickable elements that navigate to that specific historical state.
|
||||
- **Benefit:** Allows engineers to quickly jump back to a specific decision point without multiple clicks, improving the "exploratory" nature of troubleshooting.
|
||||
|
||||
## 2. Enhanced Command Output Experience
|
||||
|
||||
**Problem:**
|
||||
The "Command Output" text area in `TreeNavigationPage.tsx` is a simple text box. Engineers often need to copy commands to their terminal and then copy the output back. The current "code" blocks for commands are also static.
|
||||
|
||||
**Solution:**
|
||||
- **Add "Copy" Button to Commands:** Next to every command block (`currentNode.commands`), add a small copy icon/button for one-click copying.
|
||||
- **Paste Formatting:** Ensure the command output text area handles large pastes gracefully (it currently has a 10k char limit which is good) and considers a "Copy" button for the output itself if it needs to be moved elsewhere.
|
||||
- **Benefit:** Reduces friction in the core "read command -> execute -> paste result" loop.
|
||||
|
||||
## 3. Prominent "Create Tree" Call-to-Action
|
||||
|
||||
**Problem:**
|
||||
`MyTreesPage.tsx` has a header but lacks a primary "Create Tree" button. The empty state only guides users to "Browse Trees" (implying they should fork). Experienced users who want to build from scratch have to find the option elsewhere or navigate to "Trees" -> "New" (if that path exists/is obvious).
|
||||
|
||||
**Solution:**
|
||||
- **Header Action:** Add a prominent "Create Tree" button (using the `Plus` icon) to the header of `MyTreesPage.tsx`.
|
||||
- **Empty State:** Update the empty state to offer both "Browse Library" AND "Create from Scratch" options.
|
||||
- **Benefit:** Encourages content creation and reduces friction for power users.
|
||||
|
||||
## 4. Keyboard Shortcuts Discovery
|
||||
|
||||
**Problem:**
|
||||
The app supports keyboard shortcuts (1-9 for options, Esc for back, etc.), but they are not visible in the UI. Users may not know they exist, leading to slower interaction times.
|
||||
|
||||
**Solution:**
|
||||
- **Visual Hints:** Add small keycap hints (e.g., `[1]`, `[2]`) next to the decision options in `TreeNavigationPage.tsx`.
|
||||
- **Tooltip/Modal:** Add a subtle "Keyboard shortcuts" help icon or a `?` modal that lists available shortcuts map.
|
||||
- **Benefit:** drastically improves speed for power users (engineers love keyboard-first workflows).
|
||||
|
||||
## 5. Session Timer & Progress Visibility
|
||||
|
||||
**Problem:**
|
||||
The session timer in `TreeNavigationPage.tsx` is small and low-contrast (`text-white/40`). For a tool valuable for its time-saving metrics, this feedback is too subtle. Also, actions like "Continue" lack immediate local loading feedback, potentially causing double-clicks.
|
||||
|
||||
**Solution:**
|
||||
- **Prominent Timer:** Make the timer more visible (e.g., brighter text, perhaps a dedicated badge) to reinforce the "time-sensitive" nature of troubleshooting.
|
||||
- **Action Feedback:** Add loading spinners directly to the "Continue" and Option buttons when `isLoading` is true, rather than relying solely on global skeletons or page loaders.
|
||||
- **Benefit:** Provides better system status feedback and reinforces the value proposition of the tool.
|
||||
574
docs/plans/archive/IMPLEMENTATION-PLAN-TREE-EDITOR-CANVAS.md
Normal file
574
docs/plans/archive/IMPLEMENTATION-PLAN-TREE-EDITOR-CANVAS.md
Normal file
@@ -0,0 +1,574 @@
|
||||
# Implementation Plan: Tree Editor Canvas Redesign
|
||||
|
||||
> **Date:** February 17, 2026
|
||||
> **Scope:** Replace NodeList + NodeEditorModal + TreePreviewPanel in Flow mode with a visual canvas + inline card editing
|
||||
> **Estimated Components:** 3 new files, 4 modified files
|
||||
> **Phases:** 4 (sequential)
|
||||
> **Branch:** `feature/tree-editor-canvas`
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Replace the current text-outline + modal + passive preview layout with a single-pane visual canvas where nodes are cards, editing is inline, and branches are visually connected. The tree IS the editor — no separate preview panel needed.
|
||||
|
||||
### Current State
|
||||
|
||||
The tree editor uses a text-outline metaphor:
|
||||
- Nodes listed as indented rows with ASCII tree lines in `NodeList.tsx`
|
||||
- Editing requires opening `NodeEditorModal.tsx` for each node (click row → modal opens → edit → Done → modal closes)
|
||||
- Passive `TreePreviewPanel.tsx` takes 40% of space but offers no editing
|
||||
- Adding nodes is a two-step picker-then-modal flow
|
||||
- Options/branches are opaque — can't see where each branch leads without clicking into the node
|
||||
|
||||
### Target State (5 Outcomes)
|
||||
|
||||
1. Flow mode uses a full-width `TreeCanvas` editor; preview panel is removed from Flow mode
|
||||
2. Node editing is inline in cards with local draft + ✓ save + ✕ cancel (no modal)
|
||||
3. Branches are visually rendered with parent-child connector lines and horizontal splits
|
||||
4. Metadata moves to a right slide-in panel, collapsed by default, opened from toolbar in Flow mode only
|
||||
5. Code mode remains functionally unchanged (Monaco + preview split)
|
||||
|
||||
### Layout After Redesign
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ TOOLBAR: [Flow Name] [Undo] [Redo] [Metadata] [Save] [Publish] │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ [START] │
|
||||
│ │ │
|
||||
│ ┌─────────────────────────────┐ │
|
||||
│ │ ? What type of issue? │ ← Decision card │
|
||||
│ │ ↳ [A] Network Issues │ │
|
||||
│ │ ↳ [B] App Errors │ │
|
||||
│ └─────────────────────────────┘ │
|
||||
│ │ │ │
|
||||
│ [Network card] [App Errors card] │
|
||||
│ │ │
|
||||
│ [Solution card] │
|
||||
│ │
|
||||
│ [+ Add node here] ← contextual add buttons │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Reusable Code Inventory
|
||||
|
||||
Before building anything, confirm these existing pieces are available and understand their APIs:
|
||||
|
||||
| Asset | Location | How It's Used |
|
||||
|-------|----------|---------------|
|
||||
| `useTreeEditorStore()` | `store/treeEditorStore.ts` | All CRUD: `addNode`, `updateNode`, `deleteNode`, `duplicateNode`, `reorderNodes`, `selectNode`, `findNode`, `validationErrors` |
|
||||
| `NodeFormDecision` | `components/tree-editor/NodeFormDecision.tsx` | Decision node fields (question, help_text, options) — will be rendered inline in card |
|
||||
| `NodeFormAction` | `components/tree-editor/NodeFormAction.tsx` | Action node fields — will be rendered inline in card |
|
||||
| `NodeFormResolution` | `components/tree-editor/NodeFormResolution.tsx` | Solution node fields — will be rendered inline in card |
|
||||
| `DynamicArrayField` | `components/tree-editor/DynamicArrayField.tsx` | Reuse for options array in inline card |
|
||||
| `NodePicker` | `components/tree-editor/NodePicker.tsx` | Reuse for option target selection (needs `allowCreate` prop added) |
|
||||
| `TreeMetadataForm` | `components/tree-editor/TreeMetadataForm.tsx` | Wraps into MetadataSidePanel as-is |
|
||||
| `cn()` | `@/lib/utils` | Tailwind class merging utility |
|
||||
| Design tokens | `tailwind.config.js` | `bg-card`, `border-border`, `text-foreground`, `font-heading`, `font-label` |
|
||||
| Brand colors | `tailwind.config.js` | Blue (decision), Yellow (action), Green (solution) |
|
||||
| `buildSharedLinksMap()` | `components/tree-preview/TreePreviewPanel.tsx` | Shared node detection logic — extract and reuse for jump/reference indicators |
|
||||
|
||||
---
|
||||
|
||||
## Link Model & Rendering Rules
|
||||
|
||||
**IMPORTANT:** These rules govern how the canvas renders node connections. Read before implementing Phase 2.
|
||||
|
||||
### Tree-First Rendering
|
||||
|
||||
Render only structural children (nodes in `node.children[]`) with visual connector lines. Do NOT draw cross-canvas connector lines for shared/cross-linked nodes.
|
||||
|
||||
For links to non-child/shared targets (where `option.next_node_id` points to a node that is NOT a direct child), show a compact "jump/reference" indicator badge in the card content instead of a connector line.
|
||||
|
||||
### Decision Child Lane Ordering
|
||||
|
||||
When a decision node has children, order the horizontal child lanes:
|
||||
1. **First:** Children whose `id` matches an `option.next_node_id` — ordered by option order
|
||||
2. **Then:** Append any remaining unlinked children
|
||||
|
||||
### Action Next-Child Rule
|
||||
|
||||
- If a child's `id` matches the action node's `next_node_id`, treat it as the primary "next" lane
|
||||
- If no child matches `next_node_id`, keep child visible but show the link as an "unbound reference" indicator
|
||||
|
||||
### Deletion Safety
|
||||
|
||||
**Before calling `deleteNode(nodeId)`**, the canvas must clean up all inbound references:
|
||||
- Scan all decision nodes: clear any `options[].next_node_id` that equals the deleted node's ID
|
||||
- Scan all action nodes: clear any `next_node_id` that equals the deleted node's ID
|
||||
|
||||
This prevents stale link references. The current `treeEditorStore.deleteNode()` does NOT do this cleanup — the canvas orchestration layer handles it.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: TreeCanvasNode Component (Core Inline Editor Card)
|
||||
|
||||
**File:** `frontend/src/components/tree-editor/TreeCanvasNode.tsx` (NEW)
|
||||
|
||||
### Props Interface
|
||||
|
||||
```typescript
|
||||
interface TreeCanvasNodeProps {
|
||||
node: TreeStructure
|
||||
depth: number
|
||||
fromOption?: string // Which parent option label leads here
|
||||
isExpanded: boolean
|
||||
isNew: boolean // Show "Unsaved" badge, cancel triggers delete
|
||||
onToggleExpand: () => void
|
||||
onSave: (nodeId: string, updates: Partial<TreeStructure>) => void
|
||||
onCancelNew: (nodeId: string) => void // Delete unsaved node
|
||||
onDelete: (nodeId: string) => void
|
||||
onDuplicate: (nodeId: string) => void
|
||||
onDragStart: (e: React.DragEvent, nodeId: string) => void
|
||||
onDragOver: (e: React.DragEvent) => void
|
||||
onDrop: (e: React.DragEvent) => void
|
||||
}
|
||||
```
|
||||
|
||||
### Card States
|
||||
|
||||
**Compact (default):**
|
||||
- Node type badge/icon (Decision ?, Action ⚡, Solution ✓)
|
||||
- Title text (question for decisions, title for action/solution)
|
||||
- Option labels or option count for decisions
|
||||
- Validation error badge (red dot if errors on this node)
|
||||
- "Unsaved" badge (yellow, if `isNew`)
|
||||
- Click anywhere on card → calls `onToggleExpand`
|
||||
|
||||
**Expanded (editing):**
|
||||
- Local draft state: `const [draft, setDraft] = useState<Partial<TreeStructure>>(() => cloneNodeWithoutChildren(node))`
|
||||
- Renders the appropriate existing form subcomponent inline:
|
||||
- Decision: `<NodeFormDecision node={draft} onUpdate={setDraftField} />`
|
||||
- Action: `<NodeFormAction node={draft} onUpdate={setDraftField} />`
|
||||
- Solution: `<NodeFormResolution node={draft} onUpdate={setDraftField} />`
|
||||
- Header actions row:
|
||||
- ✓ Save button → calls `onSave(node.id, draft)` (strip children from draft before passing)
|
||||
- ✕ Cancel button → if `isNew`, calls `onCancelNew(node.id)`. Otherwise resets draft to node values and collapses
|
||||
- Duplicate button (hide if root)
|
||||
- Delete button (hide if root)
|
||||
- Drag handle in header (hide if root)
|
||||
|
||||
### Card Styling
|
||||
|
||||
Aesthetic direction: "Precision engineering tool" — clean, minimal chrome, confident typography.
|
||||
|
||||
```
|
||||
All cards: bg-card border-border rounded-xl shadow-sm
|
||||
Decision: border-l-4 border-blue-500
|
||||
Action: border-l-4 border-yellow-500
|
||||
Solution: border-l-4 border-green-500
|
||||
Expanded ring: ring-1 ring-primary
|
||||
Titles: font-heading (Plus Jakarta Sans)
|
||||
Type badges: font-label (Outfit)
|
||||
```
|
||||
|
||||
### Implementation Steps
|
||||
|
||||
1. Create `TreeCanvasNode.tsx` with compact view only (type badge, title, option count)
|
||||
2. Add expanded view with local draft state and inline form rendering
|
||||
3. Wire save/cancel/delete/duplicate actions
|
||||
4. Add drag handle events
|
||||
5. Add validation badge and unsaved badge
|
||||
6. Style with brand tokens
|
||||
|
||||
### Verification
|
||||
|
||||
- Render a single card in isolation with mock data
|
||||
- Confirm compact → expanded toggle works
|
||||
- Confirm save commits draft (log output), cancel resets
|
||||
- Confirm cancel on `isNew=true` calls `onCancelNew`
|
||||
- Run `npm run build` — no TypeScript errors
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: TreeCanvas Component (Layout & Orchestration)
|
||||
|
||||
**File:** `frontend/src/components/tree-editor/TreeCanvas.tsx` (NEW)
|
||||
|
||||
### Canvas State Model
|
||||
|
||||
```typescript
|
||||
// Local UI state (NOT in Zustand store — canvas-only concerns)
|
||||
const [expandedNodeIds, setExpandedNodeIds] = useState<Set<string>>(new Set())
|
||||
const [newNodeIds, setNewNodeIds] = useState<Set<string>>(new Set())
|
||||
const [pendingAddTarget, setPendingAddTarget] = useState<string | null>(null)
|
||||
const [pendingLinkByNodeId, setPendingLinkByNodeId] = useState<Map<string, {
|
||||
parentId: string
|
||||
optionId?: string // For decision option linking
|
||||
}>>(new Map())
|
||||
const [dragState, setDragState] = useState<{
|
||||
nodeId: string
|
||||
parentId: string
|
||||
index: number
|
||||
} | null>(null)
|
||||
```
|
||||
|
||||
**Single expanded card policy:** Only one card expanded at a time. When expanding a card, collapse the previously expanded one.
|
||||
|
||||
### Rendering
|
||||
|
||||
Recursive rendering of `treeStructure` from the store:
|
||||
- Root at top, rendered as a "START" card
|
||||
- Vertical flow downward
|
||||
- When a decision node has multiple options with children, render children in horizontal lanes side-by-side
|
||||
- Single-pane scrollable area (`overflow-auto`)
|
||||
- Background: `bg-background` with subtle CSS radial dot grid pattern
|
||||
|
||||
### Connector Lines
|
||||
|
||||
Use CSS borders (not SVG) for connecting lines:
|
||||
- **Parent-to-children trunk line:** `border-l border-border` extending down from parent
|
||||
- **Horizontal fork line:** `border-t border-border` connecting sibling lane tops
|
||||
- **Vertical stubs:** `border-l border-border` dropping into each child card
|
||||
|
||||
### Add-Node Flow
|
||||
|
||||
| Parent Type | Add Button Behavior |
|
||||
|-------------|-------------------|
|
||||
| Decision | Show `+ Add child` per option row (next to each option label) |
|
||||
| Action | Show single `+ Add child` below the card |
|
||||
| Solution | No add button (terminal node) |
|
||||
|
||||
- `+ Add` buttons use dashed border, appear on hover of parent card bottom edge
|
||||
- Clicking `+ Add` sets `pendingAddTarget` → shows inline type picker (decision/action/solution buttons) at that position
|
||||
- Selecting a type:
|
||||
1. Calls `addNode(parentId, type)` → gets new node ID
|
||||
2. Adds node ID to `newNodeIds`
|
||||
3. Adds entry to `pendingLinkByNodeId` (parent ID + option ID if from a decision option)
|
||||
4. Auto-expands the new node card
|
||||
5. Clears `pendingAddTarget`
|
||||
|
||||
### Save Behavior for New Child Nodes
|
||||
|
||||
When user clicks ✓ on a new node:
|
||||
1. Call `updateNode(nodeId, draft)` to save content to store
|
||||
2. If `pendingLinkByNodeId.has(nodeId)`:
|
||||
- Get the pending link info (`parentId`, `optionId`)
|
||||
- If `optionId` exists: update parent's `options[].next_node_id` to point to this node
|
||||
- If no `optionId` (action parent): update parent's `next_node_id` to point to this node
|
||||
3. Remove from `newNodeIds`
|
||||
4. Remove from `pendingLinkByNodeId`
|
||||
|
||||
### Cancel Behavior for New Nodes
|
||||
|
||||
When user clicks ✕ on a new (unsaved) node:
|
||||
1. Call `deleteNode(nodeId)`
|
||||
2. Remove from `newNodeIds`
|
||||
3. Remove from `pendingLinkByNodeId`
|
||||
|
||||
### Delete Behavior (Any Node)
|
||||
|
||||
When user clicks delete on any node:
|
||||
1. **Clean inbound references first** (see Link Model section above):
|
||||
- Walk the full tree and clear any `options[].next_node_id` or `next_node_id` matching the node being deleted
|
||||
2. Then call `deleteNode(nodeId)`
|
||||
3. Remove from `expandedNodeIds` if present
|
||||
|
||||
### Sibling Reorder
|
||||
|
||||
- Drag handle in card header (Phase 1 wired the events)
|
||||
- Drop zones rendered between sibling cards (visual indicator line)
|
||||
- On drop: call `reorderNodes(parentId, fromIndex, toIndex)`
|
||||
|
||||
### Selection Integration
|
||||
|
||||
- Click on a card calls `selectNode(nodeId)` in the store
|
||||
- Watch `selectedNodeId` from store (including changes from `ValidationSummary` clicks):
|
||||
- Auto-expand the selected node's card
|
||||
- Scroll the card into view with `scrollIntoView({ behavior: 'smooth', block: 'nearest' })`
|
||||
|
||||
### Implementation Steps
|
||||
|
||||
1. Create `TreeCanvas.tsx` with recursive tree rendering (compact cards only, no editing)
|
||||
2. Add CSS connector lines between parent and children
|
||||
3. Add horizontal branching for decision nodes with multiple children
|
||||
4. Add canvas state model (expandedNodeIds, newNodeIds, etc.)
|
||||
5. Wire card expand/collapse with single-expanded-card policy
|
||||
6. Add inline type picker and add-node flow with pending link tracking
|
||||
7. Wire save/cancel with pending link resolution
|
||||
8. Wire delete with inbound reference cleanup
|
||||
9. Add drag-and-drop sibling reorder
|
||||
10. Add selection sync (auto-expand + scroll into view)
|
||||
11. Add grid background pattern
|
||||
12. Style add buttons (dashed border, hover reveal)
|
||||
|
||||
### Verification
|
||||
|
||||
- Create a new tree → see canvas with root START card
|
||||
- Click root → expands inline (no modal)
|
||||
- Add 2 options, save → see branch lanes
|
||||
- Add child from each option → pending link resolves on save
|
||||
- Cancel a new node → node deleted, link cleaned up
|
||||
- Delete a linked node → parent's reference cleared
|
||||
- Drag reorder siblings
|
||||
- Run `npm run build` — no TypeScript errors
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Form Refactoring + Layout Update
|
||||
|
||||
### 3A: Form Refactoring for Inline Reuse Safety
|
||||
|
||||
**Files to modify:**
|
||||
- `frontend/src/components/tree-editor/NodeFormDecision.tsx`
|
||||
- `frontend/src/components/tree-editor/NodePicker.tsx`
|
||||
|
||||
#### NodeFormDecision.tsx Changes
|
||||
|
||||
**Problem:** Currently, option reordering calls `reorderOptions()` directly on the store. In inline canvas editing, this would write to the store before the user clicks ✓ save (breaking the local draft model).
|
||||
|
||||
**Fix:** Change option reordering to mutate the local `node.options` array through the `onUpdate` callback instead of calling the store directly.
|
||||
|
||||
```typescript
|
||||
// BEFORE (writes to store immediately):
|
||||
const handleReorderOptions = (fromIndex: number, toIndex: number) => {
|
||||
reorderOptions(node.id, fromIndex, toIndex)
|
||||
}
|
||||
|
||||
// AFTER (mutates local draft via onUpdate):
|
||||
const handleReorderOptions = (fromIndex: number, toIndex: number) => {
|
||||
const newOptions = [...(node.options || [])]
|
||||
const [moved] = newOptions.splice(fromIndex, 1)
|
||||
newOptions.splice(toIndex, 0, moved)
|
||||
onUpdate({ options: newOptions })
|
||||
}
|
||||
```
|
||||
|
||||
**Keep modal compatibility:** This change is backward-compatible. In the legacy modal path, `onUpdate` already propagates to the store. In the canvas path, `onUpdate` updates the local draft.
|
||||
|
||||
#### NodePicker.tsx Changes
|
||||
|
||||
**Problem:** `NodePicker` currently has create-new-node options (`__create_decision__`, etc.) that call `addNode()` on the store. In canvas inline editing, this would create nodes as a side effect of browsing the picker during draft editing.
|
||||
|
||||
**Fix:** Add an `allowCreate` prop:
|
||||
|
||||
```typescript
|
||||
interface NodePickerProps {
|
||||
// ... existing props
|
||||
allowCreate?: boolean // default: true
|
||||
}
|
||||
```
|
||||
|
||||
- When `allowCreate={false}`, hide the "Create New" option group
|
||||
- Pass `allowCreate={false}` from `TreeCanvasNode` expanded editing
|
||||
- Pass `allowCreate={true}` (default) in legacy modal path
|
||||
|
||||
### 3B: Layout Update
|
||||
|
||||
**File:** `frontend/src/components/tree-editor/TreeEditorLayout.tsx`
|
||||
|
||||
#### Changes
|
||||
|
||||
```typescript
|
||||
interface TreeEditorLayoutProps {
|
||||
isMobile?: boolean
|
||||
isMetadataOpen: boolean // NEW
|
||||
onCloseMetadata: () => void // NEW
|
||||
}
|
||||
```
|
||||
|
||||
**Flow mode (replace the 60/40 split):**
|
||||
|
||||
```
|
||||
BEFORE:
|
||||
<div className="w-3/5">
|
||||
<TreeMetadataForm />
|
||||
<NodeList />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<TreePreviewPanel />
|
||||
</div>
|
||||
|
||||
AFTER:
|
||||
<TreeCanvas /> (full width)
|
||||
<MetadataSidePanel isOpen={isMetadataOpen} onClose={onCloseMetadata} /> (overlay)
|
||||
```
|
||||
|
||||
**Code mode:** Unchanged. Keep existing 60/40 Monaco + preview behavior.
|
||||
|
||||
### 3C: MetadataSidePanel Component
|
||||
|
||||
**File:** `frontend/src/components/tree-editor/MetadataSidePanel.tsx` (NEW)
|
||||
|
||||
Right-side slide-in panel (320px wide) that wraps `<TreeMetadataForm />`.
|
||||
|
||||
```typescript
|
||||
interface MetadataSidePanelProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
```
|
||||
|
||||
Behavior:
|
||||
- Slides in from right edge, overlays the canvas (does NOT resize it)
|
||||
- Close triggers: panel close button, backdrop click, Escape key
|
||||
- Uses existing overlay/backdrop pattern from other modals in the app
|
||||
- Only rendered in Flow mode
|
||||
|
||||
### Implementation Steps
|
||||
|
||||
1. Refactor `NodeFormDecision.tsx` — option reorder through `onUpdate`
|
||||
2. Add `allowCreate` prop to `NodePicker.tsx`
|
||||
3. Create `MetadataSidePanel.tsx`
|
||||
4. Update `TreeEditorLayout.tsx` — swap Flow mode layout, add metadata panel props
|
||||
5. Verify legacy modal path still works (if still referenced anywhere)
|
||||
|
||||
### Verification
|
||||
|
||||
- Open tree editor in Flow mode → see full-width canvas (no 60/40 split)
|
||||
- Open tree editor in Code mode → see unchanged Monaco + preview layout
|
||||
- Click Metadata button → panel slides in from right, canvas doesn't resize
|
||||
- Close metadata panel via close button, backdrop click, and Escape key
|
||||
- Edit options in inline card → reorder does NOT write to store until ✓ save
|
||||
- NodePicker in inline card → no "Create New" options shown
|
||||
- Run `npm run build` — no TypeScript errors
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Toolbar Wiring + Exports
|
||||
|
||||
**File:** `frontend/src/pages/TreeEditorPage.tsx` (modify)
|
||||
|
||||
### Changes
|
||||
|
||||
1. Add local state: `const [isMetadataOpen, setIsMetadataOpen] = useState(false)`
|
||||
2. Add "Metadata" toolbar button — visible in Flow mode only
|
||||
3. Auto-close metadata panel when switching to Code mode:
|
||||
```typescript
|
||||
// In mode switch handler:
|
||||
if (newMode === 'code') setIsMetadataOpen(false)
|
||||
```
|
||||
4. Pass metadata props into `TreeEditorLayout`:
|
||||
```typescript
|
||||
<TreeEditorLayout
|
||||
isMetadataOpen={isMetadataOpen}
|
||||
onCloseMetadata={() => setIsMetadataOpen(false)}
|
||||
/>
|
||||
```
|
||||
5. Keep all existing toolbar actions unchanged: undo/redo, save/publish, validate, analytics
|
||||
|
||||
**File:** `frontend/src/components/tree-editor/index.ts` (modify)
|
||||
|
||||
Add exports:
|
||||
```typescript
|
||||
export { TreeCanvas } from './TreeCanvas'
|
||||
export { TreeCanvasNode } from './TreeCanvasNode'
|
||||
export { MetadataSidePanel } from './MetadataSidePanel'
|
||||
```
|
||||
|
||||
Keep all legacy exports (`NodeList`, `NodeEditorModal`, `TreePreviewPanel`) — they remain in the codebase but are no longer imported in the active Flow path.
|
||||
|
||||
### Implementation Steps
|
||||
|
||||
1. Add metadata panel state and toolbar button to `TreeEditorPage.tsx`
|
||||
2. Add auto-close on Code mode switch
|
||||
3. Pass props through to `TreeEditorLayout`
|
||||
4. Update `index.ts` exports
|
||||
5. Verify no dead imports remain
|
||||
|
||||
### Verification
|
||||
|
||||
- Flow mode toolbar shows "Metadata" button
|
||||
- Code mode toolbar does NOT show "Metadata" button
|
||||
- Click Metadata → panel opens. Switch to Code → panel auto-closes
|
||||
- Undo/redo/save/publish/validate all still work
|
||||
- Run `npm run build` — no TypeScript errors
|
||||
|
||||
---
|
||||
|
||||
## Critical Files Summary
|
||||
|
||||
| File | Action | Phase | Notes |
|
||||
|------|--------|-------|-------|
|
||||
| `TreeCanvasNode.tsx` | Create | 1 | Inline-editing card with local draft + commit model |
|
||||
| `TreeCanvas.tsx` | Create | 2 | Main canvas orchestration with full state model |
|
||||
| `MetadataSidePanel.tsx` | Create | 3 | 320px right slide-in overlay wrapping TreeMetadataForm |
|
||||
| `NodeFormDecision.tsx` | Refactor | 3 | Option reorder through onUpdate (remove store write) |
|
||||
| `NodePicker.tsx` | Refactor | 3 | Add `allowCreate` prop (default true) |
|
||||
| `TreeEditorLayout.tsx` | Modify | 3 | Replace 60/40 split with full-width canvas + overlay |
|
||||
| `TreeEditorPage.tsx` | Modify | 4 | Add metadata panel toggle, auto-close on Code switch |
|
||||
| `index.ts` | Update | 4 | Export new components, keep legacy exports |
|
||||
| `treeEditorStore.ts` | No changes | — | Store logic is solid as-is |
|
||||
| `NodeList.tsx` | Keep (legacy) | — | Removed from active Flow path |
|
||||
| `NodeEditorModal.tsx` | Keep (legacy) | — | Removed from active Flow path |
|
||||
| `TreePreviewPanel.tsx` | Keep (legacy) | — | Removed from active Flow path |
|
||||
|
||||
---
|
||||
|
||||
## Assumptions & Defaults
|
||||
|
||||
- **Link rendering:** Tree-first (no full cross-canvas graph lines for shared targets)
|
||||
- **Inline edit commit:** On checkmark save (local draft, cancel discards)
|
||||
- **Metadata drawer:** Flow mode only
|
||||
- **Expanded card policy:** One expanded card at a time
|
||||
- **Legacy components:** Remain in repo, removed from active Flow path only
|
||||
- **Connector lines:** CSS borders (not SVG — simpler, matches existing patterns)
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
### Automated Tests
|
||||
|
||||
**Files:** `TreeCanvas.test.tsx`, `TreeCanvasNode.test.tsx`
|
||||
|
||||
| Test Case | What It Verifies |
|
||||
|-----------|-----------------|
|
||||
| Select node expands and scrolls card | Selection sync works |
|
||||
| Inline save commits store updates | Draft → store pipeline works |
|
||||
| Cancel on new node triggers deletion | Unsaved node cleanup works |
|
||||
| Add child from decision option sets `next_node_id` on save | Pending link resolution works |
|
||||
| Delete node clears inbound references | Reference cleanup works |
|
||||
| Sibling drag reorder calls `reorderNodes` | Drag-and-drop wiring works |
|
||||
| `allowCreate={false}` hides create options in NodePicker | Form safety works |
|
||||
| Option reorder in inline card does NOT write to store | Draft isolation works |
|
||||
|
||||
Run: `npm run build && npm run test` (or targeted vitest files)
|
||||
|
||||
### Manual Acceptance Checklist
|
||||
|
||||
- [ ] Create a new tree — canvas shows root START card
|
||||
- [ ] Click root card — expands inline with decision fields (no modal appears)
|
||||
- [ ] Fill in question, add 2 options, click ✓ — saves inline, see branch lanes
|
||||
- [ ] `+ Add child` buttons appear below each option
|
||||
- [ ] Add a child node inline — verify parent link is set correctly
|
||||
- [ ] Cancel a new unsaved node — confirm it is deleted from the tree
|
||||
- [ ] Delete a node that is referenced by another — confirm references are cleaned
|
||||
- [ ] Drag to reorder sibling nodes
|
||||
- [ ] Open Metadata panel — edit metadata — close panel (button, backdrop, Escape)
|
||||
- [ ] Validate and Publish — confirm tree saves correctly
|
||||
- [ ] Switch Flow → Code mode — metadata panel auto-closes, Code mode works normally
|
||||
- [ ] Switch Code → Flow mode — canvas renders correctly
|
||||
- [ ] Run `npm run build` — no TypeScript errors
|
||||
- [ ] Run `npm run test` — all tests pass
|
||||
|
||||
---
|
||||
|
||||
## Git Strategy
|
||||
|
||||
- Branch: `feature/tree-editor-canvas`
|
||||
- One commit per phase (4 commits total)
|
||||
- Commit messages:
|
||||
1. `feat: Add TreeCanvasNode inline editor card component`
|
||||
2. `feat: Add TreeCanvas layout with visual branching and orchestration`
|
||||
3. `refactor: Update forms for inline safety, add MetadataSidePanel, update layout`
|
||||
4. `feat: Wire toolbar metadata toggle and update exports`
|
||||
- PR when all 4 phases pass `npm run build && npm run test`
|
||||
- Include `Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>`
|
||||
|
||||
---
|
||||
|
||||
## Notes for Implementation
|
||||
|
||||
1. **Read existing code first:** Before creating any new file, read the files listed in the Reusable Code Inventory to understand current patterns and prop interfaces
|
||||
2. **Follow existing patterns:** Match the component structure, Tailwind usage, and TypeScript conventions already in the tree-editor directory
|
||||
3. **Dark mode:** All new components must support light/dark themes via existing Tailwind classes
|
||||
4. **Keyboard navigation:** Support Escape to close metadata panel, Tab through form fields in expanded cards
|
||||
5. **Loading states:** Canvas should handle the case where `treeStructure` is null (show empty state)
|
||||
6. **No store changes:** The `treeEditorStore.ts` should NOT be modified. All new state is local to the canvas components
|
||||
7. **Test each phase independently:** Each phase should leave the app in a buildable, testable state before moving to the next
|
||||
745
docs/plans/archive/MASTER-PLAN-editor-ux-fixes.md
Normal file
745
docs/plans/archive/MASTER-PLAN-editor-ux-fixes.md
Normal file
@@ -0,0 +1,745 @@
|
||||
# Master Plan: Flow Editor UX Fixes + Answer Stub Placeholders
|
||||
|
||||
> **For Claude Code:** Implement this plan task-by-task in order. Each phase must build and pass tests before proceeding to the next. Commit after each task.
|
||||
>
|
||||
> **Working directory:** Use the active tree-editor-canvas worktree or main branch as appropriate.
|
||||
|
||||
---
|
||||
|
||||
## Plan Overview
|
||||
|
||||
This plan fixes three UX pain points in the tree editor:
|
||||
|
||||
1. **Can't reach bottom of editor** — scrollable content + optional fullscreen toggle
|
||||
2. **Form clutter** — replace always-visible hint paragraphs with info-on-demand tooltips
|
||||
3. **Forced child-type selection slows branching** — introduce `'answer'` placeholder stubs so users can name branches first and pick types later
|
||||
|
||||
---
|
||||
|
||||
## Plan Comparison Notes
|
||||
|
||||
This master plan was synthesized from two candidate plans. Here's what was chosen and why:
|
||||
|
||||
| Area | Plan 1 (Strategy Doc) | Plan 2 (Canvas Implementation) | Master Plan Choice | Rationale |
|
||||
|------|----------------------|-------------------------------|-------------------|-----------|
|
||||
| **Scroll fix** | Modal-level `allowFullScreen` prop on `Modal.tsx` with localStorage persistence | Canvas-level CSS fix: `max-h-[70vh] overflow-y-auto` + sticky header on `TreeCanvasNode.tsx` | **Both** — Canvas CSS fix for inline cards AND Modal fullscreen for modal editor | They fix different surfaces. The canvas inline editor and the modal editor are separate code paths. Both need the fix. |
|
||||
| **Fullscreen toggle** | `Maximize2`/`Minimize2` icons, `allowFullScreen` opt-in prop, localStorage persistence | Not included | **Include** (Plan 1) | Fullscreen editing is a meaningful UX upgrade for complex nodes. The opt-in prop pattern keeps other modals unaffected. |
|
||||
| **Info tooltips** | Conceptual — mentions `FieldHelp.tsx` helper component + "Show tips" toggle | Line-by-line implementation — native `title` attribute on inline ⓘ badge spans | **Plan 2's inline approach, but extract to a reusable component** | Plan 2's approach is concrete and proven. But repeating the same 4-line `<span>` everywhere creates maintenance debt. Extract to a tiny `<InfoTip text="..." />` component, then use it everywhere. Skip the "Show tips" toggle — it adds complexity without clear user value. |
|
||||
| **Placeholder node naming** | Calls it `'choice'` | Calls it `'answer'` | **`'answer'`** | In a troubleshooting tree, decision options ARE answers to the question. "Choice" is ambiguous — it could mean the decision itself. "Answer" is intuitive: "What type of device?" → answers: "Server", "Desktop", "Laptop". |
|
||||
| **Answer stub creation** | Manual — user clicks "Create Placeholder" per option | Automatic — saving a decision node auto-creates stubs for any option without a `next_node_id` | **Automatic** (Plan 2) | Automatic creation is faster and requires zero extra clicks. The whole point of stubs is reducing friction. Making users manually create them defeats the purpose. |
|
||||
| **Answer stub UI** | Conversion via node editor modal (Convert to Decision/Action/Solution buttons) | Dedicated `AnswerStubCard` component — click card → inline type picker with color-coded buttons | **`AnswerStubCard`** (Plan 2) | A dedicated visual component with dashed border and inline type picker is more discoverable and faster than opening a modal just to convert. Users see the stub, click it, pick a type — done in one interaction. |
|
||||
| **NodePicker removal** | Keeps NodePicker, adds choice creation alongside it | Removes NodePicker from decision form entirely — options become label-only inputs | **Remove NodePicker** (Plan 2) | This is the key UX insight. The old flow forced users to pick a child type while still writing the question. The new flow: write your question → name your answers → save → stubs appear → convert each stub when ready. This matches how humans actually think about branching. |
|
||||
| **Publish validation** | Backend `can_publish_tree` check + frontend disabled publish button | Backend `validate_tree_structure` check + frontend `hasAnswerNodes` guard with toast message | **Both layers** (combined) | Defense in depth. Frontend gives instant feedback via toast. Backend prevents bad data regardless of client. |
|
||||
| **Markdown parser/code mode** | Explicitly handles `answer` in markdown parser, validator, and serializer | Not addressed | **Include** (Plan 1) | Important for data integrity. If a user switches to code/markdown mode, answer nodes shouldn't get silently dropped or cause parse errors. |
|
||||
| **Runtime defensive guard** | Includes guard in session navigation — if `answer` encountered at runtime, show blocking message | Not addressed | **Include** (Plan 1) | Published trees should never have answer nodes, but defensive programming matters. A clear "this tree has unresolved placeholders" message is better than a crash. |
|
||||
| **Testing plan** | Comprehensive list of frontend + backend + manual test scenarios | Build verification per task + final manual checklist | **Plan 1's scope with Plan 2's per-task verification** | Plan 1 defines what to test; Plan 2's approach of verifying builds after every task catches issues early. |
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Scrollability + Fullscreen Editor
|
||||
|
||||
### Task 1.1: Fix canvas inline card scroll (TreeCanvasNode)
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/tree-editor/TreeCanvasNode.tsx`
|
||||
|
||||
**Changes:**
|
||||
|
||||
1. Make the card header sticky when expanded. Find the header `<div>` (the one with `flex items-center gap-2 px-3 py-2.5`). Add conditional sticky classes:
|
||||
```tsx
|
||||
isExpanded && 'sticky top-0 z-10 bg-card rounded-t-xl'
|
||||
```
|
||||
|
||||
2. Make the expanded editing area scrollable. Find the expanded content `<div>` (the one with `border-t border-border px-3 pb-3 pt-3`). Add max height and scroll:
|
||||
```tsx
|
||||
className="border-t border-border px-3 pb-3 pt-3 max-h-[70vh] overflow-y-auto"
|
||||
```
|
||||
|
||||
**Verify:** `npm run build` — clean build, no errors.
|
||||
|
||||
**Commit:** `fix: make canvas card expanded area scrollable with sticky header`
|
||||
|
||||
---
|
||||
|
||||
### Task 1.2: Add fullscreen toggle to Modal component
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/common/Modal.tsx`
|
||||
- Modify: `frontend/src/components/tree-editor/NodeEditorModal.tsx`
|
||||
|
||||
**Changes to Modal.tsx:**
|
||||
|
||||
1. Add new optional prop: `allowFullScreen?: boolean` (default `false`).
|
||||
|
||||
2. Add state inside Modal:
|
||||
```tsx
|
||||
const [isFullScreen, setIsFullScreen] = useState(() => {
|
||||
if (!allowFullScreen) return false
|
||||
try {
|
||||
return localStorage.getItem('rf-editor-fullscreen') === 'true'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
3. Persist preference on toggle:
|
||||
```tsx
|
||||
const toggleFullScreen = () => {
|
||||
const next = !isFullScreen
|
||||
setIsFullScreen(next)
|
||||
try {
|
||||
localStorage.setItem('rf-editor-fullscreen', String(next))
|
||||
} catch {}
|
||||
}
|
||||
```
|
||||
|
||||
4. Add `Maximize2` and `Minimize2` imports from `lucide-react`.
|
||||
|
||||
5. Render expand/collapse button in the modal header (next to the close button) only when `allowFullScreen` is `true`:
|
||||
```tsx
|
||||
{allowFullScreen && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleFullScreen}
|
||||
className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
title={isFullScreen ? 'Exit full screen' : 'Full screen'}
|
||||
>
|
||||
{isFullScreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
|
||||
</button>
|
||||
)}
|
||||
```
|
||||
|
||||
6. Apply conditional sizing classes on the modal container:
|
||||
- Default: existing size classes (whatever `size="lg"` currently maps to, e.g. `max-w-2xl`)
|
||||
- Full screen: `fixed inset-4 max-w-none w-auto h-auto` (fills viewport with small margin)
|
||||
- Add `transition-all duration-200` for smooth animation between modes.
|
||||
- The modal body must remain `overflow-y-auto` in both modes.
|
||||
|
||||
**Changes to NodeEditorModal.tsx:**
|
||||
|
||||
Pass the new prop to Modal:
|
||||
```tsx
|
||||
<Modal isOpen={true} onClose={onClose} title={getTitle()} size="lg" footer={footerContent} allowFullScreen={true}>
|
||||
```
|
||||
|
||||
**Do NOT change:** Any other modal usage in the app. Only NodeEditorModal opts in.
|
||||
|
||||
**Verify:** `npm run build` — clean build.
|
||||
|
||||
**Commit:** `feat: add fullscreen toggle to Modal component, enable in NodeEditorModal`
|
||||
|
||||
---
|
||||
|
||||
### Task 1.3: Verify scroll contract across both editor surfaces
|
||||
|
||||
**Manual verification checklist:**
|
||||
- [ ] Open a decision node in canvas inline editor → resize browser to short viewport → form scrolls, sticky header (save/cancel) stays visible
|
||||
- [ ] Open a node in the modal editor → content scrolls, header/footer fixed
|
||||
- [ ] Click fullscreen toggle → modal fills viewport with margin → content still scrolls
|
||||
- [ ] Click collapse → returns to normal size smoothly
|
||||
- [ ] Refresh page → fullscreen preference persisted
|
||||
- [ ] Other modals (StepDetailModal, CustomStepModal, etc.) are unaffected
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Info-On-Demand Tooltips
|
||||
|
||||
### Task 2.0: Create reusable InfoTip component
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/components/common/InfoTip.tsx`
|
||||
|
||||
**Content:**
|
||||
```tsx
|
||||
interface InfoTipProps {
|
||||
text: string
|
||||
}
|
||||
|
||||
export function InfoTip({ text }: InfoTipProps) {
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center justify-center h-3.5 w-3.5 rounded-full border border-muted-foreground/40 text-[9px] text-muted-foreground cursor-help shrink-0"
|
||||
title={text}
|
||||
>
|
||||
i
|
||||
</span>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
This is a tiny component but it prevents repeating the same 4-line span pattern in every form file. Import it as `import { InfoTip } from '@/components/common/InfoTip'`.
|
||||
|
||||
**Verify:** `npm run build` — clean build.
|
||||
|
||||
**Commit:** `feat: add reusable InfoTip component for field-level help`
|
||||
|
||||
---
|
||||
|
||||
### Task 2.1: Replace hint text in NodeFormDecision
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/tree-editor/NodeFormDecision.tsx`
|
||||
|
||||
**Changes:**
|
||||
|
||||
1. Import `InfoTip` from `@/components/common/InfoTip`.
|
||||
|
||||
2. Remove the root node hint `<p>` block ("What's the main question to diagnose the issue?") — the input placeholder already conveys this.
|
||||
|
||||
3. Replace the options hint `<p>` paragraphs (both root and non-root variants) with an `<InfoTip>` on the label:
|
||||
```tsx
|
||||
<label className="flex items-center gap-1.5 text-sm font-medium text-foreground">
|
||||
{isRootNode ? 'Answer Options (Branches)' : 'Options'} <span className="text-red-400">*</span>
|
||||
<InfoTip text={isRootNode
|
||||
? "Add as many options as needed (A, B, C, D...). Each option leads to a different troubleshooting path."
|
||||
: "Each option can branch to a different next step."} />
|
||||
</label>
|
||||
```
|
||||
|
||||
4. Keep all required markers (`*`) and field-level validation error messages visible — only remove the instructional paragraphs.
|
||||
|
||||
**Verify:** `npm run build` — clean build.
|
||||
|
||||
**Commit:** `fix: replace hint paragraphs with info tooltips in NodeFormDecision`
|
||||
|
||||
---
|
||||
|
||||
### Task 2.2: Replace hint text in NodeFormAction
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/tree-editor/NodeFormAction.tsx`
|
||||
|
||||
**Changes:**
|
||||
|
||||
1. Import `InfoTip`.
|
||||
|
||||
2. Description field — replace the markdown hint `<p>` with InfoTip on the label:
|
||||
```tsx
|
||||
<label className="flex items-center gap-1.5 text-sm font-medium text-foreground">
|
||||
Description
|
||||
<InfoTip text="Supports markdown: **bold**, *italic*, - lists, 1. numbered lists, `code`" />
|
||||
</label>
|
||||
```
|
||||
|
||||
3. Commands field — replace the hint `<p>` with InfoTip on the label:
|
||||
```tsx
|
||||
<label className="flex items-center gap-1.5 text-sm font-medium text-foreground">
|
||||
Commands
|
||||
<InfoTip text="PowerShell or CLI commands to execute" />
|
||||
</label>
|
||||
```
|
||||
|
||||
**Verify:** `npm run build` — clean build.
|
||||
|
||||
**Commit:** `fix: replace hint paragraphs with info tooltips in NodeFormAction`
|
||||
|
||||
---
|
||||
|
||||
### Task 2.3: Replace hint text in NodeFormResolution
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/tree-editor/NodeFormResolution.tsx`
|
||||
|
||||
**Changes:**
|
||||
|
||||
1. Import `InfoTip`.
|
||||
|
||||
2. Description field — replace the markdown hint `<p>` with InfoTip on the label (same pattern as NodeFormAction).
|
||||
|
||||
3. Resolution Steps field — replace the hint `<p>` with InfoTip:
|
||||
```tsx
|
||||
<label className="flex items-center gap-1.5 text-sm font-medium text-foreground">
|
||||
Resolution Steps
|
||||
<InfoTip text="Step-by-step instructions for resolving the issue" />
|
||||
</label>
|
||||
```
|
||||
|
||||
**Verify:** `npm run build` — clean build.
|
||||
|
||||
**Commit:** `fix: replace hint paragraphs with info tooltips in NodeFormResolution`
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Answer Stub Placeholder System
|
||||
|
||||
### Task 3.1: Add `'answer'` to the NodeType union
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/types/tree.ts`
|
||||
|
||||
**Change:**
|
||||
```typescript
|
||||
// Before
|
||||
export type NodeType = 'decision' | 'action' | 'solution'
|
||||
|
||||
// After
|
||||
export type NodeType = 'decision' | 'action' | 'solution' | 'answer'
|
||||
```
|
||||
|
||||
**Note:** This will cause a TypeScript error in `TreeCanvasNode.tsx` because `NODE_TYPE_CONFIG` doesn't have an `'answer'` key. That's expected and fixed in Task 3.3.
|
||||
|
||||
**Verify:** `npm run build` — note the expected error, proceed.
|
||||
|
||||
**Commit:** `feat: add 'answer' to NodeType union for branch placeholder stubs`
|
||||
|
||||
---
|
||||
|
||||
### Task 3.2: Create the AnswerStubCard component
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/components/tree-editor/AnswerStubCard.tsx`
|
||||
|
||||
**Content:**
|
||||
```tsx
|
||||
import { useState } from 'react'
|
||||
import { HelpCircle, Zap, CheckCircle } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { TreeStructure } from '@/types'
|
||||
|
||||
interface AnswerStubCardProps {
|
||||
node: TreeStructure // type === 'answer'
|
||||
fromOption?: string
|
||||
onSelectType: (nodeId: string, type: 'decision' | 'action' | 'solution') => void
|
||||
}
|
||||
|
||||
export function AnswerStubCard({ node, fromOption, onSelectType }: AnswerStubCardProps) {
|
||||
const [picking, setPicking] = useState(false)
|
||||
const label = fromOption || node.title || 'Answer'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'min-w-[180px] max-w-[280px] rounded-xl border-2 border-dashed border-border bg-card/50',
|
||||
'transition-all duration-150',
|
||||
!picking && 'cursor-pointer hover:border-primary/40 hover:bg-accent/30'
|
||||
)}
|
||||
onClick={() => !picking && setPicking(true)}
|
||||
>
|
||||
{/* Label */}
|
||||
<div className="px-3 pt-2.5 pb-1 text-sm font-heading font-medium text-foreground text-center">
|
||||
{label}
|
||||
</div>
|
||||
|
||||
{/* Prompt / type picker */}
|
||||
{!picking ? (
|
||||
<div className="pb-2.5 text-center text-[10px] text-muted-foreground font-label">
|
||||
+ Choose Type
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center gap-1.5 pb-2.5 px-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); onSelectType(node.id, 'decision') }}
|
||||
className={cn(
|
||||
'flex items-center gap-1 rounded-md px-2 py-1 text-[10px] font-label',
|
||||
'border border-blue-500/30 bg-blue-500/10 text-blue-400 hover:bg-blue-500/20'
|
||||
)}
|
||||
>
|
||||
<HelpCircle className="h-2.5 w-2.5" /> Decision
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); onSelectType(node.id, 'action') }}
|
||||
className={cn(
|
||||
'flex items-center gap-1 rounded-md px-2 py-1 text-[10px] font-label',
|
||||
'border border-yellow-500/30 bg-yellow-500/10 text-yellow-400 hover:bg-yellow-500/20'
|
||||
)}
|
||||
>
|
||||
<Zap className="h-2.5 w-2.5" /> Action
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); onSelectType(node.id, 'solution') }}
|
||||
className={cn(
|
||||
'flex items-center gap-1 rounded-md px-2 py-1 text-[10px] font-label',
|
||||
'border border-green-500/30 bg-green-500/10 text-green-400 hover:bg-green-500/20'
|
||||
)}
|
||||
>
|
||||
<CheckCircle className="h-2.5 w-2.5" /> Solution
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AnswerStubCard
|
||||
```
|
||||
|
||||
**Design rationale:** Dashed border visually distinguishes stubs from real nodes. Color-coded type buttons match the existing node type color scheme. Single-click interaction (click card → pick type) is the fastest possible conversion flow.
|
||||
|
||||
**Verify:** `npm run build` — no errors mentioning AnswerStubCard.
|
||||
|
||||
**Commit:** `feat: add AnswerStubCard component for unresolved branch placeholders`
|
||||
|
||||
---
|
||||
|
||||
### Task 3.3: Guard TreeCanvasNode against `'answer'` type
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/tree-editor/TreeCanvasNode.tsx`
|
||||
|
||||
**Change:** Guard the `NODE_TYPE_CONFIG` lookup so `'answer'` doesn't crash:
|
||||
|
||||
```tsx
|
||||
// Before
|
||||
const config = NODE_TYPE_CONFIG[node.type]
|
||||
|
||||
// After
|
||||
const config = node.type in NODE_TYPE_CONFIG
|
||||
? NODE_TYPE_CONFIG[node.type as keyof typeof NODE_TYPE_CONFIG]
|
||||
: NODE_TYPE_CONFIG.decision // fallback for 'answer' (rendered by AnswerStubCard instead)
|
||||
```
|
||||
|
||||
**Note:** Answer nodes should never be rendered by TreeCanvasNode — TreeCanvas routes them to AnswerStubCard. This is a safety fallback only.
|
||||
|
||||
**Verify:** `npm run build` — the TypeScript error from Task 3.1 should now be resolved. Clean build.
|
||||
|
||||
**Commit:** `fix: guard NODE_TYPE_CONFIG lookup against 'answer' type`
|
||||
|
||||
---
|
||||
|
||||
### Task 3.4: Redesign NodeFormDecision — label-only options (remove NodePicker)
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/tree-editor/NodeFormDecision.tsx`
|
||||
|
||||
**This is the biggest UX change in the plan.** The old flow forced users to pick a child node type for each option while still writing the decision question. The new flow lets them just name their answers — stub nodes are created automatically on save.
|
||||
|
||||
**Changes:**
|
||||
|
||||
1. Remove the `NodePicker` import — it's no longer used in this form.
|
||||
|
||||
2. Replace the `DynamicArrayField` `renderItem` for options. The new renderItem shows only a letter badge + label text input per option. No NodePicker, no next_node_id selector:
|
||||
```tsx
|
||||
renderItem={(option, index) => {
|
||||
const optionLabelError = validationErrors.find(
|
||||
e => e.nodeId === node.id && e.field === `options[${index}].label`
|
||||
)
|
||||
const letter = indexToLetter(index)
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn(
|
||||
'flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs font-bold',
|
||||
isRootNode ? 'bg-blue-500/20 text-blue-400' : 'bg-accent text-muted-foreground'
|
||||
)}>
|
||||
{letter}
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={option.label}
|
||||
onChange={(e) => handleUpdateOption(index, { label: e.target.value })}
|
||||
placeholder={isRootNode
|
||||
? `Branch ${letter}: e.g., "Network Issues"...`
|
||||
: `Option ${letter} label`}
|
||||
className={cn(
|
||||
'block flex-1 rounded-md border px-3 py-2 text-sm',
|
||||
'bg-background text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary',
|
||||
optionLabelError ? 'border-red-400' : 'border-border'
|
||||
)}
|
||||
/>
|
||||
{optionLabelError && (
|
||||
<p className="mt-1 text-xs text-red-400">{optionLabelError.message}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
```
|
||||
|
||||
3. Remove the `optionNextError` validation lookup (no longer displayed since NodePicker is gone).
|
||||
|
||||
4. Remove the old `<div className="rounded-md border border-border bg-accent/50 p-3">` wrapper from the old renderItem if present — the new renderItem renders flat rows.
|
||||
|
||||
**Verify:** `npm run build` — clean build. Ensure no unused `NodePicker` import warnings.
|
||||
|
||||
**Commit:** `feat: redesign NodeFormDecision to label-only options (remove NodePicker)`
|
||||
|
||||
---
|
||||
|
||||
### Task 3.5: Wire up auto-creation and rendering in TreeCanvas
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/tree-editor/TreeCanvas.tsx`
|
||||
|
||||
**Changes:**
|
||||
|
||||
1. Import `AnswerStubCard`:
|
||||
```tsx
|
||||
import { AnswerStubCard } from './AnswerStubCard'
|
||||
```
|
||||
|
||||
2. Add `handleSelectAnswerType` callback (converts answer stub to a real type):
|
||||
```tsx
|
||||
const handleSelectAnswerType = useCallback(
|
||||
(nodeId: string, type: 'decision' | 'action' | 'solution') => {
|
||||
updateNode(nodeId, { type })
|
||||
setExpandedNodeId(nodeId)
|
||||
selectNode(nodeId)
|
||||
},
|
||||
[updateNode, selectNode]
|
||||
)
|
||||
```
|
||||
|
||||
3. Update `handleSave` — after `updateNode(nodeId, updates)`, auto-create answer stubs for any decision option that has a label but no `next_node_id`:
|
||||
```tsx
|
||||
if (updates.type === 'decision' || updates.options) {
|
||||
const options = updates.options || []
|
||||
options.forEach((opt) => {
|
||||
if (!opt.next_node_id && opt.label.trim()) {
|
||||
const stubId = addNode(nodeId, 'answer')
|
||||
updateNode(stubId, { title: opt.label })
|
||||
const updatedOptions = options.map((o) =>
|
||||
o.id === opt.id ? { ...o, next_node_id: stubId } : o
|
||||
)
|
||||
updateNode(nodeId, { options: updatedOptions })
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
4. Add `handleSelectAnswerType` to the `renderNode` `useCallback` dependency array.
|
||||
|
||||
5. In `renderNode`, conditionally render `AnswerStubCard` for answer-type nodes instead of `TreeCanvasNode`:
|
||||
```tsx
|
||||
{node.type === 'answer' ? (
|
||||
<AnswerStubCard
|
||||
node={node}
|
||||
fromOption={optionLabel}
|
||||
onSelectType={handleSelectAnswerType}
|
||||
/>
|
||||
) : (
|
||||
<TreeCanvasNode ... />
|
||||
)}
|
||||
```
|
||||
|
||||
**Verify:** `npm run build` — clean build.
|
||||
|
||||
**Commit:** `feat: auto-create answer stubs on decision save, render AnswerStubCard`
|
||||
|
||||
---
|
||||
|
||||
### Task 3.6: Guard NodeList against `'answer'` type (list editor compatibility)
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/tree-editor/NodeList.tsx`
|
||||
|
||||
**Changes:**
|
||||
|
||||
The `nodeTypeIcons` and `nodeTypeColors` Record types in `NodeListItem` only have keys for `decision`, `action`, `solution`. Add `answer`:
|
||||
|
||||
```tsx
|
||||
const nodeTypeIcons: Record<NodeType, React.ReactNode> = {
|
||||
decision: <HelpCircle className="h-4 w-4" />,
|
||||
action: <Zap className="h-4 w-4" />,
|
||||
solution: <CheckCircle className="h-4 w-4" />,
|
||||
answer: <HelpCircle className="h-4 w-4 opacity-50" />
|
||||
}
|
||||
|
||||
const nodeTypeColors: Record<NodeType, string> = {
|
||||
decision: 'bg-blue-500/20 text-blue-600 dark:text-blue-400',
|
||||
action: 'bg-yellow-500/20 text-yellow-600 dark:text-yellow-400',
|
||||
solution: 'bg-green-500/20 text-green-600 dark:text-green-400',
|
||||
answer: 'bg-muted text-muted-foreground border border-dashed border-border'
|
||||
}
|
||||
```
|
||||
|
||||
**Verify:** `npm run build` — clean build.
|
||||
|
||||
**Commit:** `fix: add answer type to NodeList icon and color maps`
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Validation + Backend Safety
|
||||
|
||||
### Task 4.1: Backend — allow `'answer'` in drafts, block on publish
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/core/tree_validation.py`
|
||||
|
||||
**Changes:**
|
||||
|
||||
1. In `_validate_node`, add an `elif` for `'answer'` before the `else` (unknown type) branch:
|
||||
```python
|
||||
elif node_type == "answer":
|
||||
# Answer nodes are draft-only placeholders — no structural validation needed
|
||||
pass
|
||||
```
|
||||
|
||||
2. Add a recursive helper function:
|
||||
```python
|
||||
def _has_answer_nodes(node: dict[str, Any]) -> bool:
|
||||
"""Recursively check if any node in the tree has type 'answer'."""
|
||||
if node.get("type") == "answer":
|
||||
return True
|
||||
for child in node.get("children", []):
|
||||
if _has_answer_nodes(child):
|
||||
return True
|
||||
return False
|
||||
```
|
||||
|
||||
3. In `validate_tree_structure`, after the recursive `_validate_children` call and before the return, add:
|
||||
```python
|
||||
# Block publish if any answer placeholder nodes remain
|
||||
if _has_answer_nodes(tree_structure):
|
||||
errors.append({
|
||||
"field": "tree_structure",
|
||||
"message": "Answer placeholders must be resolved to a node type before publishing."
|
||||
})
|
||||
```
|
||||
|
||||
**Verify:** Run backend tests — `pytest --override-ini="addopts=" -q` — all tests pass.
|
||||
|
||||
**Commit:** `feat: allow 'answer' type in tree drafts, block on publish`
|
||||
|
||||
---
|
||||
|
||||
### Task 4.2: Frontend — publish guard with toast message
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/pages/TreeEditorPage.tsx`
|
||||
|
||||
**Changes:**
|
||||
|
||||
1. Add utility function before the component:
|
||||
```typescript
|
||||
function hasAnswerNodes(node: TreeStructure): boolean {
|
||||
if (node.type === 'answer') return true
|
||||
return (node.children || []).some(hasAnswerNodes)
|
||||
}
|
||||
```
|
||||
|
||||
2. In `handlePublish`, after the tree name check and before `validate()`, add:
|
||||
```typescript
|
||||
const currentStructure = useTreeEditorStore.getState().treeStructure
|
||||
if (currentStructure && hasAnswerNodes(currentStructure)) {
|
||||
toast.error('Resolve all answer placeholders before publishing. Click each dashed stub card to assign a type.')
|
||||
setSaving(false)
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
**Verify:** `npm run build` — clean build.
|
||||
|
||||
**Commit:** `feat: block publish if unresolved answer stub nodes exist`
|
||||
|
||||
---
|
||||
|
||||
### Task 4.3: Markdown parser/serializer compatibility
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/utils/treeMarkdownSync.ts` (or wherever markdown sync lives)
|
||||
- Modify: `backend/app/core/tree_markdown_parser.py` (if exists)
|
||||
- Modify: `backend/app/core/tree_markdown_validator.py` (if exists)
|
||||
|
||||
**Changes:**
|
||||
|
||||
Ensure the markdown serializer and parser handle `type: 'answer'` gracefully:
|
||||
|
||||
1. **Serializer** (`treeStructureToMarkdownPreview` or equivalent): Serialize answer nodes with a clear marker, e.g.:
|
||||
```markdown
|
||||
### [ANSWER PLACEHOLDER] Server
|
||||
> This is an unresolved answer stub. Convert it to a Decision, Action, or Solution before publishing.
|
||||
```
|
||||
|
||||
2. **Parser**: Accept `type: answer` in parsed markdown without errors. Map it back to a node with `type: 'answer'`.
|
||||
|
||||
3. **Validator**: If a markdown validator exists, treat `answer` nodes as a publish-blocking warning (same rule as the structural validator).
|
||||
|
||||
**Note:** If these files don't exist yet, skip this task — the backend structural validation in Task 4.1 is the primary safety net.
|
||||
|
||||
**Verify:** `npm run build` + backend tests pass.
|
||||
|
||||
**Commit:** `feat: handle 'answer' type in markdown parser/serializer`
|
||||
|
||||
---
|
||||
|
||||
### Task 4.4: Runtime defensive guard in session navigation
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/pages/TreeNavigationPage.tsx`
|
||||
|
||||
**Changes:**
|
||||
|
||||
In the session player's node rendering logic, add a guard for `answer` type nodes. If the current node has `type === 'answer'`, display a blocking message instead of the normal node UI:
|
||||
|
||||
```tsx
|
||||
{currentNode.type === 'answer' && (
|
||||
<div className="rounded-lg border border-yellow-500/30 bg-yellow-500/10 p-6 text-center">
|
||||
<p className="text-sm font-medium text-yellow-600 dark:text-yellow-400">
|
||||
This tree contains an unresolved placeholder node. Please contact the tree author to complete it before use.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
**Rationale:** Published trees should never have answer nodes (blocked by validation), but this guard prevents crashes if data is somehow inconsistent. It shows a clear, non-technical message.
|
||||
|
||||
**Verify:** `npm run build` — clean build.
|
||||
|
||||
**Commit:** `fix: add defensive guard for answer nodes in session navigation`
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Final Verification
|
||||
|
||||
### Task 5.1: Full build and test suite
|
||||
|
||||
```bash
|
||||
# Frontend
|
||||
cd frontend && npm run build
|
||||
|
||||
# Backend
|
||||
cd backend && pytest --override-ini="addopts=" -q
|
||||
```
|
||||
|
||||
Both must pass with zero errors.
|
||||
|
||||
### Task 5.2: Manual test checklist
|
||||
|
||||
1. [ ] Open a decision node in canvas editor → card expands → resize browser short → form scrolls, header stays sticky
|
||||
2. [ ] Open a node via modal editor → content scrolls → header/footer fixed
|
||||
3. [ ] Click fullscreen toggle in modal → fills viewport → click again → returns to normal → preference persists on refresh
|
||||
4. [ ] Other modals (step library, custom step, etc.) have NO fullscreen button
|
||||
5. [ ] Hover ⓘ badges on all form fields → tooltip text appears → no always-visible hint paragraphs remain
|
||||
6. [ ] Create a new decision node → type question → type answer labels ("Server", "Desktop") → save
|
||||
7. [ ] Two dashed stub cards appear below the decision node
|
||||
8. [ ] Click "Server" stub → three type buttons appear (Decision / Action / Solution)
|
||||
9. [ ] Click "Action" → stub converts to Action card in expanded editing mode
|
||||
10. [ ] Save draft → succeeds (answer stubs allowed in drafts)
|
||||
11. [ ] Leave an unresolved stub → click Publish → blocked with toast: "Resolve all answer placeholders before publishing."
|
||||
12. [ ] Convert all stubs → Publish → succeeds
|
||||
13. [ ] `npm run build` passes with zero TypeScript errors
|
||||
14. [ ] All backend tests pass
|
||||
|
||||
---
|
||||
|
||||
## Summary of Files Changed
|
||||
|
||||
### New Files
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `frontend/src/components/common/InfoTip.tsx` | Reusable info tooltip badge component |
|
||||
| `frontend/src/components/tree-editor/AnswerStubCard.tsx` | Visual stub card with inline type picker |
|
||||
|
||||
### Modified Files
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `frontend/src/components/tree-editor/TreeCanvasNode.tsx` | Sticky header + scrollable expanded area + answer type guard |
|
||||
| `frontend/src/components/common/Modal.tsx` | `allowFullScreen` prop + expand/collapse toggle + localStorage persistence |
|
||||
| `frontend/src/components/tree-editor/NodeEditorModal.tsx` | Pass `allowFullScreen={true}` |
|
||||
| `frontend/src/components/tree-editor/NodeFormDecision.tsx` | InfoTip tooltips + label-only options (NodePicker removed) |
|
||||
| `frontend/src/components/tree-editor/NodeFormAction.tsx` | InfoTip tooltips |
|
||||
| `frontend/src/components/tree-editor/NodeFormResolution.tsx` | InfoTip tooltips |
|
||||
| `frontend/src/types/tree.ts` | Add `'answer'` to NodeType union |
|
||||
| `frontend/src/components/tree-editor/TreeCanvas.tsx` | Auto-create stubs + render AnswerStubCard + handleSelectAnswerType |
|
||||
| `frontend/src/components/tree-editor/NodeList.tsx` | Add answer type to icon/color maps |
|
||||
| `frontend/src/pages/TreeEditorPage.tsx` | Publish guard with hasAnswerNodes check |
|
||||
| `frontend/src/pages/TreeNavigationPage.tsx` | Runtime defensive guard for answer nodes |
|
||||
| `backend/app/core/tree_validation.py` | Allow answer in drafts, block on publish |
|
||||
| `frontend/src/utils/treeMarkdownSync.ts` | Handle answer type in serializer (if exists) |
|
||||
|
||||
### No REST API Changes Required
|
||||
The tree structure is stored as JSONB — the `answer` type flows through existing create/update endpoints without schema changes. Only the validation layer needs to know about it.
|
||||
441
docs/plans/archive/ResolutionFlow_UX_Deep_Dive_Final_Plan.md
Normal file
441
docs/plans/archive/ResolutionFlow_UX_Deep_Dive_Final_Plan.md
Normal file
@@ -0,0 +1,441 @@
|
||||
# ResolutionFlow UX Deep Dive — Final Merged Implementation Plan
|
||||
|
||||
**Date:** 2026-02-19
|
||||
**Author:** Michael Chihlas
|
||||
**Purpose:** Comprehensive frontend UX sweep merging the original 35-issue audit with Codex-revised plan. Ordered by user impact, covering broken functionality, navigation, shared components, visual consistency, and backend alignment.
|
||||
|
||||
---
|
||||
|
||||
## Locked Decisions
|
||||
|
||||
These decisions are finalized and should not be revisited:
|
||||
|
||||
1. **Canonical post-editor/post-session destination:** `/trees` (the full flow library). `/my-trees` remains available for creator-specific management but is not a navigation target from editors or sessions.
|
||||
2. **Step Library handling:** Add a placeholder route now. Not hidden, not fully built.
|
||||
3. **Cleanup strategy:** Immediate removal of dead code and unused types. No staged deprecation. One final `grep` sweep before each deletion to confirm zero references.
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — Tree Editor Authoring Blockers (Immediate)
|
||||
|
||||
**Goal:** Remove "can't reach bottom / too busy form" friction before the broader UX sweep. This phase was absent from the original audit and added by the Codex review.
|
||||
|
||||
### 0.1 Fix node editor scroll trap and bottom clipping
|
||||
|
||||
**File:** `NodeEditorPanel.tsx`
|
||||
|
||||
- Replace fixed viewport math (`h-[calc(100vh-105px)]`) with `h-full min-h-0`
|
||||
- Keep body as the only scroll container (`min-h-0 flex-1 overflow-y-auto`)
|
||||
- Make footer sticky (`sticky bottom-0`) so Save/Cancel are always reachable
|
||||
- Add `scroll-pb-24` on form body to prevent bottom fields hiding behind footer
|
||||
|
||||
### 0.2 Reduce instruction density in decision/action/resolution forms
|
||||
|
||||
**Files:** `NodeFormDecision.tsx`, `NodeFormAction.tsx`, `NodeFormResolution.tsx`
|
||||
|
||||
- Convert long instructional copy to compact labels + InfoTip tooltips
|
||||
- Keep one short contextual hint per form section
|
||||
- Remove large always-visible prose blocks
|
||||
|
||||
### 0.3 Keep answer-first branching flow explicit
|
||||
|
||||
**File:** `NodeEditorPanel.tsx`
|
||||
|
||||
- Preserve existing behavior: saving decision options without `next_node_id` auto-creates answer stubs
|
||||
- Add UI hint in decision form: "Options become answer placeholders you type later."
|
||||
|
||||
### Phase 0 Verification
|
||||
|
||||
- Open a long decision form → verify full vertical scroll to footer fields/buttons
|
||||
- Focus bottom fields → confirm they are visible (not clipped behind footer)
|
||||
- Decision instructions are compact with info tooltips (no walls of text)
|
||||
- Save a decision with two new options → two answer placeholders are auto-created
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Broken Functionality (Critical)
|
||||
|
||||
**Goal:** Fix features that are actively broken or silently wrong right now.
|
||||
|
||||
### 1.1 Register /step-library route
|
||||
|
||||
Sidebar links to `/step-library` but no route exists — users hit a blank page.
|
||||
|
||||
**Changes:**
|
||||
|
||||
- `router.tsx` — add route with lazy-loaded `StepLibraryPage.tsx` placeholder shell
|
||||
- `StepLibraryPage.tsx` — create placeholder page (e.g., "Step Library — Coming Soon" with consistent layout)
|
||||
- `Sidebar.tsx` — remove `badge="dot"` while placeholder is live (no false "new feature" signal)
|
||||
|
||||
### 1.2 Preserve backend auth error detail in login/register flows
|
||||
|
||||
Login failures show "Request failed with status code 401" instead of "Invalid credentials."
|
||||
|
||||
**File:** `authStore.ts` (lines ~50-54, 63-67)
|
||||
|
||||
- Extract `error.response?.data?.detail` before falling back to generic message
|
||||
- Apply to both login and register error paths
|
||||
|
||||
### 1.3 Fix inverted 4xx toast logic and add 429 handling
|
||||
|
||||
**File:** `client.ts` (lines ~36-43)
|
||||
|
||||
4xx errors with a `detail` message currently suppress the toast entirely (inverted condition). No 429 handler exists.
|
||||
|
||||
**Behavior after fix:**
|
||||
|
||||
| Status | Action |
|
||||
|--------|--------|
|
||||
| 401 | Suppressed (handled by token refresh flow) |
|
||||
| 429 | Always toast: backend detail or "Rate limit exceeded, please retry shortly." |
|
||||
| Other 4xx | Toast `detail` if present, else "Invalid request." |
|
||||
| 5xx | Existing generic server error toast (unchanged) |
|
||||
|
||||
### 1.4 Fix role update payload contract mismatch
|
||||
|
||||
**File:** `accounts.ts` (line ~28)
|
||||
|
||||
Sends `{ role }` but backend schema expects `{ account_role }`. Role changes silently fail with 422.
|
||||
|
||||
- Change to `{ account_role: role }`
|
||||
|
||||
### 1.5 Fix "Repeat Last Session" broken for non-troubleshooting flows
|
||||
|
||||
**File:** `TreeLibraryPage.tsx` (line ~453)
|
||||
|
||||
Currently hardcodes `/trees/:id/navigate` — loses prefill state via safety redirect for procedural/maintenance flows.
|
||||
|
||||
- Use `getSessionResumePath()` instead of hardcoded path
|
||||
|
||||
### Phase 1 Verification
|
||||
|
||||
- Step Library nav opens placeholder page from Sidebar, AppLayout mobile nav, and Quick Launch
|
||||
- Login with bad credentials shows backend detail string (e.g., "Invalid credentials"), not axios error
|
||||
- Force a 429 response and verify toast appears
|
||||
- Change a team member's role in Account Settings → confirm it actually saves (requires `team_admin` user)
|
||||
- Repeat Last Session works correctly for troubleshooting, procedural, and maintenance flow types
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Navigation Correctness (Medium Scope)
|
||||
|
||||
**Goal:** Fix cases where users end up at unexpected pages or lack navigation affordances.
|
||||
|
||||
### 2.1 Standardize editor/session back and exit targets to /trees
|
||||
|
||||
**Files:**
|
||||
|
||||
- `ProceduralEditorPage.tsx` (lines ~86, 92, 157) — change `/my-trees` → `/trees`
|
||||
- `ProceduralNavigationPage.tsx` (lines ~120, 180, 304, 330) — error, cancel, completion, and intake cancel paths all → `/trees`
|
||||
|
||||
### 2.2 Add explicit exit affordance during procedural session execution
|
||||
|
||||
Currently there is no way to leave a procedural session mid-execution except the browser back button.
|
||||
|
||||
**File:** `ProceduralNavigationPage.tsx` (top bar, ~line 345)
|
||||
|
||||
- Add an Exit button to the top bar
|
||||
- If session has progress, exit opens a `ConfirmDialog` before navigating to `/trees`
|
||||
- If no progress, navigate directly
|
||||
|
||||
### 2.3 Remove duplicate account links in global nav
|
||||
|
||||
"Team" and "Settings" both point to `/account`. Same duplication in TopBar dropdown.
|
||||
|
||||
**Files:**
|
||||
|
||||
- `Sidebar.tsx` (lines ~206-207) — consolidate footer to single "Account" item
|
||||
- `TopBar.tsx` — consolidate dropdown to single "Account" entry
|
||||
|
||||
### 2.4 Improve analytics routing for non-owners
|
||||
|
||||
Non-owners click Analytics, hit an access-denied wall, then must click through to personal stats.
|
||||
|
||||
**File:** `TeamAnalyticsPage.tsx` (lines ~48-65)
|
||||
|
||||
- Auto-redirect non-owner/non-admin users to `/analytics/me`
|
||||
- Show an informational toast explaining the redirect (e.g., "Viewing your personal analytics")
|
||||
|
||||
### 2.5 Add feedback before permission redirects in tree editor
|
||||
|
||||
`TreeEditorPage` silently redirects when users lack permission — no feedback shown.
|
||||
|
||||
**File:** `TreeEditorPage.tsx` (lines ~143-146, 157-159)
|
||||
|
||||
- Add `toast.error("You don't have permission to edit this flow")` before each `navigate()` call
|
||||
|
||||
### 2.6 Delete orphaned AdminCategoriesPage
|
||||
|
||||
Not connected to any route, superseded by `admin/GlobalCategoriesPage.tsx`.
|
||||
|
||||
- Delete `AdminCategoriesPage.tsx`
|
||||
- Remove its export from `index.ts` if present
|
||||
|
||||
### Phase 2 Verification
|
||||
|
||||
- Procedural editor Back button → `/trees` (not `/my-trees`)
|
||||
- Procedural session cancel, exit, error, and completion → all route to `/trees`
|
||||
- Exit button is visible during procedural execution and prompts confirmation if session has progress
|
||||
- Sidebar footer shows one "Account" item (not "Team" + "Settings")
|
||||
- Non-owner clicks Analytics → auto-redirects to `/analytics/me` with toast
|
||||
- Unauthorized tree edit attempt → toast shown, then redirect
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Shared Components & Quick Consistency Wins (Medium Scope)
|
||||
|
||||
**Goal:** Build reusable infrastructure and fix small but important consistency issues.
|
||||
|
||||
### 3.1 Create shared Spinner component
|
||||
|
||||
Currently 4 different spinner implementations across 20+ files.
|
||||
|
||||
**Create:** `Spinner.tsx` with `sm | md | lg` sizes, default `border-t-primary`
|
||||
|
||||
**Migrate page-level loading states in:**
|
||||
|
||||
- `ProceduralNavigationPage.tsx`
|
||||
- `ProceduralEditorPage.tsx`
|
||||
- `TreeEditorPage.tsx`
|
||||
- `TreeNavigationPage.tsx`
|
||||
- `SessionHistoryPage.tsx`
|
||||
- `SessionDetailPage.tsx`
|
||||
- `MySharesPage.tsx`
|
||||
- `MyTreesPage.tsx`
|
||||
- `AccountSettingsPage.tsx`
|
||||
- `SharedSessionPage.tsx`
|
||||
- `PageLoader.tsx`
|
||||
|
||||
**Deferred:** Leave tiny inline button spinners for later to avoid churn.
|
||||
|
||||
### 3.2 Promote EmptyState to shared component
|
||||
|
||||
Admin has a well-designed EmptyState; main app uses 3+ ad hoc patterns.
|
||||
|
||||
- Move/create `common/EmptyState.tsx`
|
||||
- Re-export from admin's `EmptyState.tsx` for backward compatibility
|
||||
- Adopt in: `MySharesPage.tsx`, `SessionHistoryPage.tsx`, `TreeLibraryPage.tsx`
|
||||
|
||||
### 3.3 Replace native window.confirm() with design-system ConfirmDialog
|
||||
|
||||
3 places use native browser dialogs that break the design system.
|
||||
|
||||
**Files:**
|
||||
|
||||
- `MySharesPage.tsx` (line ~81)
|
||||
- `NodeEditorPanel.tsx` (line ~87)
|
||||
- `FolderSidebar.tsx` (line ~286)
|
||||
|
||||
### 3.4 Fix sidebar optimistic unpin bug
|
||||
|
||||
State is removed immediately even if API call fails — the flow disappears permanently until page refresh.
|
||||
|
||||
**File:** `Sidebar.tsx` (lines ~105-113)
|
||||
|
||||
- Move `setPinnedFlows` update to after successful `await` (not before)
|
||||
|
||||
### 3.5 Fix PinnedFlow.tree_type missing 'maintenance'
|
||||
|
||||
Maintenance flows can be pinned but navigation will use the wrong path.
|
||||
|
||||
**Files:**
|
||||
|
||||
- `pinnedFlows.ts` (line ~7) — add `'maintenance'` to the `PinnedFlow.tree_type` union type
|
||||
- `PinnedFlowsSection.tsx` — validate icon/path logic via `getTreeNavigatePath` handles maintenance correctly
|
||||
|
||||
### Phase 3 Verification
|
||||
|
||||
- All page-level loading states use the shared `Spinner` component (visual consistency)
|
||||
- Empty states in MyShares, SessionHistory, TreeLibrary use the shared `EmptyState` component
|
||||
- Deleting a share, removing a node, removing a folder → all show styled dialog (not browser native)
|
||||
- Unpin a flow while network is down → flow should NOT disappear (reverts on failure)
|
||||
- Pin a maintenance flow → clicking it navigates to the correct path
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Visual Consistency Sweep (Large Scope — Many Small Changes)
|
||||
|
||||
**Goal:** Design system compliance fixes. Each change is small but there are many files.
|
||||
|
||||
### 4.1 Fix Sonner toast styling
|
||||
|
||||
The `richColors` prop overrides custom CSS with garish built-in green/red backgrounds. Existing custom CSS in `index.css` (lines ~201-251) already defines themed toasts (`bg-card`, `border-border`, colored border accents) but `richColors` overrides them.
|
||||
|
||||
**File:** `main.tsx`
|
||||
|
||||
- Remove `richColors` prop
|
||||
- Add `visibleToasts={3}` and `gap={8}`
|
||||
- Keep existing custom toast CSS in `index.css`; patch only if needed to ensure themed styles fully apply
|
||||
|
||||
### 4.2 Typography: Add font-heading to all page H1s
|
||||
|
||||
Missing from approximately half the pages.
|
||||
|
||||
**Files to update:**
|
||||
|
||||
- `MyTreesPage.tsx`
|
||||
- `TeamAnalyticsPage.tsx`
|
||||
- `MyAnalyticsPage.tsx`
|
||||
- `FeedbackPage.tsx`
|
||||
- `AccountSettingsPage.tsx`
|
||||
- `TeamCategoriesPage.tsx`
|
||||
- `admin/PageHeader.tsx`
|
||||
|
||||
### 4.3 Typography: Add font-label to TagBadges component
|
||||
|
||||
Design system requires Outfit font for tags/badges.
|
||||
|
||||
**File:** `TagBadges.tsx`
|
||||
|
||||
### 4.4 Fix hardcoded light-mode button in TeamAnalyticsPage
|
||||
|
||||
Only hardcoded `bg-white text-black` button in the app — breaks in dark mode.
|
||||
|
||||
**File:** `TeamAnalyticsPage.tsx` (line ~59)
|
||||
|
||||
- Replace with `bg-gradient-brand text-white`
|
||||
|
||||
### 4.5 Fix non-standard focus ring tokens
|
||||
|
||||
Analytics selects use `focus:ring-ring` instead of the standard token.
|
||||
|
||||
**Files:** `TeamAnalyticsPage.tsx`, `MyAnalyticsPage.tsx`
|
||||
|
||||
- Change `focus:ring-ring` → `focus:ring-primary/20`
|
||||
|
||||
### 4.6 Replace deprecated glass-stat style
|
||||
|
||||
**File:** `AccountSettingsPage.tsx` (line ~588)
|
||||
|
||||
- Replace `glass-stat` with `bg-card border border-border`
|
||||
|
||||
### 4.7 Standardize container/padding on analytics pages
|
||||
|
||||
Missing responsive padding.
|
||||
|
||||
**Files:** `TeamAnalyticsPage.tsx`, `MyAnalyticsPage.tsx`
|
||||
|
||||
- Add `container mx-auto px-4 py-6 sm:px-6 sm:py-8`
|
||||
|
||||
### Phase 4 Verification
|
||||
|
||||
- Toast colors/borders follow custom theme (not Sonner rich presets) — check success, error, and info toasts
|
||||
- All page H1s use `font-heading` (Plus Jakarta Sans)
|
||||
- Tag badges use `font-label` (Outfit)
|
||||
- TeamAnalytics CTA button renders correctly in both light and dark mode
|
||||
- Focus rings on analytics selects use subtle `primary/20` glow
|
||||
- No `glass-stat` class remains in the codebase
|
||||
- Analytics pages have consistent container spacing matching rest of app
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — Backend Alignment & Cleanup (Immediate Removals)
|
||||
|
||||
**Goal:** API contract fixes and dead code removal. All removals are immediate (per locked decision #3), with a final `grep` sweep before each deletion.
|
||||
|
||||
### 5.1 Remove non-functional drafts toggle from library UI
|
||||
|
||||
Backend has no `include_drafts` parameter — the toggle does nothing.
|
||||
|
||||
**Files:**
|
||||
|
||||
- `TreeLibraryPage.tsx` — remove `showDrafts` state and drafts filter UI
|
||||
- `tree.ts` (`TreeFilters` type) — remove `include_drafts` field
|
||||
|
||||
### 5.2 Align invite types with backend schema
|
||||
|
||||
`invited_by_id` and `accepted_by_id` not in backend response — always `undefined`.
|
||||
|
||||
**File:** `account.ts` — remove `invited_by_id` and `accepted_by_id` from `AccountInvite` type
|
||||
|
||||
### 5.3 Remove dead/unused client code
|
||||
|
||||
Before deleting each item, run a global `grep` to confirm zero consumers:
|
||||
|
||||
| Item | File | What to Remove |
|
||||
|------|------|----------------|
|
||||
| `pinnedFlowsApi.pin()` | `pinnedFlows.ts` | Dead method (never called) |
|
||||
| `pinnedFlowsApi.reorder()` | `pinnedFlows.ts` | Dead method (never called) |
|
||||
| `treesApi.getSharedTree()` | `trees.ts` | Dead method (no route/consumer) |
|
||||
| `SessionListResponse` | `sessions.ts` | Unused type |
|
||||
| `RatingCreate.is_verified_use` | `step.ts` | Field ignored by backend |
|
||||
| `AdminCategoriesPage.tsx` | — | Orphaned file + export (if not already deleted in Phase 2) |
|
||||
|
||||
### 5.4 Add session list truncation indicator
|
||||
|
||||
Session history silently truncates at the backend limit with no indication to the user.
|
||||
|
||||
**File:** `SessionHistoryPage.tsx`
|
||||
|
||||
- Request `size=51` from backend
|
||||
- If result length is 51: show "Showing first 50 sessions" indicator, render only first 50
|
||||
- If result length ≤ 50: show "Showing X sessions"
|
||||
- No backend API contract changes required (frontend lookahead strategy)
|
||||
|
||||
### Phase 5 Verification
|
||||
|
||||
- No drafts toggle visible in TreeLibrary UI
|
||||
- `grep -r "include_drafts" frontend/src/` returns zero results
|
||||
- `grep -r "invited_by_id\|accepted_by_id" frontend/src/` returns zero results for `AccountInvite` usage
|
||||
- `grep` for each removed method/type confirms zero references
|
||||
- Session history shows truncation indicator at 50+ results
|
||||
- Session history shows "Showing X sessions" at fewer than 50 results
|
||||
- `cd frontend && npm run build` passes with zero errors
|
||||
|
||||
---
|
||||
|
||||
## Items Explicitly Deferred
|
||||
|
||||
| Item | Reason |
|
||||
|------|--------|
|
||||
| Migrate 14+ custom modals to shared Modal component | Very high effort, low breakage risk. Migrate incrementally on new work. |
|
||||
| Standardize input border radius app-wide | Cosmetic, low user impact |
|
||||
| Standardize icon sizing method (`className` vs `size` prop) | No visual difference |
|
||||
| Session pagination (full load-more) | Larger feature, beyond UX sweep scope |
|
||||
| Inline button spinners | Leave for later to avoid churn (Phase 3 note) |
|
||||
| Full Step Library feature build | Intentionally placeholder-only this cycle |
|
||||
|
||||
---
|
||||
|
||||
## Public APIs / Interfaces / Types Changed
|
||||
|
||||
**Frontend routing:**
|
||||
|
||||
- Add new route `/step-library` in `router.tsx`
|
||||
|
||||
**Type/interface updates:**
|
||||
|
||||
- `TreeFilters` in `tree.ts`: remove `include_drafts`
|
||||
- `AccountInvite` in `account.ts`: remove `invited_by_id`, `accepted_by_id`
|
||||
- `PinnedFlow.tree_type` in `pinnedFlows.ts`: add `'maintenance'`
|
||||
- `RatingCreate` in `step.ts`: remove `is_verified_use`
|
||||
- Remove unused `SessionListResponse` in `sessions.ts`
|
||||
|
||||
**Shared component surface:**
|
||||
|
||||
- Add `Spinner` component in `Spinner.tsx`
|
||||
- Add common `EmptyState` component in `EmptyState.tsx`
|
||||
|
||||
---
|
||||
|
||||
## Cross-Phase Verification Checklist
|
||||
|
||||
Run after each phase is complete:
|
||||
|
||||
1. **Build:** `cd frontend && npm run build` — must pass with zero errors
|
||||
2. **Navigation:** Test all sidebar items, back buttons, editor → library flow, procedural navigation exit
|
||||
3. **Auth:** Intentional wrong password → shows backend error detail
|
||||
4. **Role change:** Test in Account Settings (requires `team_admin` user)
|
||||
5. **Visual spot-check:** H1 fonts, spinner consistency, empty states, toast styling, dark mode
|
||||
6. **Grep sweep:** Before merging each phase, confirm no dead references remain for removed items
|
||||
|
||||
---
|
||||
|
||||
## Assumptions
|
||||
|
||||
- `/trees` is the canonical destination for broad flow browsing and all post-task returns
|
||||
- `/my-trees` remains available but is not a default navigation target
|
||||
- Step Library is placeholder-only this cycle — no full feature work
|
||||
- No backend API contract changes are required for any item in this plan
|
||||
- Session truncation uses the frontend lookahead strategy (`size=51`)
|
||||
- All cleanup is immediate with pre-deletion reference verification
|
||||
581
docs/plans/archive/ai-guided-flow-creation.md
Normal file
581
docs/plans/archive/ai-guided-flow-creation.md
Normal file
@@ -0,0 +1,581 @@
|
||||
# AI-Guided Flow Creation — Design Document
|
||||
|
||||
> **Date:** 2026-02-12
|
||||
> **Status:** Draft
|
||||
> **Phase:** 3 (AI Intelligence)
|
||||
> **Dependencies:** Global Categories, Flow Editor, Plan Limits, Session Tracking
|
||||
> **Estimated Effort:** 3-4 weeks (backend + frontend + prompt engineering)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
AI-Guided Flow Creation is an interactive wizard that helps engineers build troubleshooting and procedural flows through a structured conversation. Rather than generating a complete flow from a single description (expensive, low-quality, black box), the wizard asks targeted questions at each stage, using the engineer's domain expertise to shape the output while AI fills in the details.
|
||||
|
||||
**Core Principle:** The engineer knows the problem domain. The AI knows how to structure it. The wizard brings both together.
|
||||
|
||||
**Key Differentiator:** This is NOT a "describe your problem and AI builds everything" feature. It's a collaborative creation tool where AI assists at specific, bounded points — keeping costs predictable, output quality high, and the engineer in control.
|
||||
|
||||
---
|
||||
|
||||
## Why This Approach
|
||||
|
||||
| Approach | Cost/Flow | Quality | Engineer Control | Reusability |
|
||||
|---|---|---|---|---|
|
||||
| Full AI generation (one-shot) | ~$0.03 | Medium (needs heavy editing) | None until review | None |
|
||||
| **Guided wizard (this design)** | **~$0.01-0.02** | **High (shaped by engineer)** | **At every step** | **High (cached prompts)** |
|
||||
| Manual creation only | $0.00 | Varies | Full | None |
|
||||
|
||||
The guided approach wins on every axis because:
|
||||
|
||||
1. **Smaller, targeted API calls** replace one large generation call
|
||||
2. **Fixed question patterns** enable aggressive prompt caching (90% input savings)
|
||||
3. **Engineer input at each stage** means less post-generation editing
|
||||
4. **Structured data collection** before any AI call means better prompts and better output
|
||||
|
||||
---
|
||||
|
||||
## User Flow
|
||||
|
||||
### Stage 1: Foundation (No AI — Pure UI)
|
||||
|
||||
The user provides structured metadata that will inform all subsequent AI calls.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ CREATE A NEW FLOW │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Flow Type: │
|
||||
│ ○ Troubleshooting (diagnostic branching — "what's wrong?")│
|
||||
│ ○ Procedure (step-by-step — "how do I set this up?") │
|
||||
│ │
|
||||
│ Category: │
|
||||
│ [▼ Networking ] │
|
||||
│ │
|
||||
│ Flow Name: │
|
||||
│ [Printer Not Printing ] │
|
||||
│ │
|
||||
│ Brief Description: │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ User reports print jobs stuck in queue, printer │ │
|
||||
│ │ shows as offline or errors when printing │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Target Environment (optional): │
|
||||
│ □ Windows □ macOS □ Linux │
|
||||
│ □ Cloud/SaaS □ On-Premises □ Hybrid │
|
||||
│ │
|
||||
│ [Next →] │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Data collected:** flow_type, category_id, name, description, environment tags.
|
||||
**AI cost:** $0.00
|
||||
|
||||
---
|
||||
|
||||
### Stage 2: Structure Scaffolding (Light AI)
|
||||
|
||||
Based on Stage 1 input, AI suggests the top-level structure. The user confirms, removes, or adds items.
|
||||
|
||||
**For Troubleshooting flows — AI suggests initial symptom branches:**
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ WHAT SYMPTOMS WOULD A USER REPORT? │
|
||||
│ │
|
||||
│ Based on "Printer Not Printing", here are common starting │
|
||||
│ points. Check the ones that apply, add your own: │
|
||||
│ │
|
||||
│ AI Suggestions: │
|
||||
│ ☑ Print jobs stuck in queue / never print │
|
||||
│ ☑ Printer shows offline │
|
||||
│ ☑ Prints but output is garbled or wrong │
|
||||
│ ☐ Printer not found / can't add printer │
|
||||
│ ☑ Specific application can't print (others can) │
|
||||
│ │
|
||||
│ + Add your own symptom: [____________________________] │
|
||||
│ │
|
||||
│ [← Back] [Next →] │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**For Procedure flows — AI suggests major phases:**
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ WHAT ARE THE MAJOR PHASES? │
|
||||
│ │
|
||||
│ Based on "New Server Build", here are typical phases. │
|
||||
│ Check the ones that apply, reorder, add your own: │
|
||||
│ │
|
||||
│ AI Suggestions: │
|
||||
│ ☑ 1. Pre-requisites & Planning │
|
||||
│ ☑ 2. OS Installation & Base Config │
|
||||
│ ☑ 3. Network Configuration │
|
||||
│ ☑ 4. Domain Join & Security │
|
||||
│ ☑ 5. Role/Application Installation │
|
||||
│ ☑ 6. Verification & Handoff │
|
||||
│ │
|
||||
│ + Add a phase: [____________________________] │
|
||||
│ │
|
||||
│ [← Back] [Next →] │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**API call:** 1 call to Haiku 4.5. Input: system prompt (cached) + stage 1 metadata. Output: 5-8 suggestions as JSON array.
|
||||
**Estimated cost:** ~$0.003-0.005
|
||||
|
||||
---
|
||||
|
||||
### Stage 3: Branch/Step Detail (Light AI, per branch)
|
||||
|
||||
For each item the user selected in Stage 2, AI suggests the diagnostic steps or sub-steps. The user processes one branch/phase at a time.
|
||||
|
||||
**For Troubleshooting — diagnostic steps per symptom:**
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ BRANCH: "Print jobs stuck in queue" │
|
||||
│ │
|
||||
│ What steps should an engineer follow for this symptom? │
|
||||
│ │
|
||||
│ AI Suggestions: │
|
||||
│ ☑ Check Print Spooler service status │
|
||||
│ Action: Run Get-Service Spooler — restart if stopped │
|
||||
│ │
|
||||
│ ☑ Clear the print queue │
|
||||
│ Action: Stop spooler, delete files in │
|
||||
│ C:\Windows\System32\spool\PRINTERS, restart spooler │
|
||||
│ │
|
||||
│ ☑ Verify printer port and driver │
|
||||
│ Decision: Is the printer networked or USB? │
|
||||
│ → Networked: ping printer IP, check port config │
|
||||
│ → USB: check cable, try different port │
|
||||
│ │
|
||||
│ ☑ Test with different application │
|
||||
│ Decision: Does it print from Notepad? │
|
||||
│ → Yes: Application-specific issue │
|
||||
│ → No: System-level print problem │
|
||||
│ │
|
||||
│ + Add your own step: [____________________________] │
|
||||
│ │
|
||||
│ [✎ Edit any step] [← Back] [Next →] │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**For Procedure — detailed steps per phase:**
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ PHASE: "OS Installation & Base Config" │
|
||||
│ │
|
||||
│ What steps should be performed in this phase? │
|
||||
│ │
|
||||
│ AI Suggestions: │
|
||||
│ ☑ Boot from installation media │
|
||||
│ Action: Mount ISO / insert USB, boot to BIOS, set │
|
||||
│ boot order │
|
||||
│ │
|
||||
│ ☑ Configure disk partitions │
|
||||
│ Action: Create partitions per standard │
|
||||
│ 📝 Record: "Partition layout used" │
|
||||
│ │
|
||||
│ ☑ Set hostname │
|
||||
│ Action: Follow naming convention: SITE-ROLE-## │
|
||||
│ 📝 Record: "Hostname assigned" │
|
||||
│ │
|
||||
│ ☑ Configure local admin account │
|
||||
│ Action: Set password per policy, disable built-in │
|
||||
│ Administrator │
|
||||
│ │
|
||||
│ ☑ Install Windows Updates │
|
||||
│ Action: Run full update cycle, reboot, repeat until │
|
||||
│ clean │
|
||||
│ │
|
||||
│ + Add your own step: [____________________________] │
|
||||
│ │
|
||||
│ [← Back] [Next →] │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Note the 📝 Record fields** — for procedure flows, AI can suggest which steps should capture data (hostname, IP, etc.). This feeds directly into the session documentation.
|
||||
|
||||
**API call:** 1 call per branch/phase. Input: system prompt (cached) + stage 1 metadata + branch name + context from other branches. Output: 3-6 detailed steps as JSON.
|
||||
**Estimated cost:** ~$0.003-0.005 per branch. 5 branches = ~$0.015-0.025
|
||||
|
||||
---
|
||||
|
||||
### Stage 4: Review & Refine (No AI)
|
||||
|
||||
The complete flow is assembled and shown in the existing tree/flow editor. User can:
|
||||
|
||||
- Rearrange nodes
|
||||
- Edit text on any step
|
||||
- Add/remove branches
|
||||
- Preview the flow as an end-user would see it
|
||||
- Save as draft or publish
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ REVIEW YOUR FLOW │
|
||||
│ │
|
||||
│ "Printer Not Printing" — Troubleshooting Flow │
|
||||
│ Category: Networking | 5 branches | 23 steps │
|
||||
│ │
|
||||
│ [Visual tree/flow editor — existing component] │
|
||||
│ │
|
||||
│ ┌─ Root: What is the printing issue? │
|
||||
│ ├── Jobs stuck in queue (5 steps) │
|
||||
│ ├── Printer shows offline (4 steps) │
|
||||
│ ├── Garbled output (3 steps) │
|
||||
│ ├── App-specific failure (4 steps) │
|
||||
│ └── Can't add printer (4 steps) │
|
||||
│ │
|
||||
│ [Save as Draft] [Publish] │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**AI cost:** $0.00
|
||||
|
||||
---
|
||||
|
||||
## Total Cost Per Flow
|
||||
|
||||
| Stage | AI Calls | Estimated Cost |
|
||||
|---|---|---|
|
||||
| 1. Foundation | 0 | $0.000 |
|
||||
| 2. Structure Scaffolding | 1 | $0.003-0.005 |
|
||||
| 3. Branch/Step Detail | 1 per branch (avg 5) | $0.015-0.025 |
|
||||
| 4. Review & Refine | 0 | $0.000 |
|
||||
| **Total** | **~6 calls** | **$0.018-0.030** |
|
||||
|
||||
**With prompt caching active** (system prompt cached across all users):
|
||||
- First user of the day: ~$0.025 (full system prompt cost)
|
||||
- Subsequent users: ~$0.012-0.018 (90% savings on system prompt input)
|
||||
|
||||
---
|
||||
|
||||
## Cost Projections By Plan
|
||||
|
||||
### Per-User Monthly Cost to ResolutionFlow
|
||||
|
||||
| Plan | AI Flows/Month | Est. Cost/Month | Subscription Price | Margin |
|
||||
|---|---|---|---|---|
|
||||
| Free | 2 | $0.04-0.06 | $0 | -$0.06 (acquisition cost) |
|
||||
| Pro | 30 (1/day) | $0.54-0.90 | $29 | 97%+ |
|
||||
| Pro (heavy) | 300 (10/day) | $5.40-9.00 | $29 | 69-81% |
|
||||
| Team (per user) | 300 (10/day) | $5.40-9.00 | ~$20/seat | 55-73% |
|
||||
|
||||
### At Scale (Monthly Platform Cost)
|
||||
|
||||
| Scenario | Users | Flows/Month | Monthly AI Cost |
|
||||
|---|---|---|---|
|
||||
| Beta (15 users) | 15 | ~150 | $2.70-4.50 |
|
||||
| Early growth | 100 | ~1,500 | $27-45 |
|
||||
| Scaling | 500 | ~7,500 | $135-225 |
|
||||
| Target ($10K MRR) | ~400 | ~6,000 | $108-180 |
|
||||
|
||||
**Bottom line:** Even at 10/day per user, AI costs stay under 30% of subscription revenue. At typical usage (1-3/day), AI costs are negligible — under 5% of revenue.
|
||||
|
||||
---
|
||||
|
||||
## Plan Limits Integration
|
||||
|
||||
### New Fields for plan_limits Table
|
||||
|
||||
```sql
|
||||
ALTER TABLE plan_limits
|
||||
ADD COLUMN ai_flows_per_month INTEGER DEFAULT NULL,
|
||||
ADD COLUMN ai_calls_per_flow INTEGER DEFAULT NULL;
|
||||
```
|
||||
|
||||
| Plan | ai_flows_per_month | ai_calls_per_flow |
|
||||
|---|---|---|
|
||||
| Free | 2 | 6 |
|
||||
| Pro | 50 | 10 |
|
||||
| Team | 200 (per account) | 10 |
|
||||
|
||||
`ai_calls_per_flow` caps how many branches can get AI suggestions per flow. This prevents a user from creating a 50-branch monstrosity that costs $0.25 per generation.
|
||||
|
||||
### Tracking Table
|
||||
|
||||
```sql
|
||||
CREATE TABLE ai_usage_log (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
account_id UUID NOT NULL REFERENCES accounts(id),
|
||||
user_id UUID NOT NULL REFERENCES users(id),
|
||||
usage_type VARCHAR(50) NOT NULL, -- 'flow_scaffold', 'branch_detail', 'branch_suggest'
|
||||
model VARCHAR(100) NOT NULL, -- 'claude-haiku-4-5'
|
||||
input_tokens INTEGER NOT NULL,
|
||||
output_tokens INTEGER NOT NULL,
|
||||
estimated_cost_usd NUMERIC(10, 6) NOT NULL,
|
||||
flow_id UUID REFERENCES trees(id),
|
||||
metadata JSONB DEFAULT '{}', -- stage, branch name, etc.
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_ai_usage_account_month
|
||||
ON ai_usage_log (account_id, created_at);
|
||||
```
|
||||
|
||||
This gives you:
|
||||
- Per-account usage tracking for limit enforcement
|
||||
- Cost visibility in admin dashboard
|
||||
- Data to optimize prompts over time (which calls use the most tokens?)
|
||||
|
||||
---
|
||||
|
||||
## Backend Implementation
|
||||
|
||||
### New Files
|
||||
|
||||
```
|
||||
app/
|
||||
services/
|
||||
ai_flow_generator.py # Core AI service — prompt construction, API calls, parsing
|
||||
api/
|
||||
endpoints/
|
||||
ai_generation.py # API endpoints for wizard stages
|
||||
core/
|
||||
ai_config.py # Model selection, pricing constants, feature flag checks
|
||||
```
|
||||
|
||||
### API Endpoints
|
||||
|
||||
```
|
||||
POST /api/v1/ai/flow/scaffold
|
||||
Body: { flow_type, category_id, name, description, environment_tags }
|
||||
Returns: { suggestions: ["symptom1", "symptom2", ...] }
|
||||
Auth: Required, plan limit checked
|
||||
AI: 1 Haiku call
|
||||
|
||||
POST /api/v1/ai/flow/branch-detail
|
||||
Body: { flow_type, category_id, name, description, branch_name, existing_branches }
|
||||
Returns: { steps: [ { type, title, action/question, help_text, sub_branches?, record_fields? } ] }
|
||||
Auth: Required, plan limit checked, per-flow call limit checked
|
||||
AI: 1 Haiku call
|
||||
|
||||
POST /api/v1/ai/flow/assemble
|
||||
Body: { flow_type, category_id, name, description, branches: [ { name, steps: [...] } ] }
|
||||
Returns: { tree_structure: { ... } } // Valid tree structure ready for create_tree
|
||||
Auth: Required
|
||||
AI: 0 calls — pure assembly logic, runs through normalize_node + validation
|
||||
```
|
||||
|
||||
### Prompt Architecture
|
||||
|
||||
The system prompt is the same across all users and all calls within a stage. This is critical for prompt caching.
|
||||
|
||||
**System Prompt (Stage 2 — Troubleshooting):**
|
||||
```
|
||||
You are a senior MSP engineer helping build troubleshooting decision trees.
|
||||
Given a problem description and category, suggest 4-7 initial symptom branches
|
||||
that a support engineer would encounter.
|
||||
|
||||
Rules:
|
||||
- Each suggestion should be a distinct, common symptom (not overlapping)
|
||||
- Order from most common to least common
|
||||
- Use plain language that a Tier 1 engineer would understand
|
||||
- Focus on observable symptoms, not root causes
|
||||
- Respond in JSON format only: { "suggestions": ["symptom1", "symptom2"] }
|
||||
```
|
||||
|
||||
**System Prompt (Stage 2 — Procedure):**
|
||||
```
|
||||
You are a senior MSP engineer helping build procedural checklists.
|
||||
Given a project description and category, suggest 4-8 major phases
|
||||
that the project should follow.
|
||||
|
||||
Rules:
|
||||
- Phases should be in logical execution order
|
||||
- Each phase should be a distinct stage of work
|
||||
- Use standard MSP/IT terminology
|
||||
- Respond in JSON format only: { "suggestions": ["phase1", "phase2"] }
|
||||
```
|
||||
|
||||
**System Prompt (Stage 3 — Branch Detail):**
|
||||
```
|
||||
You are a senior MSP engineer helping build troubleshooting steps.
|
||||
Given a symptom/branch name and context, suggest 3-6 diagnostic steps.
|
||||
|
||||
For each step, provide:
|
||||
- type: "action" (do something), "decision" (yes/no check), or "solution" (resolution)
|
||||
- title: short step name
|
||||
- content: detailed instructions including commands where relevant
|
||||
- sub_branches: (for decisions only) what the yes/no paths look like
|
||||
- record_fields: (for procedures only) data the engineer should document
|
||||
|
||||
Rules:
|
||||
- Include specific commands (PowerShell preferred for Windows environments)
|
||||
- Action steps should be concrete and actionable
|
||||
- Decision steps should have clear yes/no outcomes
|
||||
- End branches with solution nodes where possible
|
||||
- Respond in JSON format only
|
||||
```
|
||||
|
||||
**Caching strategy:** System prompts are sent as cacheable prefixes. With a 5-minute TTL, any user hitting the wizard within 5 minutes of another user gets 90% savings on input tokens. During active hours, this cache will almost always be warm.
|
||||
|
||||
---
|
||||
|
||||
## Frontend Implementation
|
||||
|
||||
### New Components
|
||||
|
||||
```
|
||||
frontend/src/
|
||||
pages/
|
||||
FlowWizardPage.tsx # Main wizard container with stage navigation
|
||||
components/
|
||||
wizard/
|
||||
WizardStageFoundation.tsx # Stage 1: type, category, name, description
|
||||
WizardStageScaffold.tsx # Stage 2: AI suggestions with checkboxes
|
||||
WizardStageBranchDetail.tsx # Stage 3: per-branch step suggestions
|
||||
WizardStageReview.tsx # Stage 4: assembled flow preview
|
||||
WizardProgress.tsx # Stage indicator bar
|
||||
SuggestionCheckbox.tsx # Reusable AI suggestion with check/uncheck
|
||||
CustomItemInput.tsx # "Add your own" input field
|
||||
api/
|
||||
aiGeneration.ts # API client for AI endpoints
|
||||
hooks/
|
||||
useAiWizard.ts # State management for wizard flow
|
||||
```
|
||||
|
||||
### Wizard State Management
|
||||
|
||||
```typescript
|
||||
interface WizardState {
|
||||
// Stage 1
|
||||
flowType: 'troubleshooting' | 'procedure';
|
||||
categoryId: string;
|
||||
name: string;
|
||||
description: string;
|
||||
environmentTags: string[];
|
||||
|
||||
// Stage 2
|
||||
scaffoldSuggestions: string[]; // AI-generated
|
||||
selectedBranches: string[]; // User-confirmed
|
||||
customBranches: string[]; // User-added
|
||||
|
||||
// Stage 3
|
||||
branchDetails: Record<string, BranchStep[]>; // Per-branch steps
|
||||
currentBranchIndex: number;
|
||||
|
||||
// Stage 4
|
||||
assembledStructure: TreeStructure | null;
|
||||
|
||||
// Meta
|
||||
currentStage: 1 | 2 | 3 | 4;
|
||||
aiCallsUsed: number;
|
||||
isGenerating: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
### UX Considerations
|
||||
|
||||
1. **Loading states:** AI calls take 2-5 seconds. Show a subtle loading animation with context ("Analyzing common symptoms..."), not a spinner.
|
||||
|
||||
2. **Graceful degradation:** If an AI call fails, show an empty state with "Add your own" prominent. The wizard should never be blocked by AI failure.
|
||||
|
||||
3. **Edit-in-place:** Users should be able to click any AI suggestion to edit the text before accepting it.
|
||||
|
||||
4. **Skip AI:** Every stage should have a "Skip suggestions, I'll add my own" option. Power users may want to build manually but still benefit from the wizard structure.
|
||||
|
||||
5. **Mobile consideration:** The wizard should work on tablet at minimum. MSP engineers may use it on-site with an iPad.
|
||||
|
||||
---
|
||||
|
||||
## Environment & Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```
|
||||
# .env / Railway
|
||||
ANTHROPIC_API_KEY=sk-ant-...
|
||||
AI_GENERATION_MODEL=claude-haiku-4-5-20251001
|
||||
AI_GENERATION_ENABLED=true # Kill switch
|
||||
AI_MAX_RETRIES=2
|
||||
AI_REQUEST_TIMEOUT=30 # seconds
|
||||
```
|
||||
|
||||
### Feature Flag Integration
|
||||
|
||||
Use existing feature_flags system:
|
||||
|
||||
```json
|
||||
{
|
||||
"ai_flow_generation": {
|
||||
"enabled": true,
|
||||
"plans": ["pro", "team"],
|
||||
"beta_override": true // Allow specific free users during beta
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rollout Strategy
|
||||
|
||||
### Phase 3a: Internal Testing (Week 1)
|
||||
|
||||
- Backend endpoints + prompt engineering
|
||||
- Test with admin account only
|
||||
- Iterate on prompt quality — this is where most time goes
|
||||
- Validate cost estimates against real API usage
|
||||
|
||||
### Phase 3b: Beta Testing (Week 2-3)
|
||||
|
||||
- Frontend wizard UI
|
||||
- Enable for beta testers (feature flag)
|
||||
- Collect feedback on suggestion quality
|
||||
- Monitor ai_usage_log for actual costs
|
||||
|
||||
### Phase 3c: General Availability (Week 4)
|
||||
|
||||
- Enable for all Pro/Team plans
|
||||
- Free tier gets limited access (2/month)
|
||||
- Admin dashboard shows AI cost metrics
|
||||
- Plan limit enforcement active
|
||||
|
||||
---
|
||||
|
||||
## Future Enhancements (Not in Initial Scope)
|
||||
|
||||
These are explicitly deferred to keep initial scope manageable:
|
||||
|
||||
1. **AI-suggested branch improvements** — "Based on 15 unresolved sessions on this branch, AI suggests adding these steps." Requires session outcome tracking (separate feature).
|
||||
|
||||
2. **AI step refinement** — User selects an existing step and says "make this more detailed" or "add the PowerShell commands." Single-step AI call, very cheap.
|
||||
|
||||
3. **Template flows from wizard patterns** — If many users create similar flows (e.g., "printer troubleshooting"), cache the assembled structure as a template. Future users get instant results with zero AI cost.
|
||||
|
||||
4. **Multi-language generation** — Same wizard, but AI generates steps in the user's language. Prompt change only, no architecture change.
|
||||
|
||||
5. **AI from session data** — After enough sessions are tracked, use anonymized session paths to improve AI suggestions. "80% of engineers check the spooler service first" → AI learns to suggest that first.
|
||||
|
||||
---
|
||||
|
||||
## Key Risks & Mitigations
|
||||
|
||||
| Risk | Impact | Mitigation |
|
||||
|---|---|---|
|
||||
| Anthropic API outage | Wizard Stage 2-3 blocked | Graceful fallback to manual entry; AI is enhancement, not requirement |
|
||||
| Poor suggestion quality | Users lose trust in feature | Extensive prompt testing before launch; user can always edit/override |
|
||||
| Cost overrun from heavy usage | Margin erosion | Per-account monthly limits in plan_limits; ai_usage_log for monitoring |
|
||||
| Prompt injection via user input | Unexpected AI output | Sanitize user input; structured JSON output parsing; never execute AI output as code |
|
||||
| Haiku model deprecated | Service interruption | Model name in env variable, not hardcoded; swap to successor model |
|
||||
| Users treat AI as authoritative | Bad troubleshooting steps followed blindly | "Draft" status by default; review stage required; clear "AI-suggested" labeling |
|
||||
|
||||
---
|
||||
|
||||
## Success Metrics
|
||||
|
||||
| Metric | Target | How to Measure |
|
||||
|---|---|---|
|
||||
| Wizard completion rate | >70% of starts finish Stage 4 | Track stage progression in analytics |
|
||||
| AI suggestion acceptance rate | >50% of suggestions kept | Compare suggestions shown vs. selected |
|
||||
| Time to create flow (wizard vs manual) | 60%+ faster with wizard | Compare creation timestamps |
|
||||
| Post-wizard editing rate | <30% of nodes edited after assembly | Compare Stage 4 structure vs. published version |
|
||||
| Cost per flow | <$0.03 average | ai_usage_log aggregation |
|
||||
| User satisfaction | Positive feedback from beta | Direct feedback + usage retention |
|
||||
101
docs/plans/archive/deferred-procedural-features.md
Normal file
101
docs/plans/archive/deferred-procedural-features.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# Deferred Procedural Flow Features
|
||||
|
||||
> **Created:** February 14, 2026
|
||||
> **Status:** Backlog — prioritize based on engineer feedback
|
||||
> **Related:** Procedural Flows v1 shipped (Phases 1-4 complete, archived)
|
||||
|
||||
---
|
||||
|
||||
## Tier 1 — High Impact / Most Requested
|
||||
|
||||
### Conditional Steps
|
||||
Show or hide steps based on intake form values. For example, skip "Configure DNS Forwarders" if the intake form says the DC won't be the primary DNS server.
|
||||
|
||||
- Requires: step-level `condition` field (e.g., `{ field: "is_primary_dns", operator: "equals", value: "yes" }`)
|
||||
- UI: condition builder in StepEditor, runtime evaluation in ProceduralNavigationPage
|
||||
- Complexity: Medium
|
||||
|
||||
### Sub-Checklists Within Steps
|
||||
Break a single step into smaller checkable items. For instance, "Install Windows Features" could have sub-items: AD DS, DNS, DHCP.
|
||||
|
||||
- Requires: `sub_items: { label: string, required: boolean }[]` on ProceduralStep
|
||||
- UI: checkable list within StepDetail, all sub-items must be checked before "Mark Complete"
|
||||
- Complexity: Low-Medium
|
||||
|
||||
### Step Templates / Reusable Step Library
|
||||
Save commonly used steps (e.g., "Verify DNS resolution", "Create AD OU structure") and insert them into any procedural flow.
|
||||
|
||||
- Requires: new StepTemplate model, API endpoints for CRUD, "Insert from Library" button in editor
|
||||
- UI: step template browser modal, search/filter by category
|
||||
- Complexity: Medium
|
||||
|
||||
---
|
||||
|
||||
## Tier 2 — Valuable Enhancements
|
||||
|
||||
### Screenshot Verification Type
|
||||
Upload a screenshot as proof of completion for a step. Useful for "confirm the dashboard shows X" type verification.
|
||||
|
||||
- Requires: file upload endpoint, `verification_type: 'screenshot'` option, image preview in StepDetail
|
||||
- Dependency: file attachment infrastructure (Phase 3 roadmap)
|
||||
- Complexity: Medium-High
|
||||
|
||||
### Session Assignment / Handoff
|
||||
Assign a procedural session to another engineer, or hand off mid-procedure. Track who completed which steps.
|
||||
|
||||
- Requires: `assigned_to` field on Session, assignment API, notification on assignment
|
||||
- UI: assign button in session list, "Assigned to you" filter
|
||||
- Complexity: Medium
|
||||
|
||||
### Approval Workflows
|
||||
Certain steps or entire procedures require manager/lead approval before proceeding. Step marked as "pending approval" until approved.
|
||||
|
||||
- Requires: approval model, notification system integration, approval status on steps
|
||||
- UI: approval request button, approval queue for managers
|
||||
- Complexity: High
|
||||
|
||||
---
|
||||
|
||||
## Tier 3 — Advanced / Future
|
||||
|
||||
### AI-Assisted Template Generation
|
||||
Describe a procedure in plain text and generate a structured flow with steps, commands, and intake form fields.
|
||||
|
||||
- Requires: LLM integration, prompt engineering for step generation, review/edit UI
|
||||
- Complexity: High
|
||||
|
||||
### Automated PowerShell/CLI Execution
|
||||
Run commands directly from a step against a connected endpoint (via agent or SSH).
|
||||
|
||||
- Requires: secure agent infrastructure, command execution sandbox, output capture
|
||||
- Security: significant — needs careful scoping
|
||||
- Complexity: Very High
|
||||
|
||||
### Branching Hybrid
|
||||
Mini decision-tree within a procedure step. "If the server responds with error X, do A. If it responds normally, continue."
|
||||
|
||||
- Requires: nested decision node within a procedural step, conditional next-step logic
|
||||
- Complexity: High
|
||||
|
||||
### Procedural Code-Mode Editor
|
||||
YAML/JSON editor for power users who want to define procedures as code rather than using the visual editor.
|
||||
|
||||
- Requires: code editor component, schema validation, bidirectional sync with visual editor
|
||||
- Complexity: Medium
|
||||
|
||||
### Template Marketplace
|
||||
Share and discover procedure templates across accounts. Community-contributed flows.
|
||||
|
||||
- Requires: public/private visibility, template publishing flow, discovery/search, ratings
|
||||
- Dependency: subscription tier integration (which tiers can publish/access)
|
||||
- Complexity: Very High
|
||||
|
||||
---
|
||||
|
||||
## Prioritization Notes
|
||||
|
||||
Start with **Conditional Steps** and **Sub-Checklists** — these are the most common requests from engineers using procedural flows in the field. They're also relatively contained changes that don't require new infrastructure.
|
||||
|
||||
**Step Templates** would be the next logical addition once multiple procedural flows exist and engineers start noticing repeated patterns.
|
||||
|
||||
Everything in Tier 3 requires either new infrastructure (file uploads, agent system, LLM integration) or significant architectural work. Defer until Tiers 1-2 are validated.
|
||||
364
docs/plans/archive/plan-improvements-for-analytics.md
Normal file
364
docs/plans/archive/plan-improvements-for-analytics.md
Normal file
@@ -0,0 +1,364 @@
|
||||
# Post-Implementation Improvements — Analytics & Feedback
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Context:** The analytics & feedback feature has just been implemented from `docs/plans/2026-02-15-analytics-feedback-implementation.md`. These are five targeted improvements identified from a comparative review. Apply them to the existing code on the current branch (`feat/analytics-feedback`).
|
||||
|
||||
**Reference files you'll need to modify:**
|
||||
- `backend/app/api/endpoints/analytics.py` — analytics query logic
|
||||
- `backend/app/schemas/analytics.py` — response schemas
|
||||
- `backend/app/api/endpoints/ratings.py` — rating endpoints
|
||||
- `backend/app/api/endpoints/steps.py` — existing step rating routes
|
||||
- `frontend/src/types/analytics.ts` — TypeScript types
|
||||
- `frontend/src/pages/TeamAnalyticsPage.tsx` — team dashboard
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Switch from Average to Median Duration
|
||||
|
||||
**Why:** One 3-hour debugging session shouldn't skew stats for a flow that normally takes 8 minutes. Median is the standard for time-based metrics in analytics.
|
||||
|
||||
**Backend changes:**
|
||||
|
||||
In `backend/app/schemas/analytics.py`:
|
||||
- Rename `avg_duration_minutes` → `median_duration_minutes` in `AnalyticsSummary`, `TopFlow`, and `TopEngineer`
|
||||
|
||||
In `backend/app/api/endpoints/analytics.py`:
|
||||
- Replace `func.avg(...)` duration calculations with a median approach
|
||||
- PostgreSQL supports `percentile_cont(0.5) WITHIN GROUP (ORDER BY ...)` for median
|
||||
- Example replacement for the `_build_summary` helper:
|
||||
|
||||
```python
|
||||
from sqlalchemy import text
|
||||
|
||||
# Replace the avg duration query with:
|
||||
duration_q = await db.execute(
|
||||
select(
|
||||
func.percentile_cont(0.5).within_group(
|
||||
func.extract('epoch', Session.completed_at - Session.started_at) / 60
|
||||
)
|
||||
).where(*base_filter, Session.completed_at.isnot(None))
|
||||
)
|
||||
median_duration = round(float(duration_q.scalar() or 0), 1)
|
||||
```
|
||||
|
||||
- Apply the same change to the top_flows and top_engineers subqueries (replace `func.avg` with `percentile_cont(0.5)` for duration)
|
||||
- Update all references from `avg_duration` to `median_duration` in the response construction
|
||||
|
||||
**Frontend changes:**
|
||||
|
||||
In `frontend/src/types/analytics.ts`:
|
||||
- Rename `avg_duration_minutes` → `median_duration_minutes` in `TopFlow`, `TopEngineer`, and `AnalyticsSummary`
|
||||
|
||||
In `frontend/src/pages/TeamAnalyticsPage.tsx` and `frontend/src/pages/MyAnalyticsPage.tsx`:
|
||||
- Update stat card label from "Avg Duration" → "Median Duration"
|
||||
- Update any references to `avg_duration_minutes` → `median_duration_minutes`
|
||||
|
||||
In `frontend/src/components/analytics/FlowAnalyticsPanel.tsx`:
|
||||
- Same rename for the flow summary stat card
|
||||
|
||||
**Verify:** `cd backend && python -m pytest --override-ini="addopts=" -v` and `cd frontend && npm run build`
|
||||
|
||||
**Commit:** `git commit -am "refactor: use median instead of average for duration metrics"`
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Add Step Dropoff Metrics to Flow Analytics
|
||||
|
||||
**Why:** Dropoff data is passive (requires no user action) and tells flow authors exactly where engineers get stuck and abandon sessions. This is the most actionable metric for flow improvement.
|
||||
|
||||
**Backend changes:**
|
||||
|
||||
In `backend/app/schemas/analytics.py`:
|
||||
- Add fields to `StepFeedbackSummary`:
|
||||
|
||||
```python
|
||||
class StepFeedbackSummary(BaseModel):
|
||||
node_id: str
|
||||
node_title: str
|
||||
helpful_yes: int
|
||||
helpful_no: int
|
||||
helpful_rate: float
|
||||
visit_count: int = 0 # NEW
|
||||
dropoff_count: int = 0 # NEW
|
||||
dropoff_rate: float = 0.0 # NEW
|
||||
```
|
||||
|
||||
In `backend/app/api/endpoints/analytics.py`, in the `get_flow_analytics` function:
|
||||
- After the existing step_feedback section, add dropoff calculation logic:
|
||||
|
||||
```python
|
||||
# Step dropoff analysis — build from session decisions JSONB
|
||||
# For each session in the period for this tree:
|
||||
# - Extract all node_ids from the decisions array
|
||||
# - The LAST node_id in an incomplete session = dropoff point
|
||||
# - Count visits per node and dropoffs per node
|
||||
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
# Get all sessions for this tree in period
|
||||
sessions_q = await db.execute(
|
||||
select(Session.id, Session.decisions, Session.completed_at)
|
||||
.where(Session.tree_id == tree_id, Session.started_at >= period_start)
|
||||
)
|
||||
sessions_data = sessions_q.all()
|
||||
|
||||
# Build node visit and dropoff maps
|
||||
node_visits: dict[str, int] = {}
|
||||
node_dropoffs: dict[str, int] = {}
|
||||
|
||||
for sess in sessions_data:
|
||||
decisions = sess.decisions or []
|
||||
for decision in decisions:
|
||||
node_id = decision.get("node_id") or decision.get("nodeId", "")
|
||||
if node_id:
|
||||
node_visits[node_id] = node_visits.get(node_id, 0) + 1
|
||||
|
||||
# If session not completed, last decision node is a dropoff
|
||||
if not sess.completed_at and decisions:
|
||||
last_decision = decisions[-1]
|
||||
last_node = last_decision.get("node_id") or last_decision.get("nodeId", "")
|
||||
if last_node:
|
||||
node_dropoffs[last_node] = node_dropoffs.get(last_node, 0) + 1
|
||||
|
||||
# Merge dropoff data into step_feedback list
|
||||
# Get node titles from the tree's content JSONB
|
||||
tree_result = await db.execute(select(Tree.content).where(Tree.id == tree_id))
|
||||
tree_content = tree_result.scalar_one_or_none() or {}
|
||||
nodes = tree_content.get("nodes", [])
|
||||
node_title_map = {n.get("id", ""): n.get("title", n.get("label", "Unnamed")) for n in nodes}
|
||||
|
||||
# Build combined step feedback with visits + dropoffs + thumbs
|
||||
all_node_ids = set(list(node_visits.keys()) + [sf.node_id for sf in step_feedback])
|
||||
combined_feedback = []
|
||||
for nid in all_node_ids:
|
||||
visits = node_visits.get(nid, 0)
|
||||
dropoffs = node_dropoffs.get(nid, 0)
|
||||
# Find existing thumbs data if any
|
||||
existing = next((sf for sf in step_feedback if sf.node_id == nid), None)
|
||||
combined_feedback.append(StepFeedbackSummary(
|
||||
node_id=nid,
|
||||
node_title=node_title_map.get(nid, "Unknown Step"),
|
||||
helpful_yes=existing.helpful_yes if existing else 0,
|
||||
helpful_no=existing.helpful_no if existing else 0,
|
||||
helpful_rate=existing.helpful_rate if existing else 0.0,
|
||||
visit_count=visits,
|
||||
dropoff_count=dropoffs,
|
||||
dropoff_rate=round(dropoffs / visits, 3) if visits > 0 else 0.0,
|
||||
))
|
||||
|
||||
# Sort by dropoff_rate descending so worst steps are first
|
||||
combined_feedback.sort(key=lambda x: x.dropoff_rate, reverse=True)
|
||||
step_feedback = combined_feedback
|
||||
```
|
||||
|
||||
**Frontend changes:**
|
||||
|
||||
In `frontend/src/types/analytics.ts`:
|
||||
- Add to `StepFeedbackSummary`:
|
||||
|
||||
```typescript
|
||||
export interface StepFeedbackSummary {
|
||||
node_id: string
|
||||
node_title: string
|
||||
helpful_yes: number
|
||||
helpful_no: number
|
||||
helpful_rate: number
|
||||
visit_count: number // NEW
|
||||
dropoff_count: number // NEW
|
||||
dropoff_rate: number // NEW
|
||||
}
|
||||
```
|
||||
|
||||
In `frontend/src/components/analytics/FlowAnalyticsPanel.tsx`:
|
||||
- Add dropoff columns to the step feedback table: "Visits", "Dropoffs", "Dropoff Rate"
|
||||
- Highlight rows where `dropoff_rate > 0.2` with a subtle red/warning background
|
||||
|
||||
**Verify:** `cd backend && python -m pytest --override-ini="addopts=" -v` and `cd frontend && npm run build`
|
||||
|
||||
**Commit:** `git commit -am "feat: add step dropoff metrics to flow analytics"`
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Add Backward-Compatible /ratings Alias Routes
|
||||
|
||||
**Why:** The existing backend uses `/{step_id}/rate` but the frontend uses `/{step_id}/ratings`. This is a real inconsistency that needs fixing.
|
||||
|
||||
**Backend changes:**
|
||||
|
||||
In `backend/app/api/endpoints/steps.py`:
|
||||
- Find the existing routes: `POST /{step_id}/rate`, `PUT /{step_id}/rate`, `DELETE /{step_id}/rate`
|
||||
- Add alias routes that point to the same handler functions:
|
||||
|
||||
```python
|
||||
# After the existing rate_step function:
|
||||
@router.post("/{step_id}/ratings", response_model=StepRatingResponse, status_code=status.HTTP_201_CREATED,
|
||||
include_in_schema=False)
|
||||
async def rate_step_alias(
|
||||
step_id: UUID,
|
||||
rating_data: StepRatingCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Alias for POST /{step_id}/rate — backward compatibility."""
|
||||
return await rate_step(step_id, rating_data, db, current_user)
|
||||
|
||||
|
||||
@router.put("/{step_id}/ratings", response_model=StepRatingResponse,
|
||||
include_in_schema=False)
|
||||
async def update_rating_alias(
|
||||
step_id: UUID,
|
||||
rating_data: StepRatingUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Alias for PUT /{step_id}/rate — backward compatibility."""
|
||||
return await update_rating(step_id, rating_data, db, current_user)
|
||||
|
||||
|
||||
@router.delete("/{step_id}/ratings", status_code=204,
|
||||
include_in_schema=False)
|
||||
async def delete_rating_alias(
|
||||
step_id: UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Alias for DELETE /{step_id}/rate — backward compatibility."""
|
||||
return await delete_rating(step_id, db, current_user)
|
||||
```
|
||||
|
||||
Note: Use `include_in_schema=False` to keep the OpenAPI docs clean — only the canonical `/rate` routes appear in docs.
|
||||
|
||||
**Verify:** `cd backend && python -m pytest --override-ini="addopts=" -v`
|
||||
|
||||
**Commit:** `git commit -am "fix: add /ratings alias routes for backward compatibility"`
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Anonymize Feedback Comments in Analytics
|
||||
|
||||
**Why:** In a team tool where managers see feedback, engineers will give more honest feedback if their identity isn't attached. Anonymous feedback = better signal.
|
||||
|
||||
**Backend changes:**
|
||||
|
||||
In `backend/app/schemas/analytics.py`:
|
||||
- Remove `user_name` from `FlowRatingItem`:
|
||||
|
||||
```python
|
||||
class FlowRatingItem(BaseModel):
|
||||
rating: int
|
||||
comment: Optional[str]
|
||||
created_at: datetime
|
||||
# user_name removed — feedback is anonymous in analytics views
|
||||
```
|
||||
|
||||
In `backend/app/api/endpoints/analytics.py`, in `get_flow_analytics`:
|
||||
- Remove the `User.name` join from the recent_comments query:
|
||||
|
||||
```python
|
||||
# Recent comments — anonymous
|
||||
comments_q = await db.execute(
|
||||
select(SessionRating.rating, SessionRating.comment, SessionRating.created_at)
|
||||
.where(
|
||||
SessionRating.tree_id == tree_id,
|
||||
SessionRating.comment.isnot(None),
|
||||
SessionRating.comment != "",
|
||||
)
|
||||
.order_by(SessionRating.created_at.desc())
|
||||
.limit(10)
|
||||
)
|
||||
recent_comments = [
|
||||
FlowRatingItem(
|
||||
rating=row.rating,
|
||||
comment=row.comment,
|
||||
created_at=row.created_at,
|
||||
)
|
||||
for row in comments_q.all()
|
||||
]
|
||||
```
|
||||
|
||||
**Frontend changes:**
|
||||
|
||||
In `frontend/src/types/analytics.ts`:
|
||||
- Remove `user_name` from `FlowRatingItem`:
|
||||
|
||||
```typescript
|
||||
export interface FlowRatingItem {
|
||||
rating: number
|
||||
comment?: string
|
||||
created_at: string
|
||||
// user_name removed — anonymous feedback
|
||||
}
|
||||
```
|
||||
|
||||
In `frontend/src/components/analytics/FlowAnalyticsPanel.tsx`:
|
||||
- Remove any rendering of `user_name` in the recent comments list
|
||||
- Comments should show: star rating + comment text + relative timestamp only
|
||||
|
||||
**Verify:** `cd backend && python -m pytest --override-ini="addopts=" -v` and `cd frontend && npm run build`
|
||||
|
||||
**Commit:** `git commit -am "privacy: anonymize feedback comments in analytics views"`
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Add Explicit active_engineers Metric
|
||||
|
||||
**Why:** "How many of my techs actually used this tool this month" is a key adoption metric for MSP managers.
|
||||
|
||||
**Backend changes:**
|
||||
|
||||
In `backend/app/schemas/analytics.py`:
|
||||
- Verify `AnalyticsSummary` already has an `active_engineers` field. If not, add:
|
||||
|
||||
```python
|
||||
class AnalyticsSummary(BaseModel):
|
||||
total_sessions: int
|
||||
completed_sessions: int
|
||||
completion_rate: float
|
||||
median_duration_minutes: float
|
||||
active_engineers: int = 0 # ADD if missing
|
||||
outcome_breakdown: OutcomeBreakdown
|
||||
```
|
||||
|
||||
In `backend/app/api/endpoints/analytics.py`:
|
||||
- In the `_build_summary` helper (or the team analytics endpoint), add the active engineers count:
|
||||
|
||||
```python
|
||||
# Active engineers: distinct users with >= 1 session in window
|
||||
active_q = await db.execute(
|
||||
select(func.count(func.distinct(Session.user_id)))
|
||||
.where(*base_filter)
|
||||
)
|
||||
active_engineers = active_q.scalar() or 0
|
||||
```
|
||||
|
||||
- Include `active_engineers=active_engineers` in the `AnalyticsSummary(...)` construction
|
||||
- For personal analytics (`/me`), set `active_engineers=1` (it's always just the requesting user)
|
||||
|
||||
**Frontend changes:**
|
||||
|
||||
In `frontend/src/types/analytics.ts`:
|
||||
- Verify `AnalyticsSummary` has `active_engineers: number`. Add if missing.
|
||||
|
||||
In `frontend/src/pages/TeamAnalyticsPage.tsx`:
|
||||
- Verify there's a stat card for "Active Engineers". If missing, add one using the `summary.active_engineers` value.
|
||||
|
||||
**Verify:** `cd backend && python -m pytest --override-ini="addopts=" -v` and `cd frontend && npm run build`
|
||||
|
||||
**Commit:** `git commit -am "feat: add explicit active_engineers metric to team analytics"`
|
||||
|
||||
---
|
||||
|
||||
## Final: Full Test Suite + Build Verification
|
||||
|
||||
```bash
|
||||
cd backend && python -m pytest --override-ini="addopts=" -v
|
||||
cd frontend && npm run build
|
||||
```
|
||||
|
||||
Fix any failures, then:
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "fix: post-improvement test and build fixes"
|
||||
```
|
||||
Reference in New Issue
Block a user