From df46046c86865fcaa3cf66b5b91d81ffaf606f9b Mon Sep 17 00:00:00 2001 From: chihlasm Date: Sun, 8 Mar 2026 15:43:22 -0400 Subject: [PATCH 1/7] fix: resolve MissingGreenlet crash and add MCP fallback in AI Assistant Capture user_id/account_id before try block so error handler survives db.rollback() without triggering lazy loads in async context. Add retry-without-MCP fallback when Anthropic MCP server returns rate limit or connection errors. Fixes PYTHON-FASTAPI-3, PYTHON-FASTAPI-4 Co-Authored-By: Claude Opus 4.6 --- backend/app/api/endpoints/assistant_chat.py | 18 +++++++---- .../app/services/assistant_chat_service.py | 32 +++++++++++++------ 2 files changed, 35 insertions(+), 15 deletions(-) diff --git a/backend/app/api/endpoints/assistant_chat.py b/backend/app/api/endpoints/assistant_chat.py index 83422367..3bcc1f5e 100644 --- a/backend/app/api/endpoints/assistant_chat.py +++ b/backend/app/api/endpoints/assistant_chat.py @@ -145,11 +145,17 @@ async def post_message( plan = await get_user_plan(current_user.account_id, db) + # Capture scalar fields before the try block — after db.rollback() + # the ORM objects are expired and accessing attributes triggers a + # lazy load, which crashes in async context (MissingGreenlet). + user_id = current_user.id + account_id = current_user.account_id + try: ai_content, suggested_flows, chat = await assistant_chat_service.send_message( chat_id=chat_id, - user_id=current_user.id, - account_id=current_user.account_id, + user_id=user_id, + account_id=account_id, message=data.message, db=db, ) @@ -159,8 +165,8 @@ async def post_message( logger.exception("Assistant chat message failed: %s", e) await db.rollback() await record_ai_usage( - user_id=current_user.id, - account_id=current_user.account_id, + user_id=user_id, + account_id=account_id, conversation_id=None, generation_type="assistant_message", tier=plan, @@ -180,8 +186,8 @@ async def post_message( ) await record_ai_usage( - user_id=current_user.id, - account_id=current_user.account_id, + user_id=user_id, + account_id=account_id, conversation_id=None, generation_type="assistant_message", tier=plan, diff --git a/backend/app/services/assistant_chat_service.py b/backend/app/services/assistant_chat_service.py index 86390608..9508fe56 100644 --- a/backend/app/services/assistant_chat_service.py +++ b/backend/app/services/assistant_chat_service.py @@ -189,15 +189,29 @@ async def _call_anthropic_cached( } ] - response = await client.beta.messages.create( - model=settings.AI_MODEL_ANTHROPIC, - max_tokens=max_tokens, - system=system_blocks, - messages=messages, - mcp_servers=mcp_servers, - tools=tools, - betas=["mcp-client-2025-11-20"], - ) + try: + response = await client.beta.messages.create( + model=settings.AI_MODEL_ANTHROPIC, + max_tokens=max_tokens, + system=system_blocks, + messages=messages, + mcp_servers=mcp_servers, + tools=tools, + betas=["mcp-client-2025-11-20"], + ) + except anthropic.BadRequestError as e: + # MCP server failures (rate limits, connection errors) should not + # block the assistant entirely — retry without MCP tools. + if "MCP server" in str(e) and mcp_servers is not anthropic.NOT_GIVEN: + logger.warning("MCP server error, retrying without MCP: %s", e) + response = await client.beta.messages.create( + model=settings.AI_MODEL_ANTHROPIC, + max_tokens=max_tokens, + system=system_blocks, + messages=messages, + ) + else: + raise # Extract text from response — MCP responses can have multiple block # types (text, mcp_tool_use, mcp_tool_result). We join all text blocks. From 2a2894496d8b9ab57462bc604ca4797485cda169 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Sun, 8 Mar 2026 17:41:22 -0400 Subject: [PATCH 2/7] fix: use session_id instead of id in AI flow builder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CreateFlowDropdown accessed `session.id` which is undefined — the API returns `session_id`. The undefined value caused "undefined" to be interpolated into URL paths, triggering 422 validation errors from FastAPI. Co-Authored-By: Claude Opus 4.6 --- frontend/src/components/common/CreateFlowDropdown.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/common/CreateFlowDropdown.tsx b/frontend/src/components/common/CreateFlowDropdown.tsx index 85816601..37586ddb 100644 --- a/frontend/src/components/common/CreateFlowDropdown.tsx +++ b/frontend/src/components/common/CreateFlowDropdown.tsx @@ -30,7 +30,7 @@ export function CreateFlowDropdown({ const session = await editorAIApi.startSession( aiPromptFlowType === 'maintenance' ? 'procedural' : aiPromptFlowType ) - const sessionId = session.id + const sessionId = session.session_id // Send the user's prompt await editorAIApi.sendMessage({ From 4d2f644bacba36269ec3b4078278da97ac0cb60f Mon Sep 17 00:00:00 2001 From: chihlasm Date: Mon, 9 Mar 2026 00:03:29 -0400 Subject: [PATCH 3/7] feat: maximize Sentry free plan coverage for frontend and backend - ErrorBoundary: use Sentry.ErrorBoundary with crash feedback dialog - RouteError: capture route errors in Sentry (skip chunk load errors) - User context: set Sentry user on login (frontend + backend) - Backend: enable profiling (profiles_sample_rate) - Frontend: add feedback integration, lower replay rate to conserve quota - Add temporary verification message for production validation Co-Authored-By: Claude Opus 4.6 --- backend/app/api/deps.py | 4 + backend/app/main.py | 2 + .../src/components/common/ErrorBoundary.tsx | 95 +++++++++---------- frontend/src/components/common/RouteError.tsx | 8 ++ frontend/src/instrument.ts | 15 ++- frontend/src/store/authStore.ts | 5 + 6 files changed, 77 insertions(+), 52 deletions(-) diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index 4cdd8a94..f17858de 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -4,6 +4,7 @@ from fastapi import Depends, HTTPException, Request, status from fastapi.security import OAuth2PasswordBearer from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select +import sentry_sdk from app.core.database import get_db from app.core.security import decode_token @@ -92,6 +93,9 @@ async def get_current_active_user( detail="password_change_required" ) + # Set Sentry user context for error attribution + sentry_sdk.set_user({"id": str(current_user.id), "email": current_user.email}) + # Lightweight trial expiry check if current_user.account_id: from app.models.subscription import Subscription diff --git a/backend/app/main.py b/backend/app/main.py index ccf3feb8..61e6a4e7 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -17,6 +17,8 @@ if settings.SENTRY_DSN: environment="development" if settings.DEBUG else "production", send_default_pii=True, traces_sample_rate=1.0 if settings.DEBUG else 0.2, + # Profiling — included in free plan + profiles_sample_rate=1.0 if settings.DEBUG else 0.2, # Filter out noisy health check transactions traces_sampler=lambda ctx: ( 0.0 if ctx.get("transaction_context", {}).get("name", "").startswith("GET /health") else None diff --git a/frontend/src/components/common/ErrorBoundary.tsx b/frontend/src/components/common/ErrorBoundary.tsx index e2f4a371..7d9b327d 100644 --- a/frontend/src/components/common/ErrorBoundary.tsx +++ b/frontend/src/components/common/ErrorBoundary.tsx @@ -1,60 +1,55 @@ -import { Component, type ReactNode } from 'react' +import * as Sentry from '@sentry/react' +import { type ReactNode } from 'react' import { Button } from '@/components/ui/Button' +interface FallbackProps { + error: Error + resetError: () => void +} + +function DefaultFallback({ error, resetError }: FallbackProps) { + return ( +
+
+

+ Something went wrong +

+

+ An unexpected error occurred. Please try refreshing the page. +

+
+          {error.message}
+        
+
+ + +
+
+
+ ) +} + interface Props { children: ReactNode fallback?: ReactNode } -interface State { - hasError: boolean - error: Error | null -} - -export class ErrorBoundary extends Component { - constructor(props: Props) { - super(props) - this.state = { hasError: false, error: null } - } - - static getDerivedStateFromError(error: Error): State { - return { hasError: true, error } - } - - componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { - console.error('ErrorBoundary caught an error:', error, errorInfo) - } - - render() { - if (this.state.hasError) { - if (this.props.fallback) { - return this.props.fallback - } - - return ( -
-
-

- Something went wrong -

-

- An unexpected error occurred. Please try refreshing the page. -

- {this.state.error && ( -
-                {this.state.error.message}
-              
- )} - -
-
- ) - } - - return this.props.children - } +export function ErrorBoundary({ children, fallback }: Props) { + return ( + { + if (fallback) return fallback as React.ReactElement + return + }} + showDialog + > + {children} + + ) } export default ErrorBoundary diff --git a/frontend/src/components/common/RouteError.tsx b/frontend/src/components/common/RouteError.tsx index 01931aea..45ba1d71 100644 --- a/frontend/src/components/common/RouteError.tsx +++ b/frontend/src/components/common/RouteError.tsx @@ -1,5 +1,6 @@ import { useEffect } from 'react' import { useRouteError, isRouteErrorResponse, useNavigate } from 'react-router-dom' +import * as Sentry from '@sentry/react' import { Button } from '@/components/ui/Button' function isChunkLoadError(error: unknown): boolean { @@ -19,6 +20,13 @@ export function RouteError() { const error = useRouteError() const navigate = useNavigate() + // Report route errors to Sentry (skip chunk load errors — those are deploy artifacts) + useEffect(() => { + if (error && !isChunkLoadError(error)) { + Sentry.captureException(error) + } + }, [error]) + // Auto-reload once on chunk load failures (stale deploy) useEffect(() => { if (isChunkLoadError(error)) { diff --git a/frontend/src/instrument.ts b/frontend/src/instrument.ts index 0e484d23..7fed0cc9 100644 --- a/frontend/src/instrument.ts +++ b/frontend/src/instrument.ts @@ -10,6 +10,11 @@ Sentry.init({ maskAllText: false, blockAllMedia: false, }), + // Crash feedback dialog — prompts users after unhandled errors + Sentry.feedbackIntegration({ + autoInject: false, + colorScheme: "dark", + }), ], // Tracing — capture 100% in dev, 20% in production @@ -19,7 +24,13 @@ Sentry.init({ /^https:\/\/api\.resolutionflow\.com/, ], - // Session Replay — record 10% of sessions, 100% of error sessions - replaysSessionSampleRate: 0.1, + // Session Replay — conserve free-plan quota + // 1% of normal sessions, 100% of error sessions + replaysSessionSampleRate: import.meta.env.PROD ? 0.01 : 0.0, replaysOnErrorSampleRate: 1.0, }); + +// TODO: Remove after verifying Sentry is receiving frontend events +if (import.meta.env.PROD) { + Sentry.captureMessage("ResolutionFlow frontend Sentry verification", "info"); +} diff --git a/frontend/src/store/authStore.ts b/frontend/src/store/authStore.ts index a883f6d8..8ada3ccc 100644 --- a/frontend/src/store/authStore.ts +++ b/frontend/src/store/authStore.ts @@ -1,5 +1,6 @@ import { create } from 'zustand' import { persist } from 'zustand/middleware' +import * as Sentry from '@sentry/react' import type { User, Token, UserCreate, UserLogin, Account, SubscriptionDetails } from '@/types' import { authApi } from '@/api/auth' import { apiClient } from '@/api/client' @@ -81,6 +82,7 @@ export const useAuthStore = create()( localStorage.removeItem('access_token') localStorage.removeItem('refresh_token') clearCachedQuota() + Sentry.setUser(null) set({ user: null, token: null, account: null, subscription: null, isAuthenticated: false, error: null }) } }, @@ -104,6 +106,9 @@ export const useAuthStore = create()( throw reason } + // Set Sentry user context for error attribution + Sentry.setUser({ id: user.id, email: user.email }) + set({ user, account, subscription, isLoading: false }) } catch (error: unknown) { const message = error instanceof Error ? error.message : 'Failed to fetch user' From 5885888489d847080a99de4d8a78fa0bff1847f9 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Mon, 9 Mar 2026 00:13:16 -0400 Subject: [PATCH 4/7] fix: use captureException for Sentry verification instead of captureMessage Co-Authored-By: Claude Opus 4.6 --- frontend/src/instrument.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/instrument.ts b/frontend/src/instrument.ts index 7fed0cc9..70a3f5db 100644 --- a/frontend/src/instrument.ts +++ b/frontend/src/instrument.ts @@ -32,5 +32,5 @@ Sentry.init({ // TODO: Remove after verifying Sentry is receiving frontend events if (import.meta.env.PROD) { - Sentry.captureMessage("ResolutionFlow frontend Sentry verification", "info"); + Sentry.captureException(new Error("ResolutionFlow frontend Sentry verification")); } From a900408c3beb84db004baf0259757a0c3e0eb5b8 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Mon, 9 Mar 2026 00:36:14 -0400 Subject: [PATCH 5/7] fix: hardcode Sentry DSN to ensure it's available at build time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vite env vars must be present during build — VITE_SENTRY_DSN was likely undefined in Railway's build step, causing Sentry to silently not init. DSN is a public client key (shipped in every browser bundle), not a secret. Co-Authored-By: Claude Opus 4.6 --- frontend/src/instrument.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/instrument.ts b/frontend/src/instrument.ts index 70a3f5db..d59877dc 100644 --- a/frontend/src/instrument.ts +++ b/frontend/src/instrument.ts @@ -1,7 +1,7 @@ import * as Sentry from "@sentry/react"; Sentry.init({ - dsn: import.meta.env.VITE_SENTRY_DSN, + dsn: "https://23937b8c0cea2484f6a9d5b97d0b7d4b@o4511005918887936.ingest.us.sentry.io/4511005926883328", environment: import.meta.env.MODE, integrations: [ From d06abe5829b1af30e48eaf8474121ceb3670ffc0 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Mon, 9 Mar 2026 04:36:37 -0400 Subject: [PATCH 6/7] fix: restore tree editor visibility after Tailwind v4 upgrade and add Sentry DSN build arg - Add missing `flex` class on TreeEditorPage editor wrapper (collapsed canvas to 0 height) - Rewrite React Flow CSS overrides to use --xy-* custom properties (v12 compat with TW4) - Move React Flow CSS import from component to index.css (CSS layer ordering) - Add VITE_SENTRY_DSN build arg to Dockerfile for Railway builds - Use env var for Sentry DSN in instrument.ts with hardcoded fallback - Add lessons learned #53-55 to CLAUDE.md Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 6 ++ frontend/Dockerfile | 4 +- frontend/src/components/layout/AppLayout.tsx | 2 +- frontend/src/index.css | 78 +++++++++++--------- frontend/src/instrument.ts | 2 +- frontend/src/pages/TreeEditorPage.tsx | 4 +- 6 files changed, 55 insertions(+), 41 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 341b070f..6c68b1da 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -337,6 +337,12 @@ navigate(`/trees/${newTree.id}/edit`) **52. Mobile scroll-to-top — use `scrollIntoView`, not `window.scrollTo`:** Mobile browsers (iOS Safari, Firefox Android) often ignore `window.scrollTo()`. Use a ref at the top of the page and call `ref.current.scrollIntoView({ behavior: 'smooth', block: 'start' })` instead. Trigger via `useEffect` on the state change (not inline with `setState`) so the DOM has committed before scrolling. +**53. Flex height chain — every ancestor must be a flex container for `flex-1` to work:** If a child uses `flex-1` to fill its parent, the parent MUST have `display: flex` (the `flex` class). A missing `flex` on any wrapper div breaks the entire height chain, causing React Flow (and any `h-full` descendant) to collapse to 0 height. Debug with: `let el = document.querySelector('.react-flow'); while(el) { console.log(el.getBoundingClientRect().height, el.className); el = el.parentElement; }`. The break is where height drops to 0. Common symptom: React Flow error `"parent container needs a width and a height"`. + +**54. React Flow CSS in Tailwind v4 — import in `index.css`, not component JS:** With `@tailwindcss/vite`, importing `@xyflow/react/dist/style.css` inside a component file causes the plugin to process/wrap it in a cascade layer, lowering specificity. Import it in `index.css` instead: `@import '@xyflow/react/dist/style.css';` after `@import 'tailwindcss';`. Override dark theme using `--xy-*` CSS custom properties (e.g., `--xy-edge-stroke-default`) on `.react-flow.dark`, NOT old-style direct property selectors like `.react-flow__edge-path { stroke: ... }`. + +**55. App shell height chain for full-height pages (tree editor, procedural editor):** The CSS Grid app shell (`app-shell`) → `.main-content` → page component chain must preserve height. `.main-content` is a grid cell with implicit height from `1fr`. Pages using React Flow or other full-height layouts need every wrapper div between `.main-content` and the canvas to either use `flex` + `flex-1` + `min-h-0` or explicit `h-full`. Adding ANY wrapper div (e.g., for animations, transitions) without proper height classes will collapse the canvas to 0. + --- ## RBAC & Permissions diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 675bcfd4..4fbdf2b1 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -12,9 +12,11 @@ RUN npm ci # Copy source code COPY . . -# Build argument for API URL (set at build time) +# Build arguments (set at build time) ARG VITE_API_URL +ARG VITE_SENTRY_DSN ENV VITE_API_URL=$VITE_API_URL +ENV VITE_SENTRY_DSN=$VITE_SENTRY_DSN # Build the application RUN npm run build diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx index d56ddcfb..a5942fe5 100644 --- a/frontend/src/components/layout/AppLayout.tsx +++ b/frontend/src/components/layout/AppLayout.tsx @@ -183,7 +183,7 @@ export function AppLayout() { )} {/* Main Content */} -
+
diff --git a/frontend/src/index.css b/frontend/src/index.css index bdd0bbe7..8f0ddb51 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -2,6 +2,11 @@ @custom-variant dark (&:where(.dark, .dark *)); +/* React Flow — imported here (not in component JS) so @tailwindcss/vite + doesn't wrap it in a cascade layer, which would lower its specificity + below Tailwind's own styles and hide nodes/edges. */ +@import '@xyflow/react/dist/style.css'; + @theme { /* ── Brand tokens ─────────────────────────────────── */ --color-brand-gradient-from: oklch(0.65 0.13 195); /* #06b6d4 cyan-500 */ @@ -364,58 +369,59 @@ } /* ── React Flow dark theme overrides ─────────────────── */ -.react-flow__background { - background-color: transparent !important; +/* React Flow v12 uses --xy-* CSS custom properties for theming. + Override the defaults to match our Slate & Ice design system. */ +.react-flow.dark { + --xy-background-color-default: transparent; + --xy-edge-stroke-default: var(--color-border); + --xy-edge-stroke-selected-default: var(--color-primary); + --xy-edge-label-color-default: var(--color-muted-foreground); + --xy-edge-label-background-color-default: var(--color-card); + --xy-node-background-color-default: var(--color-card); + --xy-node-color-default: var(--color-foreground); + --xy-node-border-default: 1px solid var(--color-border); + --xy-handle-background-color-default: var(--color-border); + --xy-handle-border-color-default: var(--color-card); + --xy-minimap-background-color-default: var(--color-card); + --xy-minimap-mask-background-color-default: oklch(0.22 0.008 264 / 0.6); + --xy-controls-button-background-color-default: var(--color-card); + --xy-controls-button-background-color-hover-default: var(--color-accent); + --xy-controls-button-color-default: var(--color-muted-foreground); + --xy-controls-button-color-hover-default: var(--color-foreground); + --xy-controls-button-border-color-default: var(--color-border); } .react-flow__controls { - background-color: var(--color-card) !important; - border: 1px solid var(--color-border) !important; border-radius: 0.75rem !important; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3) !important; overflow: hidden; } -.react-flow__controls-button { - background-color: var(--color-card) !important; - border-color: var(--color-border) !important; - fill: var(--color-muted-foreground) !important; - color: var(--color-muted-foreground) !important; -} - -.react-flow__controls-button:hover { - background-color: var(--color-accent) !important; - fill: var(--color-foreground) !important; -} - -.react-flow__controls-button svg { - fill: inherit !important; -} - .react-flow__minimap { - background-color: var(--color-card) !important; - border: 1px solid var(--color-border) !important; border-radius: 0.75rem !important; } -.react-flow__edge-path { - stroke: var(--color-border); -} - -.react-flow__edge-text { - fill: var(--color-muted-foreground); -} - -.react-flow__edge-textbg { - fill: var(--color-card); -} - .react-flow__attribution { display: none; } -.react-flow__handle { - background-color: var(--color-border); +/* ── Glow edge animations ────────────────────────────── */ +@keyframes glow-flow-downstream { + from { stroke-dashoffset: 40; } + to { stroke-dashoffset: 0; } +} + +@keyframes glow-flow-upstream { + from { stroke-dashoffset: 0; } + to { stroke-dashoffset: 40; } +} + +.glow-edge-flow-downstream { + animation: glow-flow-downstream 1s linear infinite; +} + +.glow-edge-flow-upstream { + animation: glow-flow-upstream 1s linear infinite; } /* ── Accessibility: Reduce motion ────────────────────── */ diff --git a/frontend/src/instrument.ts b/frontend/src/instrument.ts index d59877dc..823a6d4b 100644 --- a/frontend/src/instrument.ts +++ b/frontend/src/instrument.ts @@ -1,7 +1,7 @@ import * as Sentry from "@sentry/react"; Sentry.init({ - dsn: "https://23937b8c0cea2484f6a9d5b97d0b7d4b@o4511005918887936.ingest.us.sentry.io/4511005926883328", + dsn: import.meta.env.VITE_SENTRY_DSN || "https://23937b8c0cea2484f6a9d5b97d0b7d4b@o4511005918887936.ingest.us.sentry.io/4511005926883328", environment: import.meta.env.MODE, integrations: [ diff --git a/frontend/src/pages/TreeEditorPage.tsx b/frontend/src/pages/TreeEditorPage.tsx index 2b47b0ee..f8206f21 100644 --- a/frontend/src/pages/TreeEditorPage.tsx +++ b/frontend/src/pages/TreeEditorPage.tsx @@ -517,7 +517,7 @@ export function TreeEditorPage() { } return ( -
+
{/* Main content column */}
@@ -805,7 +805,7 @@ export function TreeEditorPage() { )} {/* Main Editor */} -
+
Date: Mon, 9 Mar 2026 04:36:46 -0400 Subject: [PATCH 7/7] feat: add glow edge system with directional selection animation Custom bezier edges with gradient glow for the flow editor: - Default: subtle white/gray gradient with soft glow - Downstream (cyan): animated flowing dashes from selected node through subtree - Upstream (amber): animated flow from selected node back to root - Cross-reference: dashed cyan with arrow markers - SVG gradient + filter defs for performant rendering Co-Authored-By: Claude Opus 4.6 --- docs/plans/2026-03-09-glow-edge-design.md | 48 +++++ .../src/components/tree-editor/FlowCanvas.tsx | 10 +- .../src/components/tree-editor/GlowEdge.tsx | 188 ++++++++++++++++++ .../components/tree-editor/useTreeLayout.ts | 80 ++++++-- 4 files changed, 311 insertions(+), 15 deletions(-) create mode 100644 docs/plans/2026-03-09-glow-edge-design.md create mode 100644 frontend/src/components/tree-editor/GlowEdge.tsx diff --git a/docs/plans/2026-03-09-glow-edge-design.md b/docs/plans/2026-03-09-glow-edge-design.md new file mode 100644 index 00000000..0943cb11 --- /dev/null +++ b/docs/plans/2026-03-09-glow-edge-design.md @@ -0,0 +1,48 @@ +# Glow Edge System — Flow Editor + +> **Date:** 2026-03-09 + +## Overview + +Replace flat `smoothstep` edges in the troubleshooting flow editor with custom bezier edges featuring gradient strokes, soft glow, and directional animation on node selection. + +## Default Edges (no selection) + +- **Curve type:** Bezier (replacing right-angled `smoothstep`) +- **Stroke:** ~1.5px, subtle white/gray gradient with soft `drop-shadow` glow +- **Feel:** Clean, understated, dark-mode friendly + +## Selected Node — Downstream Edges + +- **Color:** Cyan brand gradient (`#06b6d4` → `#22d3ee`) +- **Animation:** Flowing dash animation moving downward (`stroke-dashoffset` keyframe) +- **Scope:** All edges from selected node through entire subtree (children, grandchildren, etc.) +- **Glow:** Soft cyan `drop-shadow` filter +- **Stroke:** 2px + +## Selected Node — Upstream Edges + +- **Color:** Amber gradient (`#f59e0b` → `#fbbf24`) +- **Animation:** Softer pulse/breathing opacity animation moving upward toward root +- **Scope:** All edges from selected node back to root +- **Glow:** Soft amber `drop-shadow` filter +- **Stroke:** 2px + +## Cross-reference Edges + +- Keep dashed + animated + cyan with arrows +- Use new bezier curves + glow treatment + +## Implementation + +- One custom `GlowEdge` component registered as the default edge type in React Flow +- `useTreeLayout` passes `edgeState: 'default' | 'downstream' | 'upstream'` in edge data based on `selectedNodeId` +- SVG `linearGradient` + `filter` defined once in a `` block +- CSS keyframe animation for flowing dash effect + +## Files Touched + +- **New:** `frontend/src/components/tree-editor/GlowEdge.tsx` (~60 lines) +- **Modified:** `useTreeLayout.ts` — add ancestor/descendant calculation, edge state +- **Modified:** `FlowCanvas.tsx` — register custom edge type +- **Modified:** `index.css` — keyframe animation for flowing dashes diff --git a/frontend/src/components/tree-editor/FlowCanvas.tsx b/frontend/src/components/tree-editor/FlowCanvas.tsx index 32330b9b..bc4b1f34 100644 --- a/frontend/src/components/tree-editor/FlowCanvas.tsx +++ b/frontend/src/components/tree-editor/FlowCanvas.tsx @@ -12,10 +12,10 @@ import { PanOnScrollMode, type NodeMouseHandler, } from '@xyflow/react' -import '@xyflow/react/dist/style.css' import { FlowCanvasNode, NODE_TYPE_CONFIG } from './FlowCanvasNode' import { FlowCanvasAnswerNode } from './FlowCanvasAnswerNode' +import { GlowEdge, GlowEdgeDefs } from './GlowEdge' import { useTreeLayout } from './useTreeLayout' import { cn } from '@/lib/utils' import { Map as MapIcon, MapPinOff } from 'lucide-react' @@ -27,6 +27,10 @@ const nodeTypes = { answerStub: FlowCanvasAnswerNode, } +const edgeTypes = { + glowEdge: GlowEdge, +} + interface FlowCanvasProps { selectedNodeId: string | null onNodeSelect: (nodeId: string | null) => void @@ -36,7 +40,7 @@ interface FlowCanvasProps { function FlowCanvasInner({ selectedNodeId, onNodeSelect, onSelectAnswerType, onNodeContextMenu }: FlowCanvasProps) { const { fitView, setCenter } = useReactFlow() - const { nodes: layoutNodes, edges: layoutEdges, collapsedNodeIds, toggleCollapse, onNodesMeasured } = useTreeLayout() + const { nodes: layoutNodes, edges: layoutEdges, collapsedNodeIds, toggleCollapse, onNodesMeasured } = useTreeLayout(selectedNodeId) const [minimapVisible, setMinimapVisible] = useState(true) // Inject callbacks into node data (because useTreeLayout creates placeholder functions) @@ -124,6 +128,7 @@ function FlowCanvasInner({ selectedNodeId, onNodeSelect, onSelectAnswerType, onN onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} nodeTypes={nodeTypes} + edgeTypes={edgeTypes} onNodeClick={handleNodeClick} onPaneClick={handlePaneClick} fitView @@ -139,6 +144,7 @@ function FlowCanvasInner({ selectedNodeId, onNodeSelect, onSelectAnswerType, onN proOptions={{ hideAttribution: true }} className="dark bg-accent/30" > + {minimapVisible && ( diff --git a/frontend/src/components/tree-editor/GlowEdge.tsx b/frontend/src/components/tree-editor/GlowEdge.tsx new file mode 100644 index 00000000..a1b80db5 --- /dev/null +++ b/frontend/src/components/tree-editor/GlowEdge.tsx @@ -0,0 +1,188 @@ +import { memo } from 'react' +import { BaseEdge, getBezierPath, type EdgeProps } from '@xyflow/react' + +export type EdgeState = 'default' | 'downstream' | 'upstream' | 'crossref' + +export interface GlowEdgeData { + edgeState?: EdgeState + label?: string + [key: string]: unknown +} + +const EDGE_CONFIGS: Record = { + default: { + gradientId: 'edge-gradient-default', + strokeWidth: 1.5, + filterClass: 'glow-edge-default', + animationClass: '', + }, + downstream: { + gradientId: 'edge-gradient-downstream', + strokeWidth: 2, + filterClass: 'glow-edge-downstream', + animationClass: 'glow-edge-flow-downstream', + }, + upstream: { + gradientId: 'edge-gradient-upstream', + strokeWidth: 2, + filterClass: 'glow-edge-upstream', + animationClass: 'glow-edge-flow-upstream', + }, + crossref: { + gradientId: 'edge-gradient-downstream', + strokeWidth: 2, + filterClass: 'glow-edge-downstream', + animationClass: 'glow-edge-flow-downstream', + }, +} + +/** Shared SVG — render once inside the ReactFlow container. */ +export function GlowEdgeDefs() { + return ( + + + {/* Default: subtle white/gray */} + + + + + + {/* Downstream: cyan brand */} + + + + + + {/* Upstream: amber */} + + + + + + {/* Glow filters */} + + + + + + + + + + + + + + + + + + + + + + + + ) +} + +function GlowEdgeComponent({ + id, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + data, + label, + labelStyle, + labelBgStyle, + markerEnd, +}: EdgeProps) { + const edgeState = (data as GlowEdgeData)?.edgeState ?? 'default' + const config = EDGE_CONFIGS[edgeState] + const isCrossref = edgeState === 'crossref' + + const [edgePath, labelX, labelY] = getBezierPath({ + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + }) + + const filterId = edgeState === 'default' ? 'glow-default' + : edgeState === 'upstream' ? 'glow-amber' + : 'glow-cyan' + + return ( + <> + {/* Glow layer (wider, blurred) */} + + + {/* Main edge path */} + + + {/* Animated dash overlay for downstream/upstream */} + {(edgeState === 'downstream' || edgeState === 'upstream' || isCrossref) && ( + + )} + + {/* Label */} + {label && ( + + + + {String(label)} + + + )} + + ) +} + +export const GlowEdge = memo(GlowEdgeComponent) diff --git a/frontend/src/components/tree-editor/useTreeLayout.ts b/frontend/src/components/tree-editor/useTreeLayout.ts index 5dd78c7d..d6aa5ba9 100644 --- a/frontend/src/components/tree-editor/useTreeLayout.ts +++ b/frontend/src/components/tree-editor/useTreeLayout.ts @@ -69,6 +69,41 @@ function collectCrossRefEdges(root: TreeStructure): Array<{ source: string; targ return refs } +/** Collect all descendant node IDs of a given node (not including itself). */ +function collectDescendantIds(root: TreeStructure, targetId: string): Set { + const ids = new Set() + function findAndCollect(node: TreeStructure): boolean { + if (node.id === targetId) { + // Found — collect all children recursively + function collectAll(n: TreeStructure) { + n.children?.forEach(c => { ids.add(c.id); collectAll(c) }) + } + collectAll(node) + return true + } + return node.children?.some(findAndCollect) ?? false + } + findAndCollect(root) + return ids +} + +/** Collect all ancestor node IDs from a node up to root. */ +function collectAncestorIds(root: TreeStructure, targetId: string): Set { + const ids = new Set() + function walk(node: TreeStructure, path: string[]): boolean { + if (node.id === targetId) { + path.forEach(id => ids.add(id)) + return true + } + for (const child of node.children ?? []) { + if (walk(child, [...path, node.id])) return true + } + return false + } + walk(root, []) + return ids +} + interface UseTreeLayoutResult { nodes: Node[] edges: Edge[] @@ -77,7 +112,7 @@ interface UseTreeLayoutResult { onNodesMeasured: (measuredNodes: Node[]) => void } -export function useTreeLayout(): UseTreeLayoutResult { +export function useTreeLayout(selectedNodeId?: string | null): UseTreeLayoutResult { const treeStructure = useTreeEditorStore(s => s.treeStructure) const validationErrors = useTreeEditorStore(s => s.validationErrors) const [collapsedNodeIds, setCollapsedNodeIds] = useState>(new Set()) @@ -93,6 +128,15 @@ export function useTreeLayout(): UseTreeLayoutResult { }) }, []) + // Compute ancestor/descendant sets for selected node + const { descendantIds, ancestorIds } = useMemo(() => { + if (!treeStructure || !selectedNodeId) return { descendantIds: new Set(), ancestorIds: new Set() } + return { + descendantIds: collectDescendantIds(treeStructure, selectedNodeId), + ancestorIds: collectAncestorIds(treeStructure, selectedNodeId), + } + }, [treeStructure, selectedNodeId]) + // Convert tree structure to flat nodes and edges const { rawNodes, rawEdges } = useMemo(() => { const nodes: Node[] = [] @@ -100,6 +144,21 @@ export function useTreeLayout(): UseTreeLayoutResult { if (!treeStructure) return { rawNodes: nodes, rawEdges: edges } + /** Determine edge state based on source/target relation to selected node */ + function getEdgeState(sourceId: string, targetId: string): 'default' | 'downstream' | 'upstream' { + if (!selectedNodeId) return 'default' + // Downstream: selected node → child, or descendant → descendant's child + if ((sourceId === selectedNodeId && descendantIds.has(targetId)) || + (descendantIds.has(sourceId) && descendantIds.has(targetId))) { + return 'downstream' + } + // Upstream: ancestor → ancestor, or ancestor → selected node + if ((ancestorIds.has(sourceId) && (ancestorIds.has(targetId) || targetId === selectedNodeId))) { + return 'upstream' + } + return 'default' + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars function walk(node: TreeStructure, _parentId?: string | null) { const isCollapsed = collapsedNodeIds.has(node.id) @@ -148,17 +207,18 @@ export function useTreeLayout(): UseTreeLayoutResult { for (const { child, optionLabel } of orderedChildren) { const edgeLabel = optionLabel ? truncateLabel(optionLabel) : undefined + const edgeState = getEdgeState(node.id, child.id) edges.push({ id: `${node.id}->${child.id}`, source: node.id, target: child.id, - type: 'smoothstep', + type: 'glowEdge', label: edgeLabel, labelStyle: { fill: 'var(--color-muted-foreground)', fontSize: 11 }, labelBgStyle: { fill: 'var(--color-card)', fillOpacity: 0.9 }, labelBgPadding: [4, 2] as [number, number], - style: { stroke: 'var(--color-border)' }, + data: { edgeState }, }) walk(child, node.id) @@ -168,11 +228,10 @@ export function useTreeLayout(): UseTreeLayoutResult { walk(treeStructure, null) - // Add cross-reference edges (dashed, purple) + // Add cross-reference edges if (treeStructure) { const crossRefs = collectCrossRefEdges(treeStructure) for (const ref of crossRefs) { - // Only add if both source and target nodes are visible (not collapsed away) const sourceVisible = nodes.some(n => n.id === ref.source) const targetVisible = nodes.some(n => n.id === ref.target) if (sourceVisible && targetVisible) { @@ -180,17 +239,12 @@ export function useTreeLayout(): UseTreeLayoutResult { id: `xref-${ref.source}->${ref.target}`, source: ref.source, target: ref.target, - type: 'smoothstep', - animated: true, + type: 'glowEdge', label: ref.label ? truncateLabel(ref.label) : undefined, labelStyle: { fill: 'var(--color-primary)', fontSize: 10, fontWeight: 500 }, labelBgStyle: { fill: 'var(--color-card)', fillOpacity: 0.95 }, labelBgPadding: [4, 2] as [number, number], - style: { - stroke: 'var(--color-primary)', - strokeWidth: 2, - strokeDasharray: '6 3', - }, + data: { edgeState: 'crossref' as const }, markerEnd: { type: 'arrowclosed' as const, color: 'var(--color-primary)', @@ -203,7 +257,7 @@ export function useTreeLayout(): UseTreeLayoutResult { } return { rawNodes: nodes, rawEdges: edges } - }, [treeStructure, collapsedNodeIds, validationErrors, measuredHeights]) + }, [treeStructure, collapsedNodeIds, validationErrors, measuredHeights, selectedNodeId, descendantIds, ancestorIds]) // Run dagre layout const { nodes, edges } = useMemo(() => {