merge: resolve conflicts with main (keep layout fixes)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michael Chihlas
2026-03-09 16:03:21 -04:00
17 changed files with 478 additions and 123 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 && (

View 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)

View File

@@ -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(() => {

View File

@@ -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 ────────────────────── */

View File

@@ -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"));
}

View File

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

View File

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