diff --git a/.gitignore b/.gitignore
index e6cfa118..758ce880 100644
--- a/.gitignore
+++ b/.gitignore
@@ -233,3 +233,4 @@ package.json
package-lock.json
.worktrees/
.gstack/
+.gitnexus
diff --git a/CLAUDE.md b/CLAUDE.md
index 3ebb8970..4ce6373a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1,6 +1,6 @@
# CLAUDE.md - Patherly / ResolutionFlow Project Context
-> **Last Updated:** March 27, 2026
+> **Last Updated:** April 6, 2026
---
@@ -16,7 +16,8 @@
| Context | Name Used |
|---------|-----------|
-| Repository / directory / database / Docker | `patherly` / `patherly_postgres` |
+| Repository / directory / database | `patherly` (internal name) |
+| Docker containers | `resolutionflow_postgres`, `resolutionflow_frontend`, `resolutionflow_backend` |
| Backend, frontend UI, production URLs | **ResolutionFlow** |
- **Design system:** [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md) — THE source of truth for all design decisions
@@ -44,7 +45,7 @@
- **Phase:** Go-to-Market Validation (Pre-PMF)
- **Backend:** Complete (55+ API endpoints, 100+ integration tests)
- **Frontend:** Core features complete, Tree Editor functional
-- **Database:** PostgreSQL with Docker, 98 migrations
+- **Database:** PostgreSQL with Docker, 101 migrations
- **Detailed status:** [CURRENT-STATE.md](CURRENT-STATE.md)
### What's In Progress
@@ -96,7 +97,7 @@ patherly/
│ │ ├── services/knowledge_flywheel.py # AI session analysis → flow proposals
│ │ ├── services/knowledge_flywheel_scheduler.py # APScheduler job for batch analysis
│ │ └── services/knowledge_gap_service.py # Weak options & escalation signal detection
-│ ├── alembic/ # Database migrations (001-029+)
+│ ├── alembic/ # Database migrations (001-070 sequential, then hash IDs)
│ ├── scripts/ # seed_data.py, seed_trees.py
│ └── tests/ # pytest integration tests
├── frontend/
@@ -188,8 +189,8 @@ Official ConnectWise developer guides live in `docs/connectwise/best-practices/`
## Development Commands
```powershell
-# Start PostgreSQL
-docker start patherly_postgres
+# Start PostgreSQL (run from VPS SSH — docker not available inside code-server, see Lesson 103)
+docker start resolutionflow_postgres
# Backend (from backend/)
source venv/bin/activate # Linux/Mac
@@ -203,21 +204,19 @@ npm run dev
pytest --override-ini="addopts="
# First time only: create test database
-docker exec -it patherly_postgres psql -U postgres -c "CREATE DATABASE patherly_test;"
+docker exec -it resolutionflow_postgres psql -U postgres -c "CREATE DATABASE resolutionflow_test;"
# Frontend build (IMPORTANT: stricter than tsc --noEmit — always use as final check)
cd frontend && npm run build
# Database migrations
cd backend && alembic upgrade head
-alembic revision --autogenerate -m "Description" --rev-id=NNN # NNN = next sequential number
-# IMPORTANT: Migrations use sequential 3-digit IDs (001, 002, ..., 068, 069).
-# Check the latest: ls backend/alembic/versions/ | grep -E '^\d{3}_' | sort | tail -1
-# The revision ID and filename prefix MUST match (e.g., revision="068", file=068_description.py).
-# down_revision MUST point to the previous sequential number. Never use hex hash IDs for new migrations.
+alembic revision --autogenerate -m "Description"
+# Sequential 3-digit IDs (001–070) were used historically. New migrations use Alembic's default hex hash IDs.
+# Do NOT pass --rev-id — let Alembic generate the hash automatically.
-# Access PostgreSQL
-docker exec -it patherly_postgres psql -U postgres -d patherly
+# Access PostgreSQL (run from VPS SSH — docker not available inside code-server, see Lesson 103)
+docker exec -it resolutionflow_postgres psql -U postgres -d resolutionflow
# Seed data
cd backend && pip install httpx && python -m scripts.seed_trees
@@ -292,7 +291,7 @@ gh run view --json jobs --jq '.jobs[] | {name: .name, conclusion: .conclusi
**62. Playwright strict mode — scope selectors to avoid ambiguity:** Step titles appear in both the sidebar checklist and main content heading. Use `getByRole('heading', { name })` for the main content, or scope with `page.locator('.animate-scale-in')` for command palette items. `getByText()` frequently matches multiple elements due to the sidebar + main content layout.
-**63. Node 20 required for frontend builds:** Vite 7+ requires Node 20.19+. The system Node may be v18; use nvm: `export NVM_DIR="$HOME/.nvm" && source "$NVM_DIR/nvm.sh" && nvm use 20`. For direct binary access without nvm sourcing: `PATH="/home/michaelchihlas/.nvm/versions/node/v20.19.0/bin:$PATH"`.
+**63. Node 20 required for frontend builds:** Vite 7+ requires Node 20.19+. The system Node may be v18; use nvm: `export NVM_DIR="$HOME/.nvm" && source "$NVM_DIR/nvm.sh" && nvm use 20`. For direct binary access without nvm sourcing: `PATH="$HOME/.nvm/versions/node/v20.19.0/bin:$PATH"`.
**64. PostHog product analytics:** Initialized via `PostHogProvider` in `main.tsx` with explicit `posthog.init()` + `client` prop pattern. Event helpers in `lib/analytics.ts` — use `analytics.eventName(props)` to track. `identifyUser()` called in `authStore.fetchUser()`, `resetAnalytics()` on logout. Env vars: `VITE_PUBLIC_POSTHOG_KEY`, `VITE_PUBLIC_POSTHOG_HOST`. Autocapture enabled.
@@ -332,7 +331,7 @@ gh run view --json jobs --jq '.jobs[] | {name: .name, conclusion: .conclusi
**82. `bun` requires PATH setup on devserver01:** `export BUN_INSTALL="$HOME/.bun" && export PATH="$BUN_INSTALL/bin:$PATH"`. The gstack browse binary and Playwright need this. Chromium system deps: `libatk1.0-0 libatk-bridge2.0-0 libcups2 libxkbcommon0 libatspi2.0-0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libgbm1 libasound2`.
-**83. FlowPilot ActionBar is `position: fixed; bottom: 0`:** Any UI element placed in normal document flow below the session content will be hidden behind it. New fixed-position elements (like the message bar) must use `bottom: 68px` (action bar height) and the same `left: var(--sidebar-w)` pattern. The conversation column uses `pb-32` for clearance.
+**83. ~~FlowPilot ActionBar fixed bottom~~ (Superseded by Lesson 93):** Actions moved to the page header. `FlowPilotActionBar` component exists but is no longer used in the main session flow. The only fixed-bottom element is the message input.
**84. AI session `abandoned` status is fully wired:** `POST /ai-sessions/{id}/abandon` sets status to `abandoned` with optional `reason` param. Frontend: `aiSessionsApi.abandonSession()`, `useFlowPilotSession().abandonSession()`, "Close" button in `FlowPilotActionBar`. Redirects to `/sessions` after closing.
@@ -344,6 +343,7 @@ gh run view --json jobs --jq '.jobs[] | {name: .name, conclusion: .conclusi
**88. Charcoal palette — sidebar-darkest approach:** Sidebar `#0e1016`, page `#16181f`, cards `#1e2028`, borders `#2a2e3a`. This gives more contrast range than true-dark. All colors via CSS variables in `index.css` `@theme` block. Accent is electric blue (#60a5fa), not orange or cyan.
+*(Lessons 89–91 were retracted.)*
**92. `tsc -b` in Dockerfile is stricter than `npx tsc --noEmit`:** The production build (`tsc -b && vite build`) enforces `noUnusedLocals` and `noUnusedParameters` as hard errors. After any refactor that moves logic between components or removes features, trace every import and destructured prop to remove orphans. IDE warnings (yellow squiggles) flag these — check them before pushing.
@@ -353,7 +353,7 @@ gh run view --json jobs --jq '.jobs[] | {name: .name, conclusion: .conclusi
**95. Image upload → AI vision pipeline:** Paste/attach images → upload to Railway S3 bucket via `uploadsApi.upload()` → send `upload_ids` with chat message → backend fetches from S3 via `storage_service.download_file()` → resized via `storage_service.resize_image_for_vision()` (Pillow, 1568px max, PNG→JPEG) → base64-encoded → sent as Claude multimodal content blocks. Max 3 images/message. Images are NOT stored in conversation history (text-only). Vision helpers live in `storage_service.py`.
-**96. `bg-accent` is ember orange — never use for code/kbd elements:** In Tailwind v4, `bg-accent` maps to `--color-accent: #f97316`. Use `bg-code` for code blocks, `bg-white/[0.12] border border-white/[0.06]` for inline code/badges, `bg-white/[0.08]` for kbd shortcuts. Orange is reserved for interactive elements only (buttons, active nav, links).
+**96. `bg-accent` is electric blue — never use for code/kbd elements:** In Tailwind v4, `bg-accent` maps to `--color-accent: #60a5fa` (dark) / `#2563eb` (light). Use `bg-code` for code blocks, `bg-white/[0.12] border border-white/[0.06]` for inline code/badges, `bg-white/[0.08]` for kbd shortcuts. Blue accent is reserved for interactive elements only (buttons, active nav, links). Ember orange (#f97316) is deprecated — do not use.
**97. Railway Object Storage (S3 bucket) is provisioned:** Bucket `resolutionflow-uploads` on Railway canvas. Variables: `STORAGE_ENDPOINT`, `STORAGE_ACCESS_KEY`, `STORAGE_SECRET_KEY`, `STORAGE_BUCKET_NAME`, `STORAGE_REGION` — mapped via variable references on the `patherly` backend service. Accessed via boto3 in `storage_service.py`. Pillow (`Pillow>=10.0.0`) + `libjpeg-dev`/`zlib1g-dev` in Dockerfile for image resize.
@@ -390,16 +390,16 @@ gh run view --json jobs --jq '.jobs[] | {name: .name, conclusion: .conclusi
**Source of truth:** [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md) — always read this before making visual or UI decisions.
-- **Theme:** Flat, high-contrast dark theme (Sentry/PostHog-inspired). No glass morphism, no backdrop blur, no ambient orbs, no gradient backgrounds on surfaces. Light mode planned.
-- **Backgrounds:** `bg-page` (`#1a1c23`), `bg-sidebar` (`#10121a`), `bg-card` (`#22252e`), `bg-elevated` (`#2e3140`)
-- **Cards:** `bg-card` with 1px `border-default` (`#2e3240`), 8px radius. No shadows, no blur, no gradients. Hover: `border-hover` (`#3d4252`)
-- **Buttons:** Primary: solid `accent` (#f97316), white text, 5px radius. Ghost: transparent + 1px border, hover `bg-elevated`
-- **Inputs:** `bg-input` (`#282b35`) with 1px `border-default`, 5px radius. Focus: `border-color: accent` + `box-shadow: 0 0 0 2px accent-dim`
-- **Text:** `text-heading` (`#f0f2f5`) → `text-primary` (`#e2e5eb`) → `text-muted-foreground` (`#848b9b`) → `text-muted` (`#4f5666`). NEVER use `text-secondary` — in Tailwind v4 it maps to a surface color (#2e3140), not a text color.
-- **Borders:** `border-default` (`#2e3240`), `border-hover` (`#3d4252`)
-- **Functional colors:** `#34d399` (success), `#eab308` (warning), `#f87171` (danger) — each with `-dim` variant at 10% opacity
-- **Accent:** Ember orange `#f97316` — used sparingly (≤5% of UI). `accent-dim` = `rgba(249,115,22,0.10)`, `accent-text` = `#fdba74`
-- **Deprecated:** Do NOT use `glass-card`, `glass-stat`, `bg-gradient-brand`, `text-gradient-brand`, `backdrop-filter: blur()`, ambient orbs, purple gradients, or cyan accent (`#22d3ee`)
+- **Theme:** Flat, high-contrast dark theme (Sentry/PostHog-inspired). No glass morphism, no backdrop blur, no ambient orbs, no gradient backgrounds on surfaces. Light mode fully specified (v6).
+- **Backgrounds:** `bg-page` (`#16181f`), `bg-sidebar` (`#0e1016`), `bg-card` (`#1e2028`), `bg-elevated` (`#2a2d38`)
+- **Cards:** `bg-card` with 1px `border-default` (`#2a2e3a`), 8px radius. No shadows, no blur, no gradients. Hover: `border-hover` (`#3d4252`)
+- **Buttons:** Primary: solid `accent` (#60a5fa dark / #2563eb light), white text, 5px radius. Ghost: transparent + 1px border, hover `bg-elevated`
+- **Inputs:** `bg-input` (`#252830`) with 1px `border-default`, 5px radius. Focus: `border-color: accent` + `box-shadow: 0 0 0 2px accent-dim`
+- **Text:** `text-heading` (`#f0f2f5`) → `text-primary` (`#e2e5eb`) → `text-muted-foreground` (`#848b9b`) → `text-muted` (`#4f5666`). NEVER use `text-secondary` — in Tailwind v4 it maps to a surface color, not a text color.
+- **Borders:** `border-default` (`#2a2e3a`), `border-hover` (`#3d4252`)
+- **Functional colors:** `#34d399` (success), `#fbbf24` (warning/amber), `#f87171` (danger), `#67e8f9` (info/cyan) — each with `-dim` variant at 10% opacity
+- **Accent:** Electric blue `#60a5fa` (dark) / `#2563eb` (light) — used sparingly (≤5% of UI). `accent-dim` = `rgba(96,165,250,0.10)`, `accent-text` = `#93c5fd`
+- **Deprecated:** Do NOT use `glass-card`, `glass-stat`, `bg-gradient-brand`, `text-gradient-brand`, `backdrop-filter: blur()`, ambient orbs, purple gradients, ember orange (`#f97316`), or cyan (`#22d3ee`) as accent — cyan is now the info color only
---
@@ -518,3 +518,105 @@ When a feature, fix, or significant piece of work is finished and merged/committ
| Bugs & Fixes | CLAUDE.md → Critical Lessons Learned section |
| Design System | [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md) |
| Dev Environment | [DEV-ENV.md](DEV-ENV.md) — 46.202.92.250 setup, Docker, CORS, networking |
+
+
+# GitNexus — Code Intelligence
+
+This project is indexed by GitNexus as **resolutionflow** (14787 symbols, 31366 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
+
+> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
+
+## Always Do
+
+- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
+- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
+- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
+- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
+- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`.
+
+## When Debugging
+
+1. `gitnexus_query({query: ""})` — find execution flows related to the issue
+2. `gitnexus_context({name: ""})` — see all callers, callees, and process participation
+3. `READ gitnexus://repo/resolutionflow/process/{processName}` — trace the full execution flow step by step
+4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed
+
+## When Refactoring
+
+- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`.
+- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code.
+- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed.
+
+## Never Do
+
+- NEVER edit a function, class, or method without first running `gitnexus_impact` on it.
+- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
+- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph.
+- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope.
+
+## Tools Quick Reference
+
+| Tool | When to use | Command |
+|------|-------------|---------|
+| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` |
+| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` |
+| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` |
+| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` |
+| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` |
+| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` |
+
+## Impact Risk Levels
+
+| Depth | Meaning | Action |
+|-------|---------|--------|
+| d=1 | WILL BREAK — direct callers/importers | MUST update these |
+| d=2 | LIKELY AFFECTED — indirect deps | Should test |
+| d=3 | MAY NEED TESTING — transitive | Test if critical path |
+
+## Resources
+
+| Resource | Use for |
+|----------|---------|
+| `gitnexus://repo/resolutionflow/context` | Codebase overview, check index freshness |
+| `gitnexus://repo/resolutionflow/clusters` | All functional areas |
+| `gitnexus://repo/resolutionflow/processes` | All execution flows |
+| `gitnexus://repo/resolutionflow/process/{name}` | Step-by-step execution trace |
+
+## Self-Check Before Finishing
+
+Before completing any code modification task, verify:
+1. `gitnexus_impact` was run for all modified symbols
+2. No HIGH/CRITICAL risk warnings were ignored
+3. `gitnexus_detect_changes()` confirms changes match expected scope
+4. All d=1 (WILL BREAK) dependents were updated
+
+## Keeping the Index Fresh
+
+After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it:
+
+```bash
+npx gitnexus analyze
+```
+
+If the index previously included embeddings, preserve them by adding `--embeddings`:
+
+```bash
+npx gitnexus analyze --embeddings
+```
+
+To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.**
+
+> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`.
+
+## CLI
+
+| Task | Read this skill file |
+|------|---------------------|
+| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
+| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
+| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
+| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
+| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
+| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
+
+
diff --git a/backend/app/services/assistant_chat_service.py b/backend/app/services/assistant_chat_service.py
index 49c44ffe..6e17b8e3 100644
--- a/backend/app/services/assistant_chat_service.py
+++ b/backend/app/services/assistant_chat_service.py
@@ -77,6 +77,9 @@ scope narrows it to this endpoint.
- JSON array of objects with `text` (required) and `context` (optional, 1 sentence)
- 1-3 questions per response
- Do NOT ask questions inline in your prose. ALL questions go in the marker.
+- If the engineer's message contains tasks marked `_(not yet completed)_`, re-include \
+those as questions/actions in your next response UNLESS you are ≥75% confident the \
+information is no longer needed to resolve the issue. Default to keeping them.
**[ACTIONS] marker format:**
- JSON array of objects with `label` (required), `command` (optional), `description` (required)
@@ -155,6 +158,8 @@ To create a fork, append this marker AFTER your [QUESTIONS]/[ACTIONS] markers:
Every single response MUST contain [QUESTIONS] and/or [ACTIONS] markers with valid JSON. \
No exceptions. Not even when forking. A response without at least one of these markers \
will crash the UI. If you are unsure, include both. The markers are REQUIRED output, not optional.
+If any tasks in the engineer's message are marked `_(not yet completed)_`, re-include them \
+in your markers unless you are ≥75% confident that information is no longer relevant.
"""
diff --git a/frontend/src/components/assistant/TaskLane.tsx b/frontend/src/components/assistant/TaskLane.tsx
index 590f34b3..21bc91db 100644
--- a/frontend/src/components/assistant/TaskLane.tsx
+++ b/frontend/src/components/assistant/TaskLane.tsx
@@ -130,6 +130,14 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
}
}, [handleMouseMove, handleMouseUp])
+ // Refs so the debounced save always uses the latest questions/actions/tasks
+ const questionsRef = useRef(questions)
+ const actionsRef = useRef(actions)
+ const tasksRef = useRef(tasks)
+ useEffect(() => { questionsRef.current = questions }, [questions])
+ useEffect(() => { actionsRef.current = actions }, [actions])
+ useEffect(() => { tasksRef.current = tasks }, [tasks])
+
// Save task state to sessionStorage on every change + debounce to backend
const saveTimerRef = useRef | null>(null)
useEffect(() => {
@@ -139,9 +147,9 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
saveTimerRef.current = setTimeout(() => {
aiSessionsApi.saveTaskLane(sessionId, {
- questions: questions.map(q => ({ text: q.text, context: q.context })),
- actions: actions.map(a => ({ label: a.label, command: a.command, description: a.description })),
- responses: tasks as unknown as Array>,
+ questions: questionsRef.current.map(q => ({ text: q.text, context: q.context })),
+ actions: actionsRef.current.map(a => ({ label: a.label, command: a.command, description: a.description })),
+ responses: tasksRef.current as unknown as Array>,
}).catch(() => { /* silent — best-effort save */ })
}, 2000)
return () => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current) }
diff --git a/frontend/src/pages/AssistantChatPage.tsx b/frontend/src/pages/AssistantChatPage.tsx
index 9b6169cb..1f675727 100644
--- a/frontend/src/pages/AssistantChatPage.tsx
+++ b/frontend/src/pages/AssistantChatPage.tsx
@@ -12,7 +12,7 @@ import { analytics } from '@/lib/analytics'
import { toast } from '@/lib/toast'
import { ChatSidebar, ChatSidebarCollapsedBar } from '@/components/assistant/ChatSidebar'
import { ChatMessage } from '@/components/assistant/ChatMessage'
-import { TaskLane } from '@/components/assistant/TaskLane'
+import { TaskLane, clearTaskState } from '@/components/assistant/TaskLane'
import { ConcludeSessionModal } from '@/components/assistant/ConcludeSessionModal'
import { StatusUpdateModal } from '@/components/flowpilot/StatusUpdateModal'
import type { ChatListItem, ConclusionOutcome } from '@/types/assistant-chat'
@@ -242,7 +242,7 @@ export default function AssistantChatPage() {
if (q.length > 0 || a.length > 0) {
// Pre-load user's saved responses into sessionStorage BEFORE setting props
// so TaskLane can restore them on mount/prop-change
- const responses = (detail.pending_task_lane as Record).responses as unknown[] | undefined
+ const responses = detail.pending_task_lane.responses
if (responses && responses.length > 0) {
try {
sessionStorage.setItem(`rf-tasklane-state:${chatId}`, JSON.stringify(responses))
@@ -259,6 +259,11 @@ export default function AssistantChatPage() {
}, [])
const handleNewChat = async () => {
+ // Clear stale state immediately — don't wait for API to return
+ setShowTaskLane(false)
+ setActiveQuestions([])
+ setActiveActions([])
+ setMessages([])
try {
const session = await aiSessionsApi.createChatSession({
intake_type: 'free_text',
@@ -275,11 +280,6 @@ export default function AssistantChatPage() {
currentChatRef.current = session.session_id
setChats(prev => [chatItem, ...prev])
setActiveChatId(session.session_id)
- setMessages([])
- // Clear TaskLane from previous session
- setShowTaskLane(false)
- setActiveQuestions([])
- setActiveActions([])
} catch {
toast.error('Failed to create chat')
}
@@ -315,11 +315,14 @@ export default function AssistantChatPage() {
setMessages(prev => [...prev, { role: 'user', content: userMessage }])
setLoading(true)
+ const sentForChatId = activeChatId
try {
const response = await aiSessionsApi.sendChatMessage(activeChatId, {
message: userMessage,
upload_ids: completedUploadIds.length > 0 ? completedUploadIds : undefined,
})
+ // Guard: discard if user switched to a different chat while this was in flight
+ if (currentChatRef.current !== sentForChatId) return
analytics.aiFeatureUsed({ feature: 'assistant_chat' })
setMessages(prev => [
...prev,
@@ -327,19 +330,20 @@ export default function AssistantChatPage() {
])
setChats(prev =>
prev.map(c =>
- c.id === activeChatId
+ c.id === sentForChatId
? { ...c, message_count: c.message_count + 2, title: c.message_count === 0 ? userMessage.slice(0, 100) : c.title, updated_at: new Date().toISOString() }
: c
)
)
// Load branches if fork was created
- if (response.fork && activeChatId) {
- branching.loadBranches(activeChatId)
+ if (response.fork && sentForChatId) {
+ branching.loadBranches(sentForChatId)
}
// Show task lane if AI sent questions or actions
const hasQuestions = response.questions && response.questions.length > 0
const hasActions = response.actions && response.actions.length > 0
if (hasQuestions || hasActions) {
+ clearTaskState(sentForChatId)
setActiveQuestions(response.questions || [])
setActiveActions(response.actions || [])
setShowTaskLane(true)
@@ -358,7 +362,8 @@ export default function AssistantChatPage() {
const handleTaskSubmit = async (responses: Array<{ type: string; state: string; value: string; text?: string; label?: string }>) => {
if (!activeChatId || loading) return
- // Format task responses into a structured message for the AI
+ // Format task responses into a structured message for the AI.
+ // Pending tasks are included so the AI knows they weren't completed yet.
const parts: string[] = []
for (const r of responses) {
const name = r.type === 'question' ? `Q: ${r.text}` : r.label || 'Check'
@@ -366,6 +371,8 @@ export default function AssistantChatPage() {
parts.push(`**${name}:**\n\`\`\`\n${r.value.trim()}\n\`\`\``)
} else if (r.state === 'skipped') {
parts.push(`**${name}:** _(skipped)_`)
+ } else {
+ parts.push(`**${name}:** _(not yet completed)_`)
}
}
const userMessage = parts.join('\n\n')
@@ -373,18 +380,22 @@ export default function AssistantChatPage() {
setMessages(prev => [...prev, { role: 'user', content: userMessage }])
setLoading(true)
+ const sentForChatId = activeChatId
try {
const response = await aiSessionsApi.sendChatMessage(activeChatId, { message: userMessage })
+ // Guard: discard if user switched to a different chat while this was in flight
+ if (currentChatRef.current !== sentForChatId) return
setMessages(prev => [
...prev,
{ role: 'assistant', content: response.content, suggestedFlows: response.suggested_flows, fork: response.fork, actions: response.actions, questions: response.questions },
])
- if (response.fork && activeChatId) {
- branching.loadBranches(activeChatId)
+ if (response.fork && sentForChatId) {
+ branching.loadBranches(sentForChatId)
}
// Update task lane based on AI response
const hasQuestions = response.questions && response.questions.length > 0
const hasActions = response.actions && response.actions.length > 0
+ clearTaskState(sentForChatId)
if (hasQuestions || hasActions) {
setActiveQuestions(response.questions || [])
setActiveActions(response.actions || [])
@@ -425,6 +436,10 @@ export default function AssistantChatPage() {
}
const handleResumeNew = async (summary: string) => {
+ // Clear stale state immediately — don't wait for API to return
+ setShowTaskLane(false)
+ setActiveQuestions([])
+ setActiveActions([])
try {
const resumePrompt = `I'm continuing a previous troubleshooting session. Here's where we left off:\n\n${summary}\n\nPlease review this context and help me continue from where we stopped.`
const session = await aiSessionsApi.createChatSession({
diff --git a/frontend/src/pages/LandingPage.tsx b/frontend/src/pages/LandingPage.tsx
index ba49969e..aaff9d10 100644
--- a/frontend/src/pages/LandingPage.tsx
+++ b/frontend/src/pages/LandingPage.tsx
@@ -180,57 +180,6 @@ export default function LandingPage() {
Built by a 15-year MSP veteran who got tired of empty ticket notes.
-
-
-
-
-
- 🔒
- app.resolutionflow.com/pilot
-
-
-
-
-
-
- You
- User can't access shared drive after password reset
-
-
-
-
-
- FlowPilot is thinking…
-
-
-
-
- FlowPilot
- Likely a cached credential issue. Let's check:
-
-
-
-
- FlowPilot
- 1. Run klist purge to clear Kerberos tickets
-
-
-
-
- FlowPilot
- 2. Credential Manager → remove saved share entries
-
-
-
-
- Auto-doc
- 3 steps captured ✓
-
-
-
-
-
-
diff --git a/frontend/src/styles/landing.css b/frontend/src/styles/landing.css
index 56f36fc1..313b4d95 100644
--- a/frontend/src/styles/landing.css
+++ b/frontend/src/styles/landing.css
@@ -74,9 +74,8 @@
}
.landing-nav.scrolled {
- background: rgba(20, 22, 29, 0.95);
+ background: #0d0f15;
border-bottom: 1px solid var(--lp-border);
- backdrop-filter: blur(8px);
}
.landing-nav-inner {
@@ -230,19 +229,44 @@
/* ---- HERO ---- */
.landing-hero {
- padding: 9rem 2rem 5rem;
+ padding: 10rem 2rem 8rem;
+ position: relative;
+ overflow: hidden;
+ min-height: 580px;
+}
+
+/* Full-bleed image layer — positioned lower so terrain fills, hub upper-right */
+.landing-hero::before {
+ content: '';
+ position: absolute;
+ inset: 0;
+ background: url('/images/hero_001.jpg') 58% 38% / cover no-repeat;
+ opacity: 0.72;
+ z-index: 0;
+}
+
+/* Left-to-right bleed: solid dark where text lives, dissolves into raw image on the right */
+.landing-hero::after {
+ content: '';
+ position: absolute;
+ inset: 0;
+ background:
+ linear-gradient(to right, #14161d 22%, rgba(20, 22, 29, 0.80) 38%, rgba(20, 22, 29, 0.20) 58%, transparent 78%),
+ linear-gradient(to top, #14161d 0%, rgba(20, 22, 29, 0) 16%);
+ z-index: 1;
}
.landing-hero-inner {
max-width: 1200px;
margin: 0 auto;
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: 4rem;
- align-items: center;
+ display: flex;
+ align-items: flex-start;
+ position: relative;
+ z-index: 2;
}
.landing-hero-content {
+ max-width: 520px;
animation: landingFadeInUp 0.8s ease-out;
}
@@ -286,7 +310,7 @@
}
.landing-hero-accent {
- color: var(--lp-accent-text);
+ color: var(--lp-accent);
}
.landing-hero-sub {
@@ -359,72 +383,205 @@
animation: landingPreviewEntrance 1s cubic-bezier(0.22, 1, 0.36, 1) 0.3s both;
}
-/* ---- PREVIEW WINDOW ---- */
-.landing-preview-window {
+/* ---- TICKET COMPARISON (hero visual) ---- */
+.landing-ticket-comparison {
+ display: flex;
+ align-items: stretch;
+ gap: 0;
+ width: 100%;
+}
+
+.landing-tc-col {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ min-width: 0;
+}
+
+.landing-tc-label {
+ font-size: 0.6rem;
+ font-weight: 700;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ padding: 0 0.25rem;
+}
+
+.landing-tc-label.before-label {
+ color: var(--lp-text-dim);
+}
+
+.landing-tc-label.after-label {
+ color: var(--lp-accent);
+}
+
+.landing-tc-card {
border-radius: 8px;
border: 1px solid var(--lp-border);
background: var(--lp-card);
- overflow: hidden;
- box-shadow: 0 24px 80px rgba(0, 0, 0, 0.5);
+ padding: 0.875rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ flex: 1;
}
-.landing-preview-titlebar {
+.landing-tc-card.before-card {
+ opacity: 0.55;
+}
+
+.landing-tc-card.after-card {
+ border-color: rgba(96, 165, 250, 0.28);
+ box-shadow: 0 0 36px rgba(96, 165, 250, 0.10), 0 16px 48px rgba(0, 0, 0, 0.55);
+}
+
+.landing-tc-header {
display: flex;
align-items: center;
- gap: 12px;
- padding: 10px 14px;
- background: rgba(255, 255, 255, 0.02);
+ justify-content: space-between;
+ padding-bottom: 0.5rem;
border-bottom: 1px solid var(--lp-border);
}
-.landing-preview-dots {
- display: flex;
- gap: 6px;
+.tc-ticket-id {
+ font-size: 0.65rem;
+ font-weight: 700;
+ color: var(--lp-text-secondary);
+ font-family: 'IBM Plex Sans', sans-serif;
}
-.landing-preview-dots span {
- width: 10px;
- height: 10px;
- border-radius: 50%;
- background: var(--lp-elevated);
+.tc-status {
+ font-size: 0.58rem;
+ font-weight: 600;
+ padding: 2px 7px;
+ border-radius: 100px;
+ letter-spacing: 0.02em;
}
-.landing-preview-dots span:first-child {
- background: #ef4444;
+.tc-status.open {
+ background: rgba(248, 113, 113, 0.1);
+ color: #f87171;
+ border: 1px solid rgba(248, 113, 113, 0.2);
}
-.landing-preview-dots span:nth-child(2) {
- background: #eab308;
-}
-
-.landing-preview-dots span:last-child {
- background: #22c55e;
-}
-
-.landing-preview-url {
- display: flex;
- align-items: center;
- gap: 6px;
- padding: 3px 10px;
- border-radius: 6px;
- background: rgba(255, 255, 255, 0.03);
- border: 1px solid var(--lp-border);
- font-size: 0.6rem;
- color: var(--lp-text-dim);
- flex: 1;
- max-width: 260px;
-}
-
-.landing-lock-icon {
+.tc-status.resolved {
+ background: rgba(52, 211, 153, 0.1);
color: var(--lp-success);
- font-size: 0.55rem;
+ border: 1px solid rgba(52, 211, 153, 0.2);
}
-.landing-preview-body {
- padding: 1.25rem;
- min-height: 260px;
+.landing-tc-subject {
+ font-size: 0.7rem;
+ font-weight: 600;
+ color: var(--lp-text-heading);
+ line-height: 1.4;
+}
+
+.landing-tc-notes-heading {
+ font-size: 0.58rem;
+ font-weight: 600;
+ color: var(--lp-text-dim);
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.landing-tc-notes {
+ display: flex;
+ flex-direction: column;
+ gap: 0.3rem;
+ flex: 1;
+}
+
+.landing-tc-notes.before-notes {
+ padding: 0.6rem 0.75rem;
+ border-radius: 5px;
+ background: rgba(255, 255, 255, 0.02);
+ border: 1px dashed rgba(255, 255, 255, 0.06);
+ font-size: 0.65rem;
+ color: var(--lp-text-dim);
+ font-style: italic;
+ min-height: 80px;
+ align-items: flex-start;
+ justify-content: flex-start;
+}
+
+.tc-note {
+ display: flex;
+ align-items: baseline;
+ gap: 5px;
+ font-size: 0.62rem;
+ color: var(--lp-text-secondary);
+ line-height: 1.5;
+ animation: tcNoteIn 0.4s cubic-bezier(0.22, 1, 0.36, 1) both;
+ animation-delay: calc(1.2s + var(--note-i, 0) * 0.55s);
+ opacity: 0;
+}
+
+@keyframes tcNoteIn {
+ from { opacity: 0; transform: translateY(5px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+.tc-note.resolution-note {
+ margin-top: 0.15rem;
+ padding-top: 0.35rem;
+ border-top: 1px solid var(--lp-border);
+}
+
+.tc-time {
+ font-size: 0.55rem;
+ color: var(--lp-text-dim);
+ font-family: 'JetBrains Mono', monospace;
+ flex-shrink: 0;
+ min-width: 28px;
+}
+
+.tc-check {
+ color: var(--lp-success);
+ flex-shrink: 0;
+ font-size: 0.6rem;
+}
+
+.tc-resolution-tag {
+ font-size: 0.52rem;
+ font-weight: 700;
+ padding: 1px 5px;
+ border-radius: 3px;
+ background: rgba(96, 165, 250, 0.12);
+ color: var(--lp-accent-text);
+ flex-shrink: 0;
+ letter-spacing: 0.02em;
+}
+
+.landing-tc-footer {
+ font-size: 0.58rem;
+ padding-top: 0.4rem;
+ border-top: 1px solid var(--lp-border);
+ line-height: 1.4;
+}
+
+.landing-tc-footer.before-footer {
+ color: var(--lp-text-dim);
+}
+
+.landing-tc-footer.after-footer {
+ color: var(--lp-accent-text);
+ font-weight: 500;
+}
+
+.landing-tc-divider {
display: flex;
align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ width: 32px;
+ padding-top: 1.4rem;
+ color: var(--lp-text-dim);
+}
+
+.landing-tc-divider svg {
+ width: 14px;
+ height: 14px;
}
/* ---- MOCK ELEMENTS ---- */
@@ -1255,79 +1412,6 @@
to { opacity: 1; transform: translateY(0) scale(1); }
}
-/* ---- CHAT ANIMATION ---- */
-.landing-chat-animated {
- opacity: 0;
- transform: translateX(-16px);
- animation: landingChatSlideIn 0.5s cubic-bezier(0.22, 1, 0.36, 1) both;
- animation-delay: calc(1.5s + var(--chat-index, 0) * 1.2s);
-}
-
-.landing-chat-animated:nth-child(2) {
- animation: landingTypingLifecycle 3s ease both;
- animation-delay: 2.7s;
-}
-
-.landing-chat-animated:nth-child(3) { animation-delay: 5.7s; }
-.landing-chat-animated:nth-child(4) { animation-delay: 6.7s; }
-.landing-chat-animated:nth-child(5) { animation-delay: 7.7s; }
-.landing-chat-animated:nth-child(6) { animation-delay: 9s; }
-
-@keyframes landingChatSlideIn {
- from { opacity: 0; transform: translateX(-16px); }
- to { opacity: 1; transform: translateX(0); }
-}
-
-@keyframes landingTypingLifecycle {
- 0% { opacity: 0; transform: translateX(-16px); }
- 10% { opacity: 1; transform: translateX(0); }
- 75% { opacity: 1; transform: translateX(0); }
- 90% { opacity: 0; transform: translateX(0); }
- 100% { opacity: 0; height: 0; padding: 0; margin: 0; overflow: hidden; }
-}
-
-.landing-typing-indicator {
- display: flex;
- align-items: center;
- gap: 5px;
- padding: 6px 10px;
- border-radius: 6px;
- background: var(--lp-accent-soft);
- border: 1px solid rgba(96, 165, 250, 0.1);
- width: fit-content;
-}
-
-.landing-typing-indicator span {
- display: block;
- width: 5px;
- height: 5px;
- border-radius: 50%;
- background: var(--lp-accent);
- opacity: 0.5;
- animation: landingTypingBounce 1s ease-in-out infinite;
-}
-
-.landing-typing-indicator span:nth-child(2) { animation-delay: 0.15s; }
-.landing-typing-indicator span:nth-child(3) { animation-delay: 0.3s; }
-
-.landing-typing-label {
- font-size: 0.6rem;
- color: var(--lp-accent);
- font-weight: 500;
- white-space: nowrap;
- /* Override dot styles */
- width: auto !important;
- height: auto !important;
- border-radius: 0 !important;
- background: transparent !important;
- opacity: 1 !important;
- animation: none !important;
-}
-
-@keyframes landingTypingBounce {
- 0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
- 30% { transform: translateY(-4px); opacity: 1; }
-}
/* ---- SCROLL REVEAL ---- */
.landing-reveal {
@@ -1617,7 +1701,6 @@
/* ---- REDUCED MOTION ---- */
@media (prefers-reduced-motion: reduce) {
- .landing-chat-animated,
.landing-hero-visual,
.landing-hero-content {
opacity: 1;
@@ -1625,13 +1708,10 @@
animation: none;
}
- .landing-typing-indicator span {
+ .tc-note {
+ opacity: 1;
+ transform: none;
animation: none;
- opacity: 0.6;
- }
-
- .landing-chat-animated:nth-child(2) {
- display: none;
}
.landing-reveal {
diff --git a/frontend/src/types/ai-session.ts b/frontend/src/types/ai-session.ts
index dd2ae7b4..c8f90886 100644
--- a/frontend/src/types/ai-session.ts
+++ b/frontend/src/types/ai-session.ts
@@ -196,7 +196,7 @@ export interface AISessionDetail extends AISessionSummary {
ticket_data: Record | null
steps: AISessionStepResponse[]
conversation_messages: Array<{ role: string; content: string }>
- pending_task_lane: { questions: QuestionItem[]; actions: ActionItem[] } | null
+ pending_task_lane: { questions: QuestionItem[]; actions: ActionItem[]; responses?: Array> } | null
is_branching: boolean
active_branch_id: string | null
}