fix: resolve task lane stale state, partial submit, and closure bugs #125

Merged
chihlasm merged 4 commits from fix/task-lane-partial-submit into main 2026-04-06 20:31:41 +00:00
8 changed files with 388 additions and 228 deletions

1
.gitignore vendored
View File

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

156
CLAUDE.md
View File

@@ -1,6 +1,6 @@
# CLAUDE.md - Patherly / ResolutionFlow Project Context # CLAUDE.md - Patherly / ResolutionFlow Project Context
> **Last Updated:** March 27, 2026 > **Last Updated:** April 6, 2026
--- ---
@@ -16,7 +16,8 @@
| Context | Name Used | | 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** | | Backend, frontend UI, production URLs | **ResolutionFlow** |
- **Design system:** [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md) — THE source of truth for all design decisions - **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) - **Phase:** Go-to-Market Validation (Pre-PMF)
- **Backend:** Complete (55+ API endpoints, 100+ integration tests) - **Backend:** Complete (55+ API endpoints, 100+ integration tests)
- **Frontend:** Core features complete, Tree Editor functional - **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) - **Detailed status:** [CURRENT-STATE.md](CURRENT-STATE.md)
### What's In Progress ### What's In Progress
@@ -96,7 +97,7 @@ patherly/
│ │ ├── services/knowledge_flywheel.py # AI session analysis → flow proposals │ │ ├── services/knowledge_flywheel.py # AI session analysis → flow proposals
│ │ ├── services/knowledge_flywheel_scheduler.py # APScheduler job for batch analysis │ │ ├── services/knowledge_flywheel_scheduler.py # APScheduler job for batch analysis
│ │ └── services/knowledge_gap_service.py # Weak options & escalation signal detection │ │ └── 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 │ ├── scripts/ # seed_data.py, seed_trees.py
│ └── tests/ # pytest integration tests │ └── tests/ # pytest integration tests
├── frontend/ ├── frontend/
@@ -188,8 +189,8 @@ Official ConnectWise developer guides live in `docs/connectwise/best-practices/`
## Development Commands ## Development Commands
```powershell ```powershell
# Start PostgreSQL # Start PostgreSQL (run from VPS SSH — docker not available inside code-server, see Lesson 103)
docker start patherly_postgres docker start resolutionflow_postgres
# Backend (from backend/) # Backend (from backend/)
source venv/bin/activate # Linux/Mac source venv/bin/activate # Linux/Mac
@@ -203,21 +204,19 @@ npm run dev
pytest --override-ini="addopts=" pytest --override-ini="addopts="
# First time only: create test database # 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) # Frontend build (IMPORTANT: stricter than tsc --noEmit — always use as final check)
cd frontend && npm run build cd frontend && npm run build
# Database migrations # Database migrations
cd backend && alembic upgrade head cd backend && alembic upgrade head
alembic revision --autogenerate -m "Description" --rev-id=NNN # NNN = next sequential number alembic revision --autogenerate -m "Description"
# IMPORTANT: Migrations use sequential 3-digit IDs (001, 002, ..., 068, 069). # Sequential 3-digit IDs (001070) were used historically. New migrations use Alembic's default hex hash IDs.
# Check the latest: ls backend/alembic/versions/ | grep -E '^\d{3}_' | sort | tail -1 # Do NOT pass --rev-id — let Alembic generate the hash automatically.
# 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.
# Access PostgreSQL # Access PostgreSQL (run from VPS SSH — docker not available inside code-server, see Lesson 103)
docker exec -it patherly_postgres psql -U postgres -d patherly docker exec -it resolutionflow_postgres psql -U postgres -d resolutionflow
# Seed data # Seed data
cd backend && pip install httpx && python -m scripts.seed_trees 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. **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. **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`. **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. **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. **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. **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`. **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. **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. **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. - **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` (`#1a1c23`), `bg-sidebar` (`#10121a`), `bg-card` (`#22252e`), `bg-elevated` (`#2e3140`) - **Backgrounds:** `bg-page` (`#16181f`), `bg-sidebar` (`#0e1016`), `bg-card` (`#1e2028`), `bg-elevated` (`#2a2d38`)
- **Cards:** `bg-card` with 1px `border-default` (`#2e3240`), 8px radius. No shadows, no blur, no gradients. Hover: `border-hover` (`#3d4252`) - **Cards:** `bg-card` with 1px `border-default` (`#2a2e3a`), 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` - **Buttons:** Primary: solid `accent` (#60a5fa dark / #2563eb light), 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` - **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 (#2e3140), not a text color. - **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` (`#2e3240`), `border-hover` (`#3d4252`) - **Borders:** `border-default` (`#2a2e3a`), `border-hover` (`#3d4252`)
- **Functional colors:** `#34d399` (success), `#eab308` (warning), `#f87171` (danger) — each with `-dim` variant at 10% opacity - **Functional colors:** `#34d399` (success), `#fbbf24` (warning/amber), `#f87171` (danger), `#67e8f9` (info/cyan) — 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` - **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, or cyan accent (`#22d3ee`) - **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 | | Bugs & Fixes | CLAUDE.md → Critical Lessons Learned section |
| Design System | [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md) | | Design System | [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md) |
| Dev Environment | [DEV-ENV.md](DEV-ENV.md) — 46.202.92.250 setup, Docker, CORS, networking | | 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) - JSON array of objects with `text` (required) and `context` (optional, 1 sentence)
- 1-3 questions per response - 1-3 questions per response
- Do NOT ask questions inline in your prose. ALL questions go in the marker. - 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:** **[ACTIONS] marker format:**
- JSON array of objects with `label` (required), `command` (optional), `description` (required) - 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. \ 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 \ 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. 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]) }, [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 // Save task state to sessionStorage on every change + debounce to backend
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null) const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => { useEffect(() => {
@@ -139,9 +147,9 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
if (saveTimerRef.current) clearTimeout(saveTimerRef.current) if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
saveTimerRef.current = setTimeout(() => { saveTimerRef.current = setTimeout(() => {
aiSessionsApi.saveTaskLane(sessionId, { aiSessionsApi.saveTaskLane(sessionId, {
questions: questions.map(q => ({ text: q.text, context: q.context })), questions: questionsRef.current.map(q => ({ text: q.text, context: q.context })),
actions: actions.map(a => ({ label: a.label, command: a.command, description: a.description })), actions: actionsRef.current.map(a => ({ label: a.label, command: a.command, description: a.description })),
responses: tasks as unknown as Array<Record<string, unknown>>, responses: tasksRef.current as unknown as Array<Record<string, unknown>>,
}).catch(() => { /* silent — best-effort save */ }) }).catch(() => { /* silent — best-effort save */ })
}, 2000) }, 2000)
return () => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current) } return () => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current) }

View File

@@ -12,7 +12,7 @@ import { analytics } from '@/lib/analytics'
import { toast } from '@/lib/toast' import { toast } from '@/lib/toast'
import { ChatSidebar, ChatSidebarCollapsedBar } from '@/components/assistant/ChatSidebar' import { ChatSidebar, ChatSidebarCollapsedBar } from '@/components/assistant/ChatSidebar'
import { ChatMessage } from '@/components/assistant/ChatMessage' 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 { ConcludeSessionModal } from '@/components/assistant/ConcludeSessionModal'
import { StatusUpdateModal } from '@/components/flowpilot/StatusUpdateModal' import { StatusUpdateModal } from '@/components/flowpilot/StatusUpdateModal'
import type { ChatListItem, ConclusionOutcome } from '@/types/assistant-chat' import type { ChatListItem, ConclusionOutcome } from '@/types/assistant-chat'
@@ -242,7 +242,7 @@ export default function AssistantChatPage() {
if (q.length > 0 || a.length > 0) { if (q.length > 0 || a.length > 0) {
// Pre-load user's saved responses into sessionStorage BEFORE setting props // Pre-load user's saved responses into sessionStorage BEFORE setting props
// so TaskLane can restore them on mount/prop-change // 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) { if (responses && responses.length > 0) {
try { try {
sessionStorage.setItem(`rf-tasklane-state:${chatId}`, JSON.stringify(responses)) sessionStorage.setItem(`rf-tasklane-state:${chatId}`, JSON.stringify(responses))
@@ -259,6 +259,11 @@ export default function AssistantChatPage() {
}, []) }, [])
const handleNewChat = async () => { const handleNewChat = async () => {
// Clear stale state immediately — don't wait for API to return
setShowTaskLane(false)
setActiveQuestions([])
setActiveActions([])
setMessages([])
try { try {
const session = await aiSessionsApi.createChatSession({ const session = await aiSessionsApi.createChatSession({
intake_type: 'free_text', intake_type: 'free_text',
@@ -275,11 +280,6 @@ export default function AssistantChatPage() {
currentChatRef.current = session.session_id currentChatRef.current = session.session_id
setChats(prev => [chatItem, ...prev]) setChats(prev => [chatItem, ...prev])
setActiveChatId(session.session_id) setActiveChatId(session.session_id)
setMessages([])
// Clear TaskLane from previous session
setShowTaskLane(false)
setActiveQuestions([])
setActiveActions([])
} catch { } catch {
toast.error('Failed to create chat') toast.error('Failed to create chat')
} }
@@ -315,11 +315,14 @@ export default function AssistantChatPage() {
setMessages(prev => [...prev, { role: 'user', content: userMessage }]) setMessages(prev => [...prev, { role: 'user', content: userMessage }])
setLoading(true) setLoading(true)
const sentForChatId = activeChatId
try { try {
const response = await aiSessionsApi.sendChatMessage(activeChatId, { const response = await aiSessionsApi.sendChatMessage(activeChatId, {
message: userMessage, message: userMessage,
upload_ids: completedUploadIds.length > 0 ? completedUploadIds : undefined, 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' }) analytics.aiFeatureUsed({ feature: 'assistant_chat' })
setMessages(prev => [ setMessages(prev => [
...prev, ...prev,
@@ -327,19 +330,20 @@ export default function AssistantChatPage() {
]) ])
setChats(prev => setChats(prev =>
prev.map(c => 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, message_count: c.message_count + 2, title: c.message_count === 0 ? userMessage.slice(0, 100) : c.title, updated_at: new Date().toISOString() }
: c : c
) )
) )
// Load branches if fork was created // Load branches if fork was created
if (response.fork && activeChatId) { if (response.fork && sentForChatId) {
branching.loadBranches(activeChatId) branching.loadBranches(sentForChatId)
} }
// Show task lane if AI sent questions or actions // Show task lane if AI sent questions or actions
const hasQuestions = response.questions && response.questions.length > 0 const hasQuestions = response.questions && response.questions.length > 0
const hasActions = response.actions && response.actions.length > 0 const hasActions = response.actions && response.actions.length > 0
if (hasQuestions || hasActions) { if (hasQuestions || hasActions) {
clearTaskState(sentForChatId)
setActiveQuestions(response.questions || []) setActiveQuestions(response.questions || [])
setActiveActions(response.actions || []) setActiveActions(response.actions || [])
setShowTaskLane(true) 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 }>) => { const handleTaskSubmit = async (responses: Array<{ type: string; state: string; value: string; text?: string; label?: string }>) => {
if (!activeChatId || loading) return 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[] = [] const parts: string[] = []
for (const r of responses) { for (const r of responses) {
const name = r.type === 'question' ? `Q: ${r.text}` : r.label || 'Check' 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\`\`\``) parts.push(`**${name}:**\n\`\`\`\n${r.value.trim()}\n\`\`\``)
} else if (r.state === 'skipped') { } else if (r.state === 'skipped') {
parts.push(`**${name}:** _(skipped)_`) parts.push(`**${name}:** _(skipped)_`)
} else {
parts.push(`**${name}:** _(not yet completed)_`)
} }
} }
const userMessage = parts.join('\n\n') const userMessage = parts.join('\n\n')
@@ -373,18 +380,22 @@ export default function AssistantChatPage() {
setMessages(prev => [...prev, { role: 'user', content: userMessage }]) setMessages(prev => [...prev, { role: 'user', content: userMessage }])
setLoading(true) setLoading(true)
const sentForChatId = activeChatId
try { try {
const response = await aiSessionsApi.sendChatMessage(activeChatId, { message: userMessage }) 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 => [ setMessages(prev => [
...prev, ...prev,
{ role: 'assistant', content: response.content, suggestedFlows: response.suggested_flows, fork: response.fork, actions: response.actions, questions: response.questions }, { role: 'assistant', content: response.content, suggestedFlows: response.suggested_flows, fork: response.fork, actions: response.actions, questions: response.questions },
]) ])
if (response.fork && activeChatId) { if (response.fork && sentForChatId) {
branching.loadBranches(activeChatId) branching.loadBranches(sentForChatId)
} }
// Update task lane based on AI response // Update task lane based on AI response
const hasQuestions = response.questions && response.questions.length > 0 const hasQuestions = response.questions && response.questions.length > 0
const hasActions = response.actions && response.actions.length > 0 const hasActions = response.actions && response.actions.length > 0
clearTaskState(sentForChatId)
if (hasQuestions || hasActions) { if (hasQuestions || hasActions) {
setActiveQuestions(response.questions || []) setActiveQuestions(response.questions || [])
setActiveActions(response.actions || []) setActiveActions(response.actions || [])
@@ -425,6 +436,10 @@ export default function AssistantChatPage() {
} }
const handleResumeNew = async (summary: string) => { const handleResumeNew = async (summary: string) => {
// Clear stale state immediately — don't wait for API to return
setShowTaskLane(false)
setActiveQuestions([])
setActiveActions([])
try { 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 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({ 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. Built by a 15-year MSP veteran who got tired of empty ticket notes.
</p> </p>
</div> </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> </div>
</section> </section>

View File

@@ -74,9 +74,8 @@
} }
.landing-nav.scrolled { .landing-nav.scrolled {
background: rgba(20, 22, 29, 0.95); background: #0d0f15;
border-bottom: 1px solid var(--lp-border); border-bottom: 1px solid var(--lp-border);
backdrop-filter: blur(8px);
} }
.landing-nav-inner { .landing-nav-inner {
@@ -230,19 +229,44 @@
/* ---- HERO ---- */ /* ---- HERO ---- */
.landing-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 { .landing-hero-inner {
max-width: 1200px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
display: grid; display: flex;
grid-template-columns: 1fr 1fr; align-items: flex-start;
gap: 4rem; position: relative;
align-items: center; z-index: 2;
} }
.landing-hero-content { .landing-hero-content {
max-width: 520px;
animation: landingFadeInUp 0.8s ease-out; animation: landingFadeInUp 0.8s ease-out;
} }
@@ -286,7 +310,7 @@
} }
.landing-hero-accent { .landing-hero-accent {
color: var(--lp-accent-text); color: var(--lp-accent);
} }
.landing-hero-sub { .landing-hero-sub {
@@ -359,72 +383,205 @@
animation: landingPreviewEntrance 1s cubic-bezier(0.22, 1, 0.36, 1) 0.3s both; animation: landingPreviewEntrance 1s cubic-bezier(0.22, 1, 0.36, 1) 0.3s both;
} }
/* ---- PREVIEW WINDOW ---- */ /* ---- TICKET COMPARISON (hero visual) ---- */
.landing-preview-window { .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-radius: 8px;
border: 1px solid var(--lp-border); border: 1px solid var(--lp-border);
background: var(--lp-card); background: var(--lp-card);
overflow: hidden; padding: 0.875rem;
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.5); 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; display: flex;
align-items: center; align-items: center;
gap: 12px; justify-content: space-between;
padding: 10px 14px; padding-bottom: 0.5rem;
background: rgba(255, 255, 255, 0.02);
border-bottom: 1px solid var(--lp-border); border-bottom: 1px solid var(--lp-border);
} }
.landing-preview-dots { .tc-ticket-id {
display: flex; font-size: 0.65rem;
gap: 6px; font-weight: 700;
color: var(--lp-text-secondary);
font-family: 'IBM Plex Sans', sans-serif;
} }
.landing-preview-dots span { .tc-status {
width: 10px; font-size: 0.58rem;
height: 10px; font-weight: 600;
border-radius: 50%; padding: 2px 7px;
background: var(--lp-elevated); border-radius: 100px;
letter-spacing: 0.02em;
} }
.landing-preview-dots span:first-child { .tc-status.open {
background: #ef4444; 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) { .tc-status.resolved {
background: #eab308; background: rgba(52, 211, 153, 0.1);
}
.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 {
color: var(--lp-success); color: var(--lp-success);
font-size: 0.55rem; border: 1px solid rgba(52, 211, 153, 0.2);
} }
.landing-preview-body { .landing-tc-subject {
padding: 1.25rem; font-size: 0.7rem;
min-height: 260px; 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; display: flex;
align-items: center; 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 ---- */ /* ---- MOCK ELEMENTS ---- */
@@ -1255,79 +1412,6 @@
to { opacity: 1; transform: translateY(0) scale(1); } 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 ---- */ /* ---- SCROLL REVEAL ---- */
.landing-reveal { .landing-reveal {
@@ -1617,7 +1701,6 @@
/* ---- REDUCED MOTION ---- */ /* ---- REDUCED MOTION ---- */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.landing-chat-animated,
.landing-hero-visual, .landing-hero-visual,
.landing-hero-content { .landing-hero-content {
opacity: 1; opacity: 1;
@@ -1625,13 +1708,10 @@
animation: none; animation: none;
} }
.landing-typing-indicator span { .tc-note {
opacity: 1;
transform: none;
animation: none; animation: none;
opacity: 0.6;
}
.landing-chat-animated:nth-child(2) {
display: none;
} }
.landing-reveal { .landing-reveal {

View File

@@ -196,7 +196,7 @@ export interface AISessionDetail extends AISessionSummary {
ticket_data: Record<string, unknown> | null ticket_data: Record<string, unknown> | null
steps: AISessionStepResponse[] steps: AISessionStepResponse[]
conversation_messages: Array<{ role: string; content: string }> 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 is_branching: boolean
active_branch_id: string | null active_branch_id: string | null
} }