Spaces:
Sleeping
Sleeping
Commit ·
9bb34f8
1
Parent(s): e32f628
feat: enhance UI state management with graph collapse functionality and improve error handling in API requests
Browse files
.gitignore
CHANGED
|
@@ -37,3 +37,4 @@ Thumbs.db
|
|
| 37 |
# Celery
|
| 38 |
celerybeat-schedule
|
| 39 |
celerybeat.pid
|
|
|
|
|
|
| 37 |
# Celery
|
| 38 |
celerybeat-schedule
|
| 39 |
celerybeat.pid
|
| 40 |
+
.env
|
frontend/src/components/common/NoResultsState.tsx
CHANGED
|
@@ -4,6 +4,7 @@ interface Props {
|
|
| 4 |
|
| 5 |
const SUGGESTIONS = [
|
| 6 |
'Try broader terms — remove acronyms or product names',
|
|
|
|
| 7 |
'Check if the data source containing this topic is connected',
|
| 8 |
'Ask your admin to verify ingestion status for that area',
|
| 9 |
]
|
|
|
|
| 4 |
|
| 5 |
const SUGGESTIONS = [
|
| 6 |
'Try broader terms — remove acronyms or product names',
|
| 7 |
+
'Results may be limited based on your channel access — ask your admin if you need broader access',
|
| 8 |
'Check if the data source containing this topic is connected',
|
| 9 |
'Ask your admin to verify ingestion status for that area',
|
| 10 |
]
|
frontend/src/components/results/KnowledgeGraph.tsx
CHANGED
|
@@ -33,11 +33,12 @@ interface Props {
|
|
| 33 |
streaming: boolean // true while SSE/WS is active (drives the empty state animation)
|
| 34 |
onNodeClick: (node: GraphNode) => void
|
| 35 |
onNodeHover: (node: GraphNode | null, x: number, y: number) => void
|
|
|
|
| 36 |
}
|
| 37 |
|
| 38 |
// ─── Component ───────────────────────────────────────────────────────────────
|
| 39 |
|
| 40 |
-
export function KnowledgeGraph({ nodes, edges, streaming, onNodeClick, onNodeHover }: Props) {
|
| 41 |
const containerRef = useRef<HTMLDivElement>(null)
|
| 42 |
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
| 43 |
const graphRef = useRef<any>(null)
|
|
@@ -60,20 +61,21 @@ export function KnowledgeGraph({ nodes, edges, streaming, onNodeClick, onNodeHov
|
|
| 60 |
// Initialise the canvas once on mount
|
| 61 |
useEffect(() => {
|
| 62 |
if (!containerRef.current) return
|
|
|
|
| 63 |
|
| 64 |
import('force-graph').then(({ default: ForceGraph2D }) => {
|
| 65 |
if (!containerRef.current) return
|
| 66 |
|
|
|
|
| 67 |
const fg = ForceGraph2D()
|
| 68 |
-
fg(
|
| 69 |
fg.backgroundColor('transparent')
|
| 70 |
.nodeId('id')
|
| 71 |
.nodeLabel('name')
|
| 72 |
.nodeColor((n: FGNode) => n.color)
|
| 73 |
.nodeRelSize(6)
|
| 74 |
-
|
| 75 |
-
.
|
| 76 |
-
.height(containerRef.current.clientHeight || 400)
|
| 77 |
.linkColor(() => '#94a3b8')
|
| 78 |
.linkLabel('rel')
|
| 79 |
.linkDirectionalArrowLength(4)
|
|
@@ -86,11 +88,21 @@ export function KnowledgeGraph({ nodes, edges, streaming, onNodeClick, onNodeHov
|
|
| 86 |
})
|
| 87 |
graphRef.current = fg
|
| 88 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
// Flush any nodes/edges that arrived while the canvas was initialising
|
| 90 |
pushData(pendingRef.current.nodes, pendingRef.current.edges)
|
| 91 |
})
|
| 92 |
|
| 93 |
return () => {
|
|
|
|
| 94 |
graphRef.current?._destructor?.()
|
| 95 |
graphRef.current = null
|
| 96 |
}
|
|
@@ -106,7 +118,7 @@ export function KnowledgeGraph({ nodes, edges, streaming, onNodeClick, onNodeHov
|
|
| 106 |
const isEmpty = nodes.length === 0
|
| 107 |
|
| 108 |
return (
|
| 109 |
-
<div className=
|
| 110 |
|
| 111 |
{/* Empty state overlay — removed once first node arrives */}
|
| 112 |
{isEmpty && (
|
|
|
|
| 33 |
streaming: boolean // true while SSE/WS is active (drives the empty state animation)
|
| 34 |
onNodeClick: (node: GraphNode) => void
|
| 35 |
onNodeHover: (node: GraphNode | null, x: number, y: number) => void
|
| 36 |
+
className?: string
|
| 37 |
}
|
| 38 |
|
| 39 |
// ─── Component ───────────────────────────────────────────────────────────────
|
| 40 |
|
| 41 |
+
export function KnowledgeGraph({ nodes, edges, streaming, onNodeClick, onNodeHover, className }: Props) {
|
| 42 |
const containerRef = useRef<HTMLDivElement>(null)
|
| 43 |
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
| 44 |
const graphRef = useRef<any>(null)
|
|
|
|
| 61 |
// Initialise the canvas once on mount
|
| 62 |
useEffect(() => {
|
| 63 |
if (!containerRef.current) return
|
| 64 |
+
let ro: ResizeObserver | null = null
|
| 65 |
|
| 66 |
import('force-graph').then(({ default: ForceGraph2D }) => {
|
| 67 |
if (!containerRef.current) return
|
| 68 |
|
| 69 |
+
const el = containerRef.current
|
| 70 |
const fg = ForceGraph2D()
|
| 71 |
+
fg(el)
|
| 72 |
fg.backgroundColor('transparent')
|
| 73 |
.nodeId('id')
|
| 74 |
.nodeLabel('name')
|
| 75 |
.nodeColor((n: FGNode) => n.color)
|
| 76 |
.nodeRelSize(6)
|
| 77 |
+
.width(el.clientWidth || 380)
|
| 78 |
+
.height(el.clientHeight || 400)
|
|
|
|
| 79 |
.linkColor(() => '#94a3b8')
|
| 80 |
.linkLabel('rel')
|
| 81 |
.linkDirectionalArrowLength(4)
|
|
|
|
| 88 |
})
|
| 89 |
graphRef.current = fg
|
| 90 |
|
| 91 |
+
// Auto-resize the canvas when the container is resized (e.g. maximize/collapse)
|
| 92 |
+
ro = new ResizeObserver((entries) => {
|
| 93 |
+
const { width, height } = entries[0].contentRect
|
| 94 |
+
if (width > 0 && height > 0) {
|
| 95 |
+
fg.width(width).height(height)
|
| 96 |
+
}
|
| 97 |
+
})
|
| 98 |
+
ro.observe(el)
|
| 99 |
+
|
| 100 |
// Flush any nodes/edges that arrived while the canvas was initialising
|
| 101 |
pushData(pendingRef.current.nodes, pendingRef.current.edges)
|
| 102 |
})
|
| 103 |
|
| 104 |
return () => {
|
| 105 |
+
ro?.disconnect()
|
| 106 |
graphRef.current?._destructor?.()
|
| 107 |
graphRef.current = null
|
| 108 |
}
|
|
|
|
| 118 |
const isEmpty = nodes.length === 0
|
| 119 |
|
| 120 |
return (
|
| 121 |
+
<div className={`relative h-full w-full overflow-hidden${className ? ` ${className}` : ''}`}>
|
| 122 |
|
| 123 |
{/* Empty state overlay — removed once first node arrives */}
|
| 124 |
{isEmpty && (
|
frontend/src/components/results/ResultsPage.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
import { useCallback, useEffect, useRef, useState } from 'react'
|
| 2 |
import { useSearch } from '@tanstack/react-router'
|
| 3 |
import { useAuthStore } from '@/stores/authStore'
|
|
|
|
| 4 |
import { useSSEStream } from '@/hooks/useSSEStream'
|
| 5 |
import { useGraphStream } from '@/hooks/useGraphStream'
|
| 6 |
import { HallucinationWarning } from '@/components/common/HallucinationWarning'
|
|
@@ -28,6 +29,7 @@ type Tab = 'graph' | 'answer'
|
|
| 28 |
|
| 29 |
export function ResultsPage() {
|
| 30 |
const user = useAuthStore((s) => s.user)
|
|
|
|
| 31 |
const sessionRef = useRef(crypto.randomUUID())
|
| 32 |
const { q: initialQuery } = useSearch({ from: '/query' })
|
| 33 |
|
|
@@ -52,7 +54,9 @@ export function ResultsPage() {
|
|
| 52 |
const edgesAccRef = useRef<GraphEdge[]>([])
|
| 53 |
|
| 54 |
// ── Mobile tab ──────────────────────────────────────────────────────────────
|
| 55 |
-
const [activeTab, setActiveTab]
|
|
|
|
|
|
|
| 56 |
|
| 57 |
// ── Hooks ───────────────────────────────────────────────────────────────────
|
| 58 |
const { state, error, firstEventArrived, stream } = useSSEStream()
|
|
@@ -231,8 +235,11 @@ export function ResultsPage() {
|
|
| 231 |
</button>
|
| 232 |
</div>
|
| 233 |
|
| 234 |
-
{/* Two-column grid —
|
| 235 |
-
<div className=
|
|
|
|
|
|
|
|
|
|
| 236 |
|
| 237 |
{/* ── Left: answer column ──────────────────────────────────────── */}
|
| 238 |
<div className={cn(
|
|
@@ -240,6 +247,16 @@ export function ResultsPage() {
|
|
| 240 |
activeTab === 'graph' && 'hidden lg:flex',
|
| 241 |
)}>
|
| 242 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 243 |
{/* Thinking indicator — before any SSE event arrives */}
|
| 244 |
{isActive && !hasData && (
|
| 245 |
<div className="flex items-center gap-2 text-sm text-stone-400">
|
|
@@ -308,56 +325,82 @@ export function ResultsPage() {
|
|
| 308 |
{isComplete && <FollowUp onSubmit={runQuery} />}
|
| 309 |
</div>
|
| 310 |
|
| 311 |
-
{/* ── Right: graph
|
| 312 |
-
{/*
|
| 313 |
<div className={cn(
|
| 314 |
-
'flex flex-col
|
| 315 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 316 |
)}>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 317 |
<KnowledgeGraph
|
| 318 |
nodes={graphNodes}
|
| 319 |
edges={graphEdges}
|
| 320 |
streaming={isActive}
|
| 321 |
onNodeClick={handleNodeClick}
|
| 322 |
onNodeHover={handleNodeHover}
|
|
|
|
| 323 |
/>
|
| 324 |
|
| 325 |
-
{/* Graph footer
|
| 326 |
-
<div className="flex items-center gap-2">
|
| 327 |
-
{graphNodes.length > 0 && (
|
| 328 |
-
<p className="flex-1 text-xs text-stone-400">
|
| 329 |
-
{graphNodes.length} node{graphNodes.length !== 1 ? 's' : ''}
|
| 330 |
-
{' · '}
|
| 331 |
-
{graphEdges.length} connection{graphEdges.length !== 1 ? 's' : ''}
|
| 332 |
-
</p>
|
| 333 |
-
)}
|
| 334 |
-
|
| 335 |
-
{/* Refresh button — available when graph finished streaming (data may have updated) */}
|
| 336 |
{gState === 'done' && (
|
| 337 |
<button
|
| 338 |
onClick={reconnectGraph}
|
| 339 |
-
className="ml-auto
|
| 340 |
title="Reload graph with latest data"
|
| 341 |
>
|
| 342 |
-
↻ Refresh
|
| 343 |
</button>
|
| 344 |
)}
|
|
|
|
| 345 |
</div>
|
| 346 |
|
| 347 |
-
{/*
|
| 348 |
-
{gState === 'retrying' && (
|
| 349 |
-
<NetworkRetry attempt={retryCount + 1} />
|
| 350 |
-
)}
|
| 351 |
-
|
| 352 |
-
{/* Manual reload after max retries exhausted */}
|
| 353 |
{gState === 'error' && (
|
| 354 |
-
<div className="flex items-center justify-between
|
| 355 |
-
<span className="text-stone-
|
| 356 |
Couldn't load the knowledge graph.
|
| 357 |
</span>
|
| 358 |
<button
|
| 359 |
onClick={reconnectGraph}
|
| 360 |
-
className="ml-4 shrink-0 rounded bg-stone-200 px-3 py-1
|
| 361 |
>
|
| 362 |
Try again
|
| 363 |
</button>
|
|
|
|
| 1 |
import { useCallback, useEffect, useRef, useState } from 'react'
|
| 2 |
import { useSearch } from '@tanstack/react-router'
|
| 3 |
import { useAuthStore } from '@/stores/authStore'
|
| 4 |
+
import { useUIStore } from '@/stores/uiStore'
|
| 5 |
import { useSSEStream } from '@/hooks/useSSEStream'
|
| 6 |
import { useGraphStream } from '@/hooks/useGraphStream'
|
| 7 |
import { HallucinationWarning } from '@/components/common/HallucinationWarning'
|
|
|
|
| 29 |
|
| 30 |
export function ResultsPage() {
|
| 31 |
const user = useAuthStore((s) => s.user)
|
| 32 |
+
const { graphCollapsed, toggleGraphCollapsed } = useUIStore()
|
| 33 |
const sessionRef = useRef(crypto.randomUUID())
|
| 34 |
const { q: initialQuery } = useSearch({ from: '/query' })
|
| 35 |
|
|
|
|
| 54 |
const edgesAccRef = useRef<GraphEdge[]>([])
|
| 55 |
|
| 56 |
// ── Mobile tab ──────────────────────────────────────────────────────────────
|
| 57 |
+
const [activeTab, setActiveTab] = useState<Tab>('graph')
|
| 58 |
+
// Local — not persisted; maximize is a session-level power-user action
|
| 59 |
+
const [graphMaximized, setGraphMaximized] = useState(false)
|
| 60 |
|
| 61 |
// ── Hooks ───────────────────────────────────────────────────────────────────
|
| 62 |
const { state, error, firstEventArrived, stream } = useSSEStream()
|
|
|
|
| 235 |
</button>
|
| 236 |
</div>
|
| 237 |
|
| 238 |
+
{/* Two-column grid — collapses to single when graph is hidden */}
|
| 239 |
+
<div className={cn(
|
| 240 |
+
'flex flex-col gap-6 lg:grid lg:items-start lg:gap-8',
|
| 241 |
+
graphCollapsed ? 'lg:grid-cols-1' : 'lg:grid-cols-[1fr_380px]',
|
| 242 |
+
)}>
|
| 243 |
|
| 244 |
{/* ── Left: answer column ──────────────────────────────────────── */}
|
| 245 |
<div className={cn(
|
|
|
|
| 247 |
activeTab === 'graph' && 'hidden lg:flex',
|
| 248 |
)}>
|
| 249 |
|
| 250 |
+
{/* Show-graph chip — visible on desktop when graph panel is collapsed */}
|
| 251 |
+
{graphCollapsed && (
|
| 252 |
+
<button
|
| 253 |
+
onClick={toggleGraphCollapsed}
|
| 254 |
+
className="hidden lg:inline-flex w-fit items-center gap-1.5 rounded-full border border-surface-subtle bg-stone-50 px-3 py-1 text-xs text-stone-500 hover:bg-stone-100 dark:bg-stone-800/60 dark:hover:bg-stone-700"
|
| 255 |
+
>
|
| 256 |
+
▶ Show graph
|
| 257 |
+
</button>
|
| 258 |
+
)}
|
| 259 |
+
|
| 260 |
{/* Thinking indicator — before any SSE event arrives */}
|
| 261 |
{isActive && !hasData && (
|
| 262 |
<div className="flex items-center gap-2 text-sm text-stone-400">
|
|
|
|
| 325 |
{isComplete && <FollowUp onSubmit={runQuery} />}
|
| 326 |
</div>
|
| 327 |
|
| 328 |
+
{/* ── Right: graph panel ───────────────────────────────────────── */}
|
| 329 |
+
{/* On mobile: controlled by activeTab. On desktop: hidden when graphCollapsed */}
|
| 330 |
<div className={cn(
|
| 331 |
+
'flex flex-col overflow-hidden rounded-xl border border-surface-subtle',
|
| 332 |
+
// Mobile: toggle by tab; desktop: toggle by collapsed state
|
| 333 |
+
activeTab === 'answer' ? 'hidden lg:flex' : 'flex',
|
| 334 |
+
graphCollapsed && 'lg:hidden',
|
| 335 |
+
// Maximized: fixed fullscreen overlay
|
| 336 |
+
graphMaximized
|
| 337 |
+
? 'fixed inset-0 z-50 rounded-none border-0 bg-white dark:bg-stone-950'
|
| 338 |
+
: 'h-[440px]',
|
| 339 |
)}>
|
| 340 |
+
|
| 341 |
+
{/* Graph toolbar */}
|
| 342 |
+
<div className="flex shrink-0 items-center gap-2 border-b border-surface-subtle px-3 py-2">
|
| 343 |
+
<span className="flex-1 text-xs font-semibold uppercase tracking-wide text-stone-400">
|
| 344 |
+
Knowledge Graph
|
| 345 |
+
</span>
|
| 346 |
+
{graphNodes.length > 0 && (
|
| 347 |
+
<span className="text-xs text-stone-400">
|
| 348 |
+
{graphNodes.length} nodes · {graphEdges.length} edges
|
| 349 |
+
</span>
|
| 350 |
+
)}
|
| 351 |
+
{/* Maximize / restore */}
|
| 352 |
+
<button
|
| 353 |
+
onClick={() => setGraphMaximized((m) => !m)}
|
| 354 |
+
title={graphMaximized ? 'Exit fullscreen' : 'Fullscreen'}
|
| 355 |
+
className="rounded p-1 text-stone-400 hover:bg-stone-100 hover:text-stone-600 dark:hover:bg-stone-800 dark:hover:text-stone-300"
|
| 356 |
+
aria-label={graphMaximized ? 'Exit fullscreen' : 'Fullscreen'}
|
| 357 |
+
>
|
| 358 |
+
{graphMaximized ? '⤡' : '⤢'}
|
| 359 |
+
</button>
|
| 360 |
+
{/* Collapse — desktop only; on mobile graph visibility is via tabs */}
|
| 361 |
+
<button
|
| 362 |
+
onClick={toggleGraphCollapsed}
|
| 363 |
+
title="Collapse graph"
|
| 364 |
+
className="hidden rounded p-1 text-stone-400 hover:bg-stone-100 hover:text-stone-600 dark:hover:bg-stone-800 dark:hover:text-stone-300 lg:block"
|
| 365 |
+
aria-label="Collapse graph"
|
| 366 |
+
>
|
| 367 |
+
◀
|
| 368 |
+
</button>
|
| 369 |
+
</div>
|
| 370 |
+
|
| 371 |
+
{/* Canvas — flex-1 fills remaining panel height */}
|
| 372 |
<KnowledgeGraph
|
| 373 |
nodes={graphNodes}
|
| 374 |
edges={graphEdges}
|
| 375 |
streaming={isActive}
|
| 376 |
onNodeClick={handleNodeClick}
|
| 377 |
onNodeHover={handleNodeHover}
|
| 378 |
+
className="flex-1"
|
| 379 |
/>
|
| 380 |
|
| 381 |
+
{/* Graph footer */}
|
| 382 |
+
<div className="flex shrink-0 items-center gap-2 border-t border-surface-subtle px-3 py-2">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 383 |
{gState === 'done' && (
|
| 384 |
<button
|
| 385 |
onClick={reconnectGraph}
|
| 386 |
+
className="ml-auto text-xs text-stone-400 hover:text-stone-600 dark:hover:text-stone-300"
|
| 387 |
title="Reload graph with latest data"
|
| 388 |
>
|
| 389 |
+
↻ Refresh
|
| 390 |
</button>
|
| 391 |
)}
|
| 392 |
+
{gState === 'retrying' && <NetworkRetry attempt={retryCount + 1} />}
|
| 393 |
</div>
|
| 394 |
|
| 395 |
+
{/* Max-retries error bar */}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 396 |
{gState === 'error' && (
|
| 397 |
+
<div className="flex shrink-0 items-center justify-between border-t border-stone-200 bg-stone-50 px-3 py-2 text-sm dark:border-stone-700 dark:bg-stone-800/40">
|
| 398 |
+
<span className="text-stone-500 dark:text-stone-400">
|
| 399 |
Couldn't load the knowledge graph.
|
| 400 |
</span>
|
| 401 |
<button
|
| 402 |
onClick={reconnectGraph}
|
| 403 |
+
className="ml-4 shrink-0 rounded bg-stone-200 px-3 py-1 text-xs font-medium text-stone-700 hover:bg-stone-300 dark:bg-stone-700 dark:text-stone-300 dark:hover:bg-stone-600"
|
| 404 |
>
|
| 405 |
Try again
|
| 406 |
</button>
|
frontend/src/lib/http.ts
CHANGED
|
@@ -130,9 +130,8 @@ export async function ssePost(
|
|
| 130 |
body: unknown,
|
| 131 |
signal: AbortSignal,
|
| 132 |
): Promise<Response> {
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
res = await fetch(`${env.apiBaseUrl}${path}`, {
|
| 136 |
method: 'POST',
|
| 137 |
credentials: 'include',
|
| 138 |
signal,
|
|
@@ -143,12 +142,31 @@ export async function ssePost(
|
|
| 143 |
},
|
| 144 |
body: JSON.stringify(body),
|
| 145 |
})
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
} catch (err) {
|
| 147 |
if ((err as Error).name === 'AbortError') throw err
|
| 148 |
useUIStore.getState().addToast({ type: 'error', message: 'No connection to server' })
|
| 149 |
throw new ApiError(0, 'Network error')
|
| 150 |
}
|
| 151 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
if (!res.ok) {
|
| 153 |
const requestId = res.headers.get('X-Request-ID') ?? undefined
|
| 154 |
const text = await res.text().catch(() => res.statusText)
|
|
|
|
| 130 |
body: unknown,
|
| 131 |
signal: AbortSignal,
|
| 132 |
): Promise<Response> {
|
| 133 |
+
const doFetch = () =>
|
| 134 |
+
fetch(`${env.apiBaseUrl}${path}`, {
|
|
|
|
| 135 |
method: 'POST',
|
| 136 |
credentials: 'include',
|
| 137 |
signal,
|
|
|
|
| 142 |
},
|
| 143 |
body: JSON.stringify(body),
|
| 144 |
})
|
| 145 |
+
|
| 146 |
+
let res: Response
|
| 147 |
+
try {
|
| 148 |
+
res = await doFetch()
|
| 149 |
} catch (err) {
|
| 150 |
if ((err as Error).name === 'AbortError') throw err
|
| 151 |
useUIStore.getState().addToast({ type: 'error', message: 'No connection to server' })
|
| 152 |
throw new ApiError(0, 'Network error')
|
| 153 |
}
|
| 154 |
|
| 155 |
+
if (res.status === 401) {
|
| 156 |
+
const refreshed = await refreshToken()
|
| 157 |
+
if (!refreshed) {
|
| 158 |
+
useAuthStore.getState().logout()
|
| 159 |
+
window.location.href = '/login'
|
| 160 |
+
throw new ApiError(401, 'Session expired')
|
| 161 |
+
}
|
| 162 |
+
try {
|
| 163 |
+
res = await doFetch()
|
| 164 |
+
} catch (err) {
|
| 165 |
+
if ((err as Error).name === 'AbortError') throw err
|
| 166 |
+
throw new ApiError(0, 'Network error after token refresh')
|
| 167 |
+
}
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
if (!res.ok) {
|
| 171 |
const requestId = res.headers.get('X-Request-ID') ?? undefined
|
| 172 |
const text = await res.text().catch(() => res.statusText)
|
frontend/src/stores/uiStore.ts
CHANGED
|
@@ -8,11 +8,13 @@ export interface Toast {
|
|
| 8 |
}
|
| 9 |
|
| 10 |
interface UIState {
|
| 11 |
-
theme:
|
| 12 |
-
sidebarOpen:
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
|
|
|
|
|
|
| 16 |
addToast: (toast: Omit<Toast, 'id'>) => void
|
| 17 |
removeToast: (id: string) => void
|
| 18 |
}
|
|
@@ -20,9 +22,10 @@ interface UIState {
|
|
| 20 |
export const useUIStore = create<UIState>()(
|
| 21 |
persist(
|
| 22 |
(set) => ({
|
| 23 |
-
theme:
|
| 24 |
-
sidebarOpen:
|
| 25 |
-
|
|
|
|
| 26 |
|
| 27 |
toggleTheme: () =>
|
| 28 |
set((s) => ({ theme: s.theme === 'light' ? 'dark' : 'light' })),
|
|
@@ -30,6 +33,9 @@ export const useUIStore = create<UIState>()(
|
|
| 30 |
toggleSidebar: () =>
|
| 31 |
set((s) => ({ sidebarOpen: !s.sidebarOpen })),
|
| 32 |
|
|
|
|
|
|
|
|
|
|
| 33 |
addToast: (toast) =>
|
| 34 |
set((s) => ({
|
| 35 |
toasts: [...s.toasts, { ...toast, id: crypto.randomUUID() }],
|
|
@@ -38,6 +44,6 @@ export const useUIStore = create<UIState>()(
|
|
| 38 |
removeToast: (id) =>
|
| 39 |
set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })),
|
| 40 |
}),
|
| 41 |
-
{ name: 'godspeed-ui', partialize: (s) => ({ theme: s.theme }) },
|
| 42 |
),
|
| 43 |
)
|
|
|
|
| 8 |
}
|
| 9 |
|
| 10 |
interface UIState {
|
| 11 |
+
theme: 'light' | 'dark'
|
| 12 |
+
sidebarOpen: boolean
|
| 13 |
+
graphCollapsed: boolean
|
| 14 |
+
toasts: Toast[]
|
| 15 |
+
toggleTheme: () => void
|
| 16 |
+
toggleSidebar: () => void
|
| 17 |
+
toggleGraphCollapsed: () => void
|
| 18 |
addToast: (toast: Omit<Toast, 'id'>) => void
|
| 19 |
removeToast: (id: string) => void
|
| 20 |
}
|
|
|
|
| 22 |
export const useUIStore = create<UIState>()(
|
| 23 |
persist(
|
| 24 |
(set) => ({
|
| 25 |
+
theme: 'light',
|
| 26 |
+
sidebarOpen: true,
|
| 27 |
+
graphCollapsed: false,
|
| 28 |
+
toasts: [],
|
| 29 |
|
| 30 |
toggleTheme: () =>
|
| 31 |
set((s) => ({ theme: s.theme === 'light' ? 'dark' : 'light' })),
|
|
|
|
| 33 |
toggleSidebar: () =>
|
| 34 |
set((s) => ({ sidebarOpen: !s.sidebarOpen })),
|
| 35 |
|
| 36 |
+
toggleGraphCollapsed: () =>
|
| 37 |
+
set((s) => ({ graphCollapsed: !s.graphCollapsed })),
|
| 38 |
+
|
| 39 |
addToast: (toast) =>
|
| 40 |
set((s) => ({
|
| 41 |
toasts: [...s.toasts, { ...toast, id: crypto.randomUUID() }],
|
|
|
|
| 44 |
removeToast: (id) =>
|
| 45 |
set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })),
|
| 46 |
}),
|
| 47 |
+
{ name: 'godspeed-ui', partialize: (s) => ({ theme: s.theme, graphCollapsed: s.graphCollapsed }) },
|
| 48 |
),
|
| 49 |
)
|
frontend/src/types/user.ts
CHANGED
|
@@ -6,12 +6,13 @@ export interface Team {
|
|
| 6 |
}
|
| 7 |
|
| 8 |
export interface User {
|
| 9 |
-
id:
|
| 10 |
-
email:
|
| 11 |
-
name:
|
| 12 |
-
role:
|
| 13 |
-
team_id:
|
| 14 |
-
team?:
|
| 15 |
-
is_new_hire:
|
| 16 |
-
mentor_id?:
|
|
|
|
| 17 |
}
|
|
|
|
| 6 |
}
|
| 7 |
|
| 8 |
export interface User {
|
| 9 |
+
id: string
|
| 10 |
+
email: string
|
| 11 |
+
name: string
|
| 12 |
+
role: Role
|
| 13 |
+
team_id: string
|
| 14 |
+
team?: Team
|
| 15 |
+
is_new_hire: boolean
|
| 16 |
+
mentor_id?: string
|
| 17 |
+
allowed_channel_ids?: string[]
|
| 18 |
}
|