Samyuktha24 commited on
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(containerRef.current)
69
  fg.backgroundColor('transparent')
70
  .nodeId('id')
71
  .nodeLabel('name')
72
  .nodeColor((n: FGNode) => n.color)
73
  .nodeRelSize(6)
74
- // Use fixed dimensions — container may be display:none on mobile tab switch
75
- .width(containerRef.current.clientWidth || 380)
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="relative h-[400px] w-full overflow-hidden rounded-xl border border-surface-subtle">
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] = useState<Tab>('graph')
 
 
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 — graph right, answer left */}
235
- <div className="flex flex-col gap-6 lg:grid lg:grid-cols-[1fr_380px] lg:items-start lg:gap-8">
 
 
 
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 column — always mounted ─────────────────────── */}
312
- {/* CSS hides it on mobile when answer tab is active; on desktop always visible */}
313
  <div className={cn(
314
- 'flex flex-col gap-2',
315
- activeTab === 'answer' && 'hidden lg:flex',
 
 
 
 
 
 
316
  )}>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
317
  <KnowledgeGraph
318
  nodes={graphNodes}
319
  edges={graphEdges}
320
  streaming={isActive}
321
  onNodeClick={handleNodeClick}
322
  onNodeHover={handleNodeHover}
 
323
  />
324
 
325
- {/* Graph footer: node count + refresh/reload controls */}
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 shrink-0 text-xs text-stone-400 hover:text-stone-600 dark:hover:text-stone-300"
340
  title="Reload graph with latest data"
341
  >
342
- ↻ Refresh graph
343
  </button>
344
  )}
 
345
  </div>
346
 
347
- {/* Auto-retry indicator */}
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 rounded-lg border border-stone-200 bg-stone-50 px-4 py-3 text-sm dark:border-stone-700 dark:bg-stone-800/40">
355
- <span className="text-stone-600 dark:text-stone-400">
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.5 text-xs font-medium text-stone-700 hover:bg-stone-300 dark:bg-stone-700 dark:text-stone-300 dark:hover:bg-stone-600"
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
- let res: Response
134
- try {
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: 'light' | 'dark'
12
- sidebarOpen: boolean
13
- toasts: Toast[]
14
- toggleTheme: () => void
15
- toggleSidebar: () => void
 
 
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: 'light',
24
- sidebarOpen: true,
25
- toasts: [],
 
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: 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
  }
 
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
  }