merge: resolve conflicts with main (keep layout fixes)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -342,6 +342,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.
|
**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
|
## RBAC & Permissions
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from fastapi import Depends, HTTPException, Request, status
|
|||||||
from fastapi.security import OAuth2PasswordBearer
|
from fastapi.security import OAuth2PasswordBearer
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
import sentry_sdk
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.security import decode_token
|
from app.core.security import decode_token
|
||||||
@@ -92,6 +93,9 @@ async def get_current_active_user(
|
|||||||
detail="password_change_required"
|
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
|
# Lightweight trial expiry check
|
||||||
if current_user.account_id:
|
if current_user.account_id:
|
||||||
from app.models.subscription import Subscription
|
from app.models.subscription import Subscription
|
||||||
|
|||||||
@@ -145,11 +145,17 @@ async def post_message(
|
|||||||
|
|
||||||
plan = await get_user_plan(current_user.account_id, db)
|
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:
|
try:
|
||||||
ai_content, suggested_flows, chat = await assistant_chat_service.send_message(
|
ai_content, suggested_flows, chat = await assistant_chat_service.send_message(
|
||||||
chat_id=chat_id,
|
chat_id=chat_id,
|
||||||
user_id=current_user.id,
|
user_id=user_id,
|
||||||
account_id=current_user.account_id,
|
account_id=account_id,
|
||||||
message=data.message,
|
message=data.message,
|
||||||
db=db,
|
db=db,
|
||||||
)
|
)
|
||||||
@@ -159,8 +165,8 @@ async def post_message(
|
|||||||
logger.exception("Assistant chat message failed: %s", e)
|
logger.exception("Assistant chat message failed: %s", e)
|
||||||
await db.rollback()
|
await db.rollback()
|
||||||
await record_ai_usage(
|
await record_ai_usage(
|
||||||
user_id=current_user.id,
|
user_id=user_id,
|
||||||
account_id=current_user.account_id,
|
account_id=account_id,
|
||||||
conversation_id=None,
|
conversation_id=None,
|
||||||
generation_type="assistant_message",
|
generation_type="assistant_message",
|
||||||
tier=plan,
|
tier=plan,
|
||||||
@@ -180,8 +186,8 @@ async def post_message(
|
|||||||
)
|
)
|
||||||
|
|
||||||
await record_ai_usage(
|
await record_ai_usage(
|
||||||
user_id=current_user.id,
|
user_id=user_id,
|
||||||
account_id=current_user.account_id,
|
account_id=account_id,
|
||||||
conversation_id=None,
|
conversation_id=None,
|
||||||
generation_type="assistant_message",
|
generation_type="assistant_message",
|
||||||
tier=plan,
|
tier=plan,
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ if settings.SENTRY_DSN:
|
|||||||
environment="development" if settings.DEBUG else "production",
|
environment="development" if settings.DEBUG else "production",
|
||||||
send_default_pii=True,
|
send_default_pii=True,
|
||||||
traces_sample_rate=1.0 if settings.DEBUG else 0.2,
|
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
|
# Filter out noisy health check transactions
|
||||||
traces_sampler=lambda ctx: (
|
traces_sampler=lambda ctx: (
|
||||||
0.0 if ctx.get("transaction_context", {}).get("name", "").startswith("GET /health") else None
|
0.0 if ctx.get("transaction_context", {}).get("name", "").startswith("GET /health") else None
|
||||||
|
|||||||
@@ -189,15 +189,29 @@ async def _call_anthropic_cached(
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
response = await client.beta.messages.create(
|
try:
|
||||||
model=settings.AI_MODEL_ANTHROPIC,
|
response = await client.beta.messages.create(
|
||||||
max_tokens=max_tokens,
|
model=settings.AI_MODEL_ANTHROPIC,
|
||||||
system=system_blocks,
|
max_tokens=max_tokens,
|
||||||
messages=messages,
|
system=system_blocks,
|
||||||
mcp_servers=mcp_servers,
|
messages=messages,
|
||||||
tools=tools,
|
mcp_servers=mcp_servers,
|
||||||
betas=["mcp-client-2025-11-20"],
|
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
|
# Extract text from response — MCP responses can have multiple block
|
||||||
# types (text, mcp_tool_use, mcp_tool_result). We join all text blocks.
|
# types (text, mcp_tool_use, mcp_tool_result). We join all text blocks.
|
||||||
|
|||||||
48
docs/plans/2026-03-09-glow-edge-design.md
Normal file
48
docs/plans/2026-03-09-glow-edge-design.md
Normal file
@@ -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 `<defs>` 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
|
||||||
@@ -12,9 +12,11 @@ RUN npm ci
|
|||||||
# Copy source code
|
# Copy source code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Build argument for API URL (set at build time)
|
# Build arguments (set at build time)
|
||||||
ARG VITE_API_URL
|
ARG VITE_API_URL
|
||||||
|
ARG VITE_SENTRY_DSN
|
||||||
ENV VITE_API_URL=$VITE_API_URL
|
ENV VITE_API_URL=$VITE_API_URL
|
||||||
|
ENV VITE_SENTRY_DSN=$VITE_SENTRY_DSN
|
||||||
|
|
||||||
# Build the application
|
# Build the application
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export function CreateFlowDropdown({
|
|||||||
const session = await editorAIApi.startSession(
|
const session = await editorAIApi.startSession(
|
||||||
aiPromptFlowType === 'maintenance' ? 'procedural' : aiPromptFlowType
|
aiPromptFlowType === 'maintenance' ? 'procedural' : aiPromptFlowType
|
||||||
)
|
)
|
||||||
const sessionId = session.id
|
const sessionId = session.session_id
|
||||||
|
|
||||||
// Send the user's prompt
|
// Send the user's prompt
|
||||||
await editorAIApi.sendMessage({
|
await editorAIApi.sendMessage({
|
||||||
|
|||||||
@@ -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'
|
import { Button } from '@/components/ui/Button'
|
||||||
|
|
||||||
|
interface FallbackProps {
|
||||||
|
error: Error
|
||||||
|
resetError: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function DefaultFallback({ error, resetError }: FallbackProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[400px] flex-col items-center justify-center p-8">
|
||||||
|
<div className="max-w-md text-center">
|
||||||
|
<h2 className="mb-2 text-xl font-semibold text-red-400">
|
||||||
|
Something went wrong
|
||||||
|
</h2>
|
||||||
|
<p className="mb-4 text-muted-foreground">
|
||||||
|
An unexpected error occurred. Please try refreshing the page.
|
||||||
|
</p>
|
||||||
|
<pre className="mb-4 overflow-auto rounded-xl bg-white/5 border border-border p-3 text-left text-xs text-red-400">
|
||||||
|
{error.message}
|
||||||
|
</pre>
|
||||||
|
<div className="flex justify-center gap-3">
|
||||||
|
<Button variant="secondary" onClick={resetError}>
|
||||||
|
Try Again
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => window.location.reload()}>
|
||||||
|
Refresh Page
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
fallback?: ReactNode
|
fallback?: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
interface State {
|
export function ErrorBoundary({ children, fallback }: Props) {
|
||||||
hasError: boolean
|
return (
|
||||||
error: Error | null
|
<Sentry.ErrorBoundary
|
||||||
}
|
fallback={({ error, resetError }) => {
|
||||||
|
if (fallback) return fallback as React.ReactElement
|
||||||
export class ErrorBoundary extends Component<Props, State> {
|
return <DefaultFallback error={error as Error} resetError={resetError} />
|
||||||
constructor(props: Props) {
|
}}
|
||||||
super(props)
|
showDialog
|
||||||
this.state = { hasError: false, error: null }
|
>
|
||||||
}
|
{children}
|
||||||
|
</Sentry.ErrorBoundary>
|
||||||
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 (
|
|
||||||
<div className="flex min-h-[400px] flex-col items-center justify-center p-8">
|
|
||||||
<div className="max-w-md text-center">
|
|
||||||
<h2 className="mb-2 text-xl font-semibold text-red-400">
|
|
||||||
Something went wrong
|
|
||||||
</h2>
|
|
||||||
<p className="mb-4 text-muted-foreground">
|
|
||||||
An unexpected error occurred. Please try refreshing the page.
|
|
||||||
</p>
|
|
||||||
{this.state.error && (
|
|
||||||
<pre className="mb-4 overflow-auto rounded-xl bg-white/5 border border-border p-3 text-left text-xs text-red-400">
|
|
||||||
{this.state.error.message}
|
|
||||||
</pre>
|
|
||||||
)}
|
|
||||||
<Button onClick={() => window.location.reload()}>
|
|
||||||
Refresh Page
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.props.children
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ErrorBoundary
|
export default ErrorBoundary
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { useRouteError, isRouteErrorResponse, useNavigate } from 'react-router-dom'
|
import { useRouteError, isRouteErrorResponse, useNavigate } from 'react-router-dom'
|
||||||
|
import * as Sentry from '@sentry/react'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
|
|
||||||
function isChunkLoadError(error: unknown): boolean {
|
function isChunkLoadError(error: unknown): boolean {
|
||||||
@@ -19,6 +20,13 @@ export function RouteError() {
|
|||||||
const error = useRouteError()
|
const error = useRouteError()
|
||||||
const navigate = useNavigate()
|
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)
|
// Auto-reload once on chunk load failures (stale deploy)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isChunkLoadError(error)) {
|
if (isChunkLoadError(error)) {
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ import {
|
|||||||
PanOnScrollMode,
|
PanOnScrollMode,
|
||||||
type NodeMouseHandler,
|
type NodeMouseHandler,
|
||||||
} from '@xyflow/react'
|
} from '@xyflow/react'
|
||||||
import '@xyflow/react/dist/style.css'
|
|
||||||
|
|
||||||
import { FlowCanvasNode, NODE_TYPE_CONFIG } from './FlowCanvasNode'
|
import { FlowCanvasNode, NODE_TYPE_CONFIG } from './FlowCanvasNode'
|
||||||
import { FlowCanvasAnswerNode } from './FlowCanvasAnswerNode'
|
import { FlowCanvasAnswerNode } from './FlowCanvasAnswerNode'
|
||||||
|
import { GlowEdge, GlowEdgeDefs } from './GlowEdge'
|
||||||
import { useTreeLayout } from './useTreeLayout'
|
import { useTreeLayout } from './useTreeLayout'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { Map as MapIcon, MapPinOff } from 'lucide-react'
|
import { Map as MapIcon, MapPinOff } from 'lucide-react'
|
||||||
@@ -27,6 +27,10 @@ const nodeTypes = {
|
|||||||
answerStub: FlowCanvasAnswerNode,
|
answerStub: FlowCanvasAnswerNode,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const edgeTypes = {
|
||||||
|
glowEdge: GlowEdge,
|
||||||
|
}
|
||||||
|
|
||||||
interface FlowCanvasProps {
|
interface FlowCanvasProps {
|
||||||
selectedNodeId: string | null
|
selectedNodeId: string | null
|
||||||
onNodeSelect: (nodeId: string | null) => void
|
onNodeSelect: (nodeId: string | null) => void
|
||||||
@@ -36,7 +40,7 @@ interface FlowCanvasProps {
|
|||||||
|
|
||||||
function FlowCanvasInner({ selectedNodeId, onNodeSelect, onSelectAnswerType, onNodeContextMenu }: FlowCanvasProps) {
|
function FlowCanvasInner({ selectedNodeId, onNodeSelect, onSelectAnswerType, onNodeContextMenu }: FlowCanvasProps) {
|
||||||
const { fitView, setCenter } = useReactFlow()
|
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)
|
const [minimapVisible, setMinimapVisible] = useState(true)
|
||||||
|
|
||||||
// Inject callbacks into node data (because useTreeLayout creates placeholder functions)
|
// Inject callbacks into node data (because useTreeLayout creates placeholder functions)
|
||||||
@@ -124,6 +128,7 @@ function FlowCanvasInner({ selectedNodeId, onNodeSelect, onSelectAnswerType, onN
|
|||||||
onNodesChange={onNodesChange}
|
onNodesChange={onNodesChange}
|
||||||
onEdgesChange={onEdgesChange}
|
onEdgesChange={onEdgesChange}
|
||||||
nodeTypes={nodeTypes}
|
nodeTypes={nodeTypes}
|
||||||
|
edgeTypes={edgeTypes}
|
||||||
onNodeClick={handleNodeClick}
|
onNodeClick={handleNodeClick}
|
||||||
onPaneClick={handlePaneClick}
|
onPaneClick={handlePaneClick}
|
||||||
fitView
|
fitView
|
||||||
@@ -139,6 +144,7 @@ function FlowCanvasInner({ selectedNodeId, onNodeSelect, onSelectAnswerType, onN
|
|||||||
proOptions={{ hideAttribution: true }}
|
proOptions={{ hideAttribution: true }}
|
||||||
className="dark bg-accent/30"
|
className="dark bg-accent/30"
|
||||||
>
|
>
|
||||||
|
<GlowEdgeDefs />
|
||||||
<Background variant={BackgroundVariant.Dots} gap={20} size={1.5} color="oklch(0.63 0.02 260 / 0.25)" />
|
<Background variant={BackgroundVariant.Dots} gap={20} size={1.5} color="oklch(0.63 0.02 260 / 0.25)" />
|
||||||
<Controls showInteractive={false} className="bg-card! border-border! shadow-lg!" />
|
<Controls showInteractive={false} className="bg-card! border-border! shadow-lg!" />
|
||||||
{minimapVisible && (
|
{minimapVisible && (
|
||||||
|
|||||||
188
frontend/src/components/tree-editor/GlowEdge.tsx
Normal file
188
frontend/src/components/tree-editor/GlowEdge.tsx
Normal file
@@ -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<EdgeState, {
|
||||||
|
gradientId: string
|
||||||
|
strokeWidth: number
|
||||||
|
filterClass: string
|
||||||
|
animationClass: string
|
||||||
|
}> = {
|
||||||
|
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 <defs> — render once inside the ReactFlow container. */
|
||||||
|
export function GlowEdgeDefs() {
|
||||||
|
return (
|
||||||
|
<svg style={{ position: 'absolute', width: 0, height: 0 }}>
|
||||||
|
<defs>
|
||||||
|
{/* Default: subtle white/gray */}
|
||||||
|
<linearGradient id="edge-gradient-default" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stopColor="rgba(255,255,255,0.25)" />
|
||||||
|
<stop offset="100%" stopColor="rgba(255,255,255,0.08)" />
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
{/* Downstream: cyan brand */}
|
||||||
|
<linearGradient id="edge-gradient-downstream" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stopColor="#06b6d4" />
|
||||||
|
<stop offset="100%" stopColor="#22d3ee" />
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
{/* Upstream: amber */}
|
||||||
|
<linearGradient id="edge-gradient-upstream" x1="0%" y1="100%" x2="0%" y2="0%">
|
||||||
|
<stop offset="0%" stopColor="#f59e0b" />
|
||||||
|
<stop offset="100%" stopColor="#fbbf24" />
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
{/* Glow filters */}
|
||||||
|
<filter id="glow-default" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
|
<feGaussianBlur in="SourceGraphic" stdDeviation="1.5" result="blur" />
|
||||||
|
<feMerge>
|
||||||
|
<feMergeNode in="blur" />
|
||||||
|
<feMergeNode in="SourceGraphic" />
|
||||||
|
</feMerge>
|
||||||
|
</filter>
|
||||||
|
<filter id="glow-cyan" x="-30%" y="-30%" width="160%" height="160%">
|
||||||
|
<feGaussianBlur in="SourceGraphic" stdDeviation="3" result="blur" />
|
||||||
|
<feMerge>
|
||||||
|
<feMergeNode in="blur" />
|
||||||
|
<feMergeNode in="SourceGraphic" />
|
||||||
|
</feMerge>
|
||||||
|
</filter>
|
||||||
|
<filter id="glow-amber" x="-30%" y="-30%" width="160%" height="160%">
|
||||||
|
<feGaussianBlur in="SourceGraphic" stdDeviation="3" result="blur" />
|
||||||
|
<feMerge>
|
||||||
|
<feMergeNode in="blur" />
|
||||||
|
<feMergeNode in="SourceGraphic" />
|
||||||
|
</feMerge>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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) */}
|
||||||
|
<path
|
||||||
|
d={edgePath}
|
||||||
|
fill="none"
|
||||||
|
stroke={`url(#${config.gradientId})`}
|
||||||
|
strokeWidth={config.strokeWidth + 2}
|
||||||
|
filter={`url(#${filterId})`}
|
||||||
|
opacity={edgeState === 'default' ? 0.3 : 0.5}
|
||||||
|
className="pointer-events-none"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Main edge path */}
|
||||||
|
<BaseEdge
|
||||||
|
id={id}
|
||||||
|
path={edgePath}
|
||||||
|
style={{
|
||||||
|
stroke: `url(#${config.gradientId})`,
|
||||||
|
strokeWidth: config.strokeWidth,
|
||||||
|
strokeDasharray: isCrossref ? '6 3' : undefined,
|
||||||
|
}}
|
||||||
|
className={config.animationClass || undefined}
|
||||||
|
markerEnd={markerEnd}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Animated dash overlay for downstream/upstream */}
|
||||||
|
{(edgeState === 'downstream' || edgeState === 'upstream' || isCrossref) && (
|
||||||
|
<path
|
||||||
|
d={edgePath}
|
||||||
|
fill="none"
|
||||||
|
stroke={`url(#${config.gradientId})`}
|
||||||
|
strokeWidth={config.strokeWidth}
|
||||||
|
strokeDasharray="8 12"
|
||||||
|
className={config.animationClass}
|
||||||
|
opacity={0.9}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Label */}
|
||||||
|
{label && (
|
||||||
|
<g transform={`translate(${labelX}, ${labelY})`}>
|
||||||
|
<rect
|
||||||
|
x={-((String(label).length * 5.5 + 8) / 2)}
|
||||||
|
y={-9}
|
||||||
|
width={String(label).length * 5.5 + 8}
|
||||||
|
height={18}
|
||||||
|
rx={4}
|
||||||
|
style={labelBgStyle as React.CSSProperties ?? { fill: 'var(--color-card)', fillOpacity: 0.9 }}
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
textAnchor="middle"
|
||||||
|
dominantBaseline="central"
|
||||||
|
style={labelStyle as React.CSSProperties ?? { fill: 'var(--color-muted-foreground)', fontSize: 11 }}
|
||||||
|
>
|
||||||
|
{String(label)}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GlowEdge = memo(GlowEdgeComponent)
|
||||||
@@ -69,6 +69,41 @@ function collectCrossRefEdges(root: TreeStructure): Array<{ source: string; targ
|
|||||||
return refs
|
return refs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Collect all descendant node IDs of a given node (not including itself). */
|
||||||
|
function collectDescendantIds(root: TreeStructure, targetId: string): Set<string> {
|
||||||
|
const ids = new Set<string>()
|
||||||
|
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<string> {
|
||||||
|
const ids = new Set<string>()
|
||||||
|
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 {
|
interface UseTreeLayoutResult {
|
||||||
nodes: Node[]
|
nodes: Node[]
|
||||||
edges: Edge[]
|
edges: Edge[]
|
||||||
@@ -77,7 +112,7 @@ interface UseTreeLayoutResult {
|
|||||||
onNodesMeasured: (measuredNodes: Node[]) => void
|
onNodesMeasured: (measuredNodes: Node[]) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useTreeLayout(): UseTreeLayoutResult {
|
export function useTreeLayout(selectedNodeId?: string | null): UseTreeLayoutResult {
|
||||||
const treeStructure = useTreeEditorStore(s => s.treeStructure)
|
const treeStructure = useTreeEditorStore(s => s.treeStructure)
|
||||||
const validationErrors = useTreeEditorStore(s => s.validationErrors)
|
const validationErrors = useTreeEditorStore(s => s.validationErrors)
|
||||||
const [collapsedNodeIds, setCollapsedNodeIds] = useState<Set<string>>(new Set())
|
const [collapsedNodeIds, setCollapsedNodeIds] = useState<Set<string>>(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<string>(), ancestorIds: new Set<string>() }
|
||||||
|
return {
|
||||||
|
descendantIds: collectDescendantIds(treeStructure, selectedNodeId),
|
||||||
|
ancestorIds: collectAncestorIds(treeStructure, selectedNodeId),
|
||||||
|
}
|
||||||
|
}, [treeStructure, selectedNodeId])
|
||||||
|
|
||||||
// Convert tree structure to flat nodes and edges
|
// Convert tree structure to flat nodes and edges
|
||||||
const { rawNodes, rawEdges } = useMemo(() => {
|
const { rawNodes, rawEdges } = useMemo(() => {
|
||||||
const nodes: Node[] = []
|
const nodes: Node[] = []
|
||||||
@@ -100,6 +144,21 @@ export function useTreeLayout(): UseTreeLayoutResult {
|
|||||||
|
|
||||||
if (!treeStructure) return { rawNodes: nodes, rawEdges: edges }
|
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
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
function walk(node: TreeStructure, _parentId?: string | null) {
|
function walk(node: TreeStructure, _parentId?: string | null) {
|
||||||
const isCollapsed = collapsedNodeIds.has(node.id)
|
const isCollapsed = collapsedNodeIds.has(node.id)
|
||||||
@@ -148,17 +207,18 @@ export function useTreeLayout(): UseTreeLayoutResult {
|
|||||||
|
|
||||||
for (const { child, optionLabel } of orderedChildren) {
|
for (const { child, optionLabel } of orderedChildren) {
|
||||||
const edgeLabel = optionLabel ? truncateLabel(optionLabel) : undefined
|
const edgeLabel = optionLabel ? truncateLabel(optionLabel) : undefined
|
||||||
|
const edgeState = getEdgeState(node.id, child.id)
|
||||||
|
|
||||||
edges.push({
|
edges.push({
|
||||||
id: `${node.id}->${child.id}`,
|
id: `${node.id}->${child.id}`,
|
||||||
source: node.id,
|
source: node.id,
|
||||||
target: child.id,
|
target: child.id,
|
||||||
type: 'smoothstep',
|
type: 'glowEdge',
|
||||||
label: edgeLabel,
|
label: edgeLabel,
|
||||||
labelStyle: { fill: 'var(--color-muted-foreground)', fontSize: 11 },
|
labelStyle: { fill: 'var(--color-muted-foreground)', fontSize: 11 },
|
||||||
labelBgStyle: { fill: 'var(--color-card)', fillOpacity: 0.9 },
|
labelBgStyle: { fill: 'var(--color-card)', fillOpacity: 0.9 },
|
||||||
labelBgPadding: [4, 2] as [number, number],
|
labelBgPadding: [4, 2] as [number, number],
|
||||||
style: { stroke: 'var(--color-border)' },
|
data: { edgeState },
|
||||||
})
|
})
|
||||||
|
|
||||||
walk(child, node.id)
|
walk(child, node.id)
|
||||||
@@ -168,11 +228,10 @@ export function useTreeLayout(): UseTreeLayoutResult {
|
|||||||
|
|
||||||
walk(treeStructure, null)
|
walk(treeStructure, null)
|
||||||
|
|
||||||
// Add cross-reference edges (dashed, purple)
|
// Add cross-reference edges
|
||||||
if (treeStructure) {
|
if (treeStructure) {
|
||||||
const crossRefs = collectCrossRefEdges(treeStructure)
|
const crossRefs = collectCrossRefEdges(treeStructure)
|
||||||
for (const ref of crossRefs) {
|
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 sourceVisible = nodes.some(n => n.id === ref.source)
|
||||||
const targetVisible = nodes.some(n => n.id === ref.target)
|
const targetVisible = nodes.some(n => n.id === ref.target)
|
||||||
if (sourceVisible && targetVisible) {
|
if (sourceVisible && targetVisible) {
|
||||||
@@ -180,17 +239,12 @@ export function useTreeLayout(): UseTreeLayoutResult {
|
|||||||
id: `xref-${ref.source}->${ref.target}`,
|
id: `xref-${ref.source}->${ref.target}`,
|
||||||
source: ref.source,
|
source: ref.source,
|
||||||
target: ref.target,
|
target: ref.target,
|
||||||
type: 'smoothstep',
|
type: 'glowEdge',
|
||||||
animated: true,
|
|
||||||
label: ref.label ? truncateLabel(ref.label) : undefined,
|
label: ref.label ? truncateLabel(ref.label) : undefined,
|
||||||
labelStyle: { fill: 'var(--color-primary)', fontSize: 10, fontWeight: 500 },
|
labelStyle: { fill: 'var(--color-primary)', fontSize: 10, fontWeight: 500 },
|
||||||
labelBgStyle: { fill: 'var(--color-card)', fillOpacity: 0.95 },
|
labelBgStyle: { fill: 'var(--color-card)', fillOpacity: 0.95 },
|
||||||
labelBgPadding: [4, 2] as [number, number],
|
labelBgPadding: [4, 2] as [number, number],
|
||||||
style: {
|
data: { edgeState: 'crossref' as const },
|
||||||
stroke: 'var(--color-primary)',
|
|
||||||
strokeWidth: 2,
|
|
||||||
strokeDasharray: '6 3',
|
|
||||||
},
|
|
||||||
markerEnd: {
|
markerEnd: {
|
||||||
type: 'arrowclosed' as const,
|
type: 'arrowclosed' as const,
|
||||||
color: 'var(--color-primary)',
|
color: 'var(--color-primary)',
|
||||||
@@ -203,7 +257,7 @@ export function useTreeLayout(): UseTreeLayoutResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return { rawNodes: nodes, rawEdges: edges }
|
return { rawNodes: nodes, rawEdges: edges }
|
||||||
}, [treeStructure, collapsedNodeIds, validationErrors, measuredHeights])
|
}, [treeStructure, collapsedNodeIds, validationErrors, measuredHeights, selectedNodeId, descendantIds, ancestorIds])
|
||||||
|
|
||||||
// Run dagre layout
|
// Run dagre layout
|
||||||
const { nodes, edges } = useMemo(() => {
|
const { nodes, edges } = useMemo(() => {
|
||||||
|
|||||||
@@ -2,6 +2,11 @@
|
|||||||
|
|
||||||
@custom-variant dark (&:where(.dark, .dark *));
|
@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 {
|
@theme {
|
||||||
/* ── Brand tokens ─────────────────────────────────── */
|
/* ── Brand tokens ─────────────────────────────────── */
|
||||||
--color-brand-gradient-from: oklch(0.65 0.13 195); /* #06b6d4 cyan-500 */
|
--color-brand-gradient-from: oklch(0.65 0.13 195); /* #06b6d4 cyan-500 */
|
||||||
@@ -376,58 +381,59 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── React Flow dark theme overrides ─────────────────── */
|
/* ── React Flow dark theme overrides ─────────────────── */
|
||||||
.react-flow__background {
|
/* React Flow v12 uses --xy-* CSS custom properties for theming.
|
||||||
background-color: transparent !important;
|
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 {
|
.react-flow__controls {
|
||||||
background-color: var(--color-card) !important;
|
|
||||||
border: 1px solid var(--color-border) !important;
|
|
||||||
border-radius: 0.75rem !important;
|
border-radius: 0.75rem !important;
|
||||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3) !important;
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3) !important;
|
||||||
overflow: hidden;
|
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 {
|
.react-flow__minimap {
|
||||||
background-color: var(--color-card) !important;
|
|
||||||
border: 1px solid var(--color-border) !important;
|
|
||||||
border-radius: 0.75rem !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 {
|
.react-flow__attribution {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.react-flow__handle {
|
/* ── Glow edge animations ────────────────────────────── */
|
||||||
background-color: var(--color-border);
|
@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 ────────────────────── */
|
/* ── Accessibility: Reduce motion ────────────────────── */
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import * as Sentry from "@sentry/react";
|
import * as Sentry from "@sentry/react";
|
||||||
|
|
||||||
Sentry.init({
|
Sentry.init({
|
||||||
dsn: import.meta.env.VITE_SENTRY_DSN,
|
dsn: import.meta.env.VITE_SENTRY_DSN || "https://23937b8c0cea2484f6a9d5b97d0b7d4b@o4511005918887936.ingest.us.sentry.io/4511005926883328",
|
||||||
environment: import.meta.env.MODE,
|
environment: import.meta.env.MODE,
|
||||||
|
|
||||||
integrations: [
|
integrations: [
|
||||||
@@ -10,6 +10,11 @@ Sentry.init({
|
|||||||
maskAllText: false,
|
maskAllText: false,
|
||||||
blockAllMedia: 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
|
// Tracing — capture 100% in dev, 20% in production
|
||||||
@@ -19,7 +24,13 @@ Sentry.init({
|
|||||||
/^https:\/\/api\.resolutionflow\.com/,
|
/^https:\/\/api\.resolutionflow\.com/,
|
||||||
],
|
],
|
||||||
|
|
||||||
// Session Replay — record 10% of sessions, 100% of error sessions
|
// Session Replay — conserve free-plan quota
|
||||||
replaysSessionSampleRate: 0.1,
|
// 1% of normal sessions, 100% of error sessions
|
||||||
|
replaysSessionSampleRate: import.meta.env.PROD ? 0.01 : 0.0,
|
||||||
replaysOnErrorSampleRate: 1.0,
|
replaysOnErrorSampleRate: 1.0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// TODO: Remove after verifying Sentry is receiving frontend events
|
||||||
|
if (import.meta.env.PROD) {
|
||||||
|
Sentry.captureException(new Error("ResolutionFlow frontend Sentry verification"));
|
||||||
|
}
|
||||||
|
|||||||
@@ -517,7 +517,7 @@ export function TreeEditorPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full overflow-hidden">
|
<div className="flex h-[calc(100vh-56px)] overflow-hidden">
|
||||||
{/* Main content column */}
|
{/* Main content column */}
|
||||||
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||||
|
|
||||||
@@ -805,7 +805,7 @@ export function TreeEditorPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Main Editor */}
|
{/* Main Editor */}
|
||||||
<div className="min-h-0 flex-1 overflow-hidden">
|
<div className="flex min-h-0 flex-1 overflow-hidden">
|
||||||
<TreeEditorLayout
|
<TreeEditorLayout
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
isMetadataOpen={isMetadataOpen}
|
isMetadataOpen={isMetadataOpen}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import { persist } from 'zustand/middleware'
|
import { persist } from 'zustand/middleware'
|
||||||
|
import * as Sentry from '@sentry/react'
|
||||||
import type { User, Token, UserCreate, UserLogin, Account, SubscriptionDetails } from '@/types'
|
import type { User, Token, UserCreate, UserLogin, Account, SubscriptionDetails } from '@/types'
|
||||||
import { authApi } from '@/api/auth'
|
import { authApi } from '@/api/auth'
|
||||||
import { apiClient } from '@/api/client'
|
import { apiClient } from '@/api/client'
|
||||||
@@ -81,6 +82,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
localStorage.removeItem('access_token')
|
localStorage.removeItem('access_token')
|
||||||
localStorage.removeItem('refresh_token')
|
localStorage.removeItem('refresh_token')
|
||||||
clearCachedQuota()
|
clearCachedQuota()
|
||||||
|
Sentry.setUser(null)
|
||||||
set({ user: null, token: null, account: null, subscription: null, isAuthenticated: false, error: null })
|
set({ user: null, token: null, account: null, subscription: null, isAuthenticated: false, error: null })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -104,6 +106,9 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
throw reason
|
throw reason
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set Sentry user context for error attribution
|
||||||
|
Sentry.setUser({ id: user.id, email: user.email })
|
||||||
|
|
||||||
set({ user, account, subscription, isLoading: false })
|
set({ user, account, subscription, isLoading: false })
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const message = error instanceof Error ? error.message : 'Failed to fetch user'
|
const message = error instanceof Error ? error.message : 'Failed to fetch user'
|
||||||
|
|||||||
Reference in New Issue
Block a user