Merge pull request #125 from resolutionflow/fix/task-lane-partial-submit

fix: resolve task lane stale state, partial submit, and closure bugs
This commit was merged in pull request #125.
This commit is contained in:
chihlasm
2026-04-06 16:31:41 -04:00
committed by GitHub
8 changed files with 388 additions and 228 deletions

1
.gitignore vendored
View File

@@ -233,3 +233,4 @@ package.json
package-lock.json
.worktrees/
.gstack/
.gitnexus

156
CLAUDE.md
View File

@@ -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 (001070) 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 <id> --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 <id> --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 <id> --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 8991 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 <id> --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 <id> --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:start -->
# 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: "<error or symptom>"})` — find execution flows related to the issue
2. `gitnexus_context({name: "<suspect function>"})` — 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` |
<!-- gitnexus:end -->

View File

@@ -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.
"""

View File

@@ -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<ReturnType<typeof setTimeout> | 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<Record<string, unknown>>,
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<Record<string, unknown>>,
}).catch(() => { /* silent — best-effort save */ })
}, 2000)
return () => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current) }

View File

@@ -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<string, unknown>).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({

View File

@@ -180,57 +180,6 @@ export default function LandingPage() {
Built by a 15-year MSP veteran who got tired of empty ticket notes.
</p>
</div>
<div className="landing-hero-visual">
<div className="landing-preview-window">
<div className="landing-preview-titlebar">
<div className="landing-preview-dots"><span /><span /><span /></div>
<div className="landing-preview-url">
<span className="landing-lock-icon">&#128274;</span>
app.resolutionflow.com/pilot
</div>
</div>
<div className="landing-preview-body">
<div className="landing-mock-session">
<div className="landing-chat-animated" style={{ '--chat-index': 0 } as React.CSSProperties}>
<div className="landing-mock-chat-line user">
<span className="label">You</span>
<span className="text">User can&apos;t access shared drive after password reset</span>
</div>
</div>
<div className="landing-chat-animated" style={{ '--chat-index': 1 } as React.CSSProperties}>
<div className="landing-typing-indicator">
<span /><span /><span />
<span className="landing-typing-label">FlowPilot is thinking&hellip;</span>
</div>
</div>
<div className="landing-chat-animated" style={{ '--chat-index': 2 } as React.CSSProperties}>
<div className="landing-mock-chat-line ai">
<span className="label">FlowPilot</span>
<span className="text">Likely a cached credential issue. Let&apos;s check:</span>
</div>
</div>
<div className="landing-chat-animated" style={{ '--chat-index': 3 } as React.CSSProperties}>
<div className="landing-mock-chat-line ai">
<span className="label">FlowPilot</span>
<span className="text">1. Run <code>klist purge</code> to clear Kerberos tickets</span>
</div>
</div>
<div className="landing-chat-animated" style={{ '--chat-index': 4 } as React.CSSProperties}>
<div className="landing-mock-chat-line ai">
<span className="label">FlowPilot</span>
<span className="text">2. Credential Manager &rarr; remove saved share entries</span>
</div>
</div>
<div className="landing-chat-animated" style={{ '--chat-index': 5 } as React.CSSProperties}>
<div className="landing-mock-chat-line doc">
<span className="label">Auto-doc</span>
<span className="text">3 steps captured &#10003;</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>

View File

@@ -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 {

View File

@@ -196,7 +196,7 @@ export interface AISessionDetail extends AISessionSummary {
ticket_data: Record<string, unknown> | 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<Record<string, unknown>> } | null
is_branching: boolean
active_branch_id: string | null
}