vedana-backoffice 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,894 @@
1
+ from dataclasses import dataclass
2
+ from datetime import datetime, timedelta
3
+
4
+ import reflex as rx
5
+ import sqlalchemy as sa
6
+ from jims_core.db import ThreadDB, ThreadEventDB
7
+ from vedana_core.app import make_vedana_app
8
+
9
+ from vedana_backoffice.components.ui_chat import render_message_bubble
10
+ from vedana_backoffice.states.jims import ThreadViewState
11
+ from vedana_backoffice.ui import app_header
12
+ from vedana_backoffice.util import datetime_to_age
13
+
14
+
15
+ @dataclass
16
+ class ThreadVis:
17
+ thread_id: str
18
+ created_at: str
19
+ thread_age: str
20
+ interface: str
21
+ review_status: str
22
+ priority: str
23
+ tag1: str = ""
24
+ tag2: str = ""
25
+ tag3: str = ""
26
+
27
+ @classmethod
28
+ def create(
29
+ cls,
30
+ thread_id: str,
31
+ created_at: datetime,
32
+ thread_config: dict,
33
+ review_status: str = "-",
34
+ priority: str = "-",
35
+ tags_sample: list[str] | None = None,
36
+ ) -> "ThreadVis":
37
+ cfg = thread_config or {}
38
+ iface_val = cfg.get("interface") or cfg.get("channel") or cfg.get("source")
39
+ if isinstance(iface_val, dict):
40
+ iface_val = iface_val.get("name") or iface_val.get("type") or str(iface_val)
41
+ ts = list(tags_sample or [])
42
+ while len(ts) < 3:
43
+ ts.append("")
44
+ return cls(
45
+ thread_id=str(thread_id),
46
+ created_at=datetime.strftime(created_at, "%Y-%m-%d %H:%M:%S"),
47
+ thread_age=datetime_to_age(created_at),
48
+ interface=str(iface_val or ""),
49
+ review_status=str(review_status),
50
+ priority=str(priority),
51
+ tag1=str(ts[0] or ""),
52
+ tag2=str(ts[1] or ""),
53
+ tag3=str(ts[2] or ""),
54
+ # last_activity=datetime.strftime(last_activity, "%Y-%m-%d %H:%M:%S"), # todo format tz
55
+ # last_activity_age=datetime_to_age(last_activity),
56
+ )
57
+
58
+
59
+ class ThreadListState(rx.State):
60
+ threads_refreshing: bool = True
61
+ threads: list[ThreadVis] = []
62
+
63
+ # Filters
64
+ from_date: str = ""
65
+ to_date: str = ""
66
+ search_text: str = ""
67
+ sort_reverse: bool = True
68
+ review_filter: str = "Review: All" # Default
69
+ sort_by: str = "Sort by: Date"
70
+ available_tags: list[str] = []
71
+ selected_tags: list[str] = []
72
+
73
+ @staticmethod
74
+ def _parse_date(date_str: str | None) -> datetime | None:
75
+ try:
76
+ if date_str:
77
+ return datetime.strptime(date_str, "%Y-%m-%d")
78
+ except Exception:
79
+ return None
80
+ return None
81
+
82
+ @rx.event
83
+ def set_from_date(self, value: str) -> None:
84
+ self.from_date = value
85
+
86
+ @rx.event
87
+ def set_to_date(self, value: str) -> None:
88
+ self.to_date = value
89
+
90
+ @rx.event
91
+ def set_search_text(self, value: str) -> None:
92
+ self.search_text = value
93
+
94
+ @rx.event
95
+ def clear_filters(self) -> None:
96
+ self.from_date = ""
97
+ self.to_date = ""
98
+ self.search_text = ""
99
+ self.sort_reverse = True
100
+ self.review_filter = "Review: All"
101
+ self.sort_by = "Sort by: Date"
102
+ return None
103
+
104
+ @rx.event
105
+ async def toggle_sort(self) -> None:
106
+ self.sort_reverse = not self.sort_reverse
107
+ await self.get_data() # type: ignore[operator]
108
+
109
+ @rx.event
110
+ async def set_review_filter(self, value: str) -> None:
111
+ self.review_filter = value
112
+ await self.get_data() # type: ignore[operator]
113
+
114
+ @rx.event
115
+ async def set_sort_by(self, value: str) -> None:
116
+ self.sort_by = value
117
+ await self.get_data() # type: ignore[operator]
118
+
119
+ @rx.event
120
+ def toggle_tag_filter(self, tag: str, value: bool) -> None:
121
+ try:
122
+ t = str(tag)
123
+ if value:
124
+ if t not in self.selected_tags:
125
+ self.selected_tags = [*self.selected_tags, t]
126
+ else:
127
+ self.selected_tags = [x for x in self.selected_tags if x != t]
128
+ except Exception:
129
+ pass
130
+
131
+ @rx.event
132
+ def clear_tag_filter(self) -> None:
133
+ self.selected_tags = []
134
+
135
+ @rx.event
136
+ async def get_data(self) -> None:
137
+ """compiles the entire thread list table"""
138
+ vedana_app = await make_vedana_app()
139
+
140
+ from_dt = self._parse_date(self.from_date)
141
+ to_dt = self._parse_date(self.to_date)
142
+ if to_dt is not None:
143
+ # Make end exclusive by adding one day
144
+ to_dt = to_dt + timedelta(days=1)
145
+
146
+ async with vedana_app.sessionmaker() as session:
147
+ last_event_sq = (
148
+ sa.select(
149
+ ThreadEventDB.thread_id.label("t_id"),
150
+ sa.func.max(ThreadEventDB.created_at).label("last_at"),
151
+ )
152
+ .group_by(ThreadEventDB.thread_id)
153
+ .subquery()
154
+ )
155
+
156
+ stmt = sa.select(ThreadDB, last_event_sq.c.last_at).join(
157
+ last_event_sq, last_event_sq.c.t_id == ThreadDB.thread_id
158
+ )
159
+
160
+ # Apply date filters at SQL level when possible
161
+ if from_dt is not None:
162
+ stmt = stmt.where(last_event_sq.c.last_at >= from_dt)
163
+ if to_dt is not None:
164
+ stmt = stmt.where(last_event_sq.c.last_at < to_dt)
165
+
166
+ results = await session.execute(stmt)
167
+ rows = results.all()
168
+
169
+ # Collect thread ids and last_at (any event)
170
+ thread_ids: list[str] = []
171
+ last_at_by_tid: dict[str, datetime] = {}
172
+ for thread_obj, last_at in rows:
173
+ tid = str(thread_obj.thread_id)
174
+ thread_ids.append(tid)
175
+ try:
176
+ last_at_by_tid[tid] = last_at or thread_obj.created_at
177
+ except Exception:
178
+ last_at_by_tid[tid] = thread_obj.created_at
179
+
180
+ # Also collect last chat-related activity (exclude jims.backoffice.% events)
181
+ last_chat_at_by_tid: dict[str, datetime] = {}
182
+ if thread_ids:
183
+ chat_stmt = (
184
+ sa.select(
185
+ ThreadEventDB.thread_id.label("t_id"),
186
+ sa.func.max(ThreadEventDB.created_at).label("last_chat_at"),
187
+ )
188
+ .where(
189
+ sa.and_(
190
+ ThreadEventDB.thread_id.in_(thread_ids),
191
+ sa.not_(ThreadEventDB.event_type.like("jims.backoffice.%")),
192
+ )
193
+ )
194
+ .group_by(ThreadEventDB.thread_id)
195
+ )
196
+ chat_rows = (await session.execute(chat_stmt)).all()
197
+ for t_id, last_chat_at in chat_rows:
198
+ try:
199
+ last_chat_at_by_tid[str(t_id)] = last_chat_at
200
+ except Exception:
201
+ continue
202
+
203
+ # Load backoffice events for review/priority aggregation
204
+ review_status_by_tid: dict[str, str] = {tid: "-" for tid in thread_ids}
205
+ priority_by_tid: dict[str, str] = {tid: "-" for tid in thread_ids}
206
+ priority_rank_by_tid: dict[str, int] = {tid: -1 for tid in thread_ids}
207
+
208
+ if thread_ids:
209
+ bo_stmt = sa.select(ThreadEventDB).where(
210
+ sa.and_(
211
+ ThreadEventDB.thread_id.in_(thread_ids),
212
+ ThreadEventDB.event_type.like("jims.backoffice.%"), # todo split jims event_type in domains?
213
+ )
214
+ )
215
+ bo_rows = (await session.execute(bo_stmt)).scalars().all()
216
+
217
+ has_feedback: dict[str, bool] = {tid: False for tid in thread_ids}
218
+ has_resolved: dict[str, bool] = {tid: False for tid in thread_ids}
219
+
220
+ # Tag aggregation
221
+ available_tags_set: set[str] = set()
222
+ tags_by_event: dict[str, set[str]] = {}
223
+ bo_rows_sorted = sorted(bo_rows, key=lambda r: getattr(r, "created_at", datetime.min))
224
+
225
+ # Comment resolution aggregation
226
+ unresolved_by_tid: dict[str, int] = {tid: 0 for tid in thread_ids}
227
+ comment_tid_by_id: dict[str, str] = {}
228
+ resolved_cids: set[str] = set()
229
+
230
+ for ev in bo_rows_sorted:
231
+ tid = str(ev.thread_id)
232
+ etype = str(getattr(ev, "event_type", ""))
233
+ if etype == "jims.backoffice.feedback":
234
+ has_feedback[tid] = True
235
+ try:
236
+ sev = str((getattr(ev, "event_data", {}) or {}).get("severity", "Low"))
237
+ except Exception:
238
+ sev = "Low"
239
+ rank = {"Low": 0, "Medium": 1, "High": 2}.get(sev, 0)
240
+ if rank > priority_rank_by_tid.get(tid, -1):
241
+ priority_rank_by_tid[tid] = rank
242
+ priority_by_tid[tid] = {0: "Low", 1: "Medium", 2: "High"}.get(rank, "Low")
243
+ # Count unresolved comment
244
+ try:
245
+ cid = str(getattr(ev, "event_id"))
246
+ if cid:
247
+ comment_tid_by_id[cid] = tid
248
+ unresolved_by_tid[tid] = unresolved_by_tid.get(tid, 0) + 1
249
+ except Exception:
250
+ pass
251
+ elif etype == "jims.backoffice.review_resolved":
252
+ has_resolved[tid] = True
253
+ elif etype in ("jims.backoffice.tag_added", "jims.backoffice.tag_removed"):
254
+ ed = dict(getattr(ev, "event_data", {}) or {})
255
+ tag = str(ed.get("tag", "")).strip()
256
+ target = str(ed.get("target_event_id", ""))
257
+ if tag:
258
+ available_tags_set.add(tag)
259
+ if not target:
260
+ continue
261
+ cur = tags_by_event.setdefault(target, set())
262
+ if etype == "jims.backoffice.tag_added" and tag:
263
+ cur.add(tag)
264
+ elif etype == "jims.backoffice.tag_removed" and tag:
265
+ try:
266
+ cur.discard(tag)
267
+ except Exception:
268
+ pass
269
+ elif etype in ("jims.backoffice.comment_resolved", "jims.backoffice.comment_closed"):
270
+ ed = dict(getattr(ev, "event_data", {}) or {})
271
+ cid = str(ed.get("comment_id", ""))
272
+ if not cid or cid in resolved_cids:
273
+ continue
274
+ resolved_cids.add(cid)
275
+ rtid = comment_tid_by_id.get(cid, tid)
276
+ try:
277
+ unresolved_by_tid[rtid] = max(0, unresolved_by_tid.get(rtid, 0) - 1)
278
+ except Exception:
279
+ pass
280
+
281
+ for tid in thread_ids:
282
+ if has_resolved.get(tid) or (has_feedback.get(tid) and unresolved_by_tid.get(tid, 0) == 0):
283
+ review_status_by_tid[tid] = "Complete"
284
+ elif has_feedback.get(tid):
285
+ review_status_by_tid[tid] = "Pending"
286
+ else:
287
+ review_status_by_tid[tid] = "-"
288
+
289
+ # derive thread-level tag sets from event-level tags
290
+ thread_tags_by_tid: dict[str, set[str]] = {tid: set() for tid in thread_ids}
291
+ for ev in bo_rows_sorted:
292
+ etype = str(getattr(ev, "event_type", ""))
293
+ if etype not in ("jims.backoffice.tag_added", "jims.backoffice.tag_removed"):
294
+ continue
295
+ ed = dict(getattr(ev, "event_data", {}) or {})
296
+ target = str(ed.get("target_event_id", ""))
297
+ if not target:
298
+ continue
299
+ cur_tags = tags_by_event.get(target, set())
300
+ # union into its thread
301
+ thread_tags_by_tid[str(ev.thread_id)].update(cur_tags)
302
+
303
+ # store available tag values for multi-select
304
+ current_tags: set[str] = set()
305
+ for _tid, _tags in thread_tags_by_tid.items():
306
+ current_tags.update(set(_tags))
307
+ self.available_tags = sorted(list(current_tags))
308
+
309
+ else:
310
+ thread_tags_by_tid = {}
311
+
312
+ items: list[ThreadVis] = []
313
+ for thread_obj, last_at in rows:
314
+ try:
315
+ sample = sorted(list(thread_tags_by_tid.get(str(thread_obj.thread_id), set())))[:3]
316
+ items.append(
317
+ ThreadVis.create(
318
+ thread_id=str(thread_obj.thread_id),
319
+ created_at=thread_obj.created_at,
320
+ # last_activity=last_at or thread_obj.created_at,
321
+ thread_config=thread_obj.thread_config,
322
+ review_status=review_status_by_tid.get(str(thread_obj.thread_id), "-"),
323
+ priority=priority_by_tid.get(str(thread_obj.thread_id), "-"),
324
+ tags_sample=sample,
325
+ )
326
+ )
327
+ except Exception:
328
+ # Fall back if last_at is None or bad
329
+ sample = sorted(list(thread_tags_by_tid.get(str(thread_obj.thread_id), set())))[:3]
330
+ items.append(
331
+ ThreadVis.create(
332
+ thread_id=str(thread_obj.thread_id),
333
+ created_at=thread_obj.created_at,
334
+ thread_config=thread_obj.thread_config,
335
+ review_status=review_status_by_tid.get(str(thread_obj.thread_id), "-"),
336
+ priority=priority_by_tid.get(str(thread_obj.thread_id), "-"),
337
+ tags_sample=sample,
338
+ )
339
+ )
340
+
341
+ # In-memory search (thread_id or interface)
342
+ search = (self.search_text or "").strip().lower()
343
+ if search:
344
+ items = [it for it in items if search in it.thread_id.lower() or search in (it.interface or "").lower()]
345
+
346
+ # Filter by review status
347
+ rf = (self.review_filter.removeprefix("Review: ") or "All").strip()
348
+ if rf and rf != "All":
349
+ items = [it for it in items if it.review_status == rf]
350
+
351
+ # Filter by selected tags (OR semantics)
352
+ sel = set(self.selected_tags or [])
353
+ if sel:
354
+
355
+ def _has_any(t: ThreadVis) -> bool:
356
+ try:
357
+ tid = t.thread_id
358
+ return len(sel.intersection(thread_tags_by_tid.get(tid, set()))) > 0
359
+ except Exception:
360
+ return False
361
+
362
+ items = [it for it in items if _has_any(it)]
363
+
364
+ # Sorting (by last chat-related activity for Date sorts)
365
+ sort_val = self.sort_by.removeprefix("Sort by:").strip()
366
+ if sort_val == "Date":
367
+ items = sorted(
368
+ items,
369
+ key=lambda it: last_chat_at_by_tid.get(it.thread_id, last_at_by_tid.get(it.thread_id, datetime.min)),
370
+ reverse=self.sort_reverse, # sort_reverse = True by default
371
+ )
372
+ elif sort_val == "Priority":
373
+ rank_map = {"-": -1, "Low": 0, "Medium": 1, "High": 2}
374
+ items = sorted(
375
+ items,
376
+ key=lambda it: (
377
+ rank_map.get(it.priority, -1),
378
+ last_chat_at_by_tid.get(it.thread_id, last_at_by_tid.get(it.thread_id, datetime.min)),
379
+ ),
380
+ reverse=self.sort_reverse, # sort_reverse = True by default
381
+ )
382
+ else: # Date descending as a fallback
383
+ items = sorted(
384
+ items,
385
+ key=lambda it: last_chat_at_by_tid.get(it.thread_id, last_at_by_tid.get(it.thread_id, datetime.min)),
386
+ reverse=True,
387
+ )
388
+
389
+ self.threads = items
390
+ self.threads_refreshing = False
391
+
392
+
393
+ @rx.page(route="/jims", on_load=ThreadListState.get_data)
394
+ def jims_thread_list_page() -> rx.Component:
395
+ def _badge_style(bg: str, fg: str) -> dict[str, str]:
396
+ return {
397
+ "backgroundColor": bg,
398
+ "color": fg,
399
+ }
400
+
401
+ def review_badge(value: str) -> rx.Component: # type: ignore[valid-type]
402
+ return rx.cond(
403
+ value == "Pending",
404
+ rx.badge("Pending", variant="soft", size="1", style=_badge_style("var(--amber-4)", "var(--amber-11)")),
405
+ rx.cond(
406
+ value == "Complete",
407
+ rx.badge("Complete", variant="soft", size="1", style=_badge_style("var(--green-4)", "var(--green-11)")),
408
+ rx.badge(value, variant="soft", size="1", style=_badge_style("var(--gray-4)", "var(--gray-12)")),
409
+ ),
410
+ )
411
+
412
+ def priority_badge(value: str) -> rx.Component: # type: ignore[valid-type]
413
+ return rx.cond(
414
+ value == "High",
415
+ rx.badge("High", variant="soft", size="1", style=_badge_style("var(--tomato-4)", "var(--tomato-11)")),
416
+ rx.cond(
417
+ value == "Medium",
418
+ rx.badge("Medium", variant="soft", size="1", style=_badge_style("var(--amber-4)", "var(--amber-11)")),
419
+ rx.badge(value, variant="soft", size="1", style=_badge_style("var(--gray-4)", "var(--gray-12)")),
420
+ ),
421
+ )
422
+
423
+ filters = rx.hstack(
424
+ rx.hstack(
425
+ rx.hstack(
426
+ rx.input(
427
+ value=ThreadListState.from_date,
428
+ type="date",
429
+ on_change=ThreadListState.set_from_date,
430
+ ),
431
+ rx.text("-"),
432
+ rx.input(
433
+ value=ThreadListState.to_date,
434
+ type="date",
435
+ on_change=ThreadListState.set_to_date,
436
+ ),
437
+ align="center",
438
+ spacing="1",
439
+ ),
440
+ rx.select(
441
+ items=["All", "Pending", "Complete"],
442
+ placeholder="Review: All",
443
+ on_change=ThreadListState.set_review_filter,
444
+ width="180px",
445
+ ),
446
+ rx.dialog.root(
447
+ rx.dialog.trigger(
448
+ rx.button(
449
+ rx.cond(
450
+ ThreadListState.selected_tags.length() > 0, # type: ignore[attr-defined]
451
+ "Tags: " + ThreadListState.selected_tags.join(", "), # type: ignore[attr-defined]
452
+ "Tags: All",
453
+ ),
454
+ variant="soft",
455
+ color_scheme="gray",
456
+ )
457
+ ),
458
+ rx.dialog.content(
459
+ rx.vstack(
460
+ rx.hstack(
461
+ rx.dialog.title("Filter by Tags"),
462
+ rx.dialog.close(rx.button("Close", variant="ghost", size="1")),
463
+ justify="between",
464
+ align="center",
465
+ width="100%",
466
+ ),
467
+ rx.scroll_area(
468
+ rx.vstack(
469
+ rx.foreach(
470
+ ThreadListState.available_tags,
471
+ lambda t: rx.checkbox(
472
+ t,
473
+ checked=ThreadListState.selected_tags.contains(t), # type: ignore[attr-defined]
474
+ on_change=lambda v, tag=t: ThreadListState.toggle_tag_filter(tag=tag, value=v), # type: ignore[operator]
475
+ ),
476
+ ),
477
+ spacing="2",
478
+ width="100%",
479
+ ),
480
+ type="always",
481
+ scrollbars="vertical",
482
+ ),
483
+ rx.hstack(
484
+ rx.button("Apply", on_click=ThreadListState.get_data, size="1"),
485
+ rx.button(
486
+ "Clear",
487
+ variant="soft",
488
+ on_click=[ThreadListState.clear_tag_filter, ThreadListState.get_data],
489
+ size="1",
490
+ ),
491
+ spacing="2",
492
+ justify="end",
493
+ width="100%",
494
+ ),
495
+ spacing="3",
496
+ )
497
+ ),
498
+ ),
499
+ rx.hstack(
500
+ rx.select(
501
+ items=["Date", "Priority"],
502
+ on_change=ThreadListState.set_sort_by,
503
+ placeholder="Sort By: Date",
504
+ width="160px",
505
+ ),
506
+ rx.cond(
507
+ ThreadListState.sort_reverse,
508
+ rx.icon(
509
+ "arrow-down-1-0",
510
+ size=28,
511
+ stroke_width=1.5,
512
+ cursor="pointer",
513
+ flex_shrink="0",
514
+ on_click=ThreadListState.toggle_sort,
515
+ ), # type: ignore
516
+ rx.icon(
517
+ "arrow-down-0-1",
518
+ size=28,
519
+ stroke_width=1.5,
520
+ cursor="pointer",
521
+ flex_shrink="0",
522
+ on_click=ThreadListState.toggle_sort,
523
+ ), # type: ignore
524
+ ),
525
+ spacing="0",
526
+ ),
527
+ rx.input(
528
+ placeholder="Search thread or interface",
529
+ value=ThreadListState.search_text,
530
+ on_change=ThreadListState.set_search_text,
531
+ width="280px",
532
+ ),
533
+ ),
534
+ rx.hstack(
535
+ rx.button("Search", on_click=ThreadListState.get_data),
536
+ rx.button(
537
+ "Clear",
538
+ variant="soft",
539
+ on_click=[ThreadListState.clear_filters, ThreadListState.get_data],
540
+ ),
541
+ spacing="2",
542
+ ),
543
+ justify="between",
544
+ width="100%",
545
+ align="center",
546
+ wrap="wrap",
547
+ )
548
+
549
+ table = rx.table.root(
550
+ rx.table.header(
551
+ rx.table.row(
552
+ rx.table.column_header_cell("Thread ID"),
553
+ rx.table.column_header_cell("Created"),
554
+ rx.table.column_header_cell("Age"),
555
+ rx.table.column_header_cell("Interface"),
556
+ rx.table.column_header_cell("Tags"),
557
+ rx.table.column_header_cell("Review"),
558
+ rx.table.column_header_cell("Priority"),
559
+ ),
560
+ ),
561
+ rx.table.body(
562
+ rx.foreach(
563
+ ThreadListState.threads,
564
+ lambda t: rx.table.row(
565
+ rx.table.cell(
566
+ rx.button(
567
+ t.thread_id,
568
+ variant="ghost",
569
+ color_scheme="gray",
570
+ size="1",
571
+ on_click=ThreadViewState.select_thread(thread_id=t.thread_id), # type: ignore[operator]
572
+ )
573
+ ), # type: ignore[call-arg,func-returns-value]
574
+ rx.table.cell(t.created_at),
575
+ rx.table.cell(t.thread_age),
576
+ rx.table.cell(t.interface),
577
+ rx.table.cell(
578
+ rx.hstack(
579
+ rx.cond(t.tag1 != "", rx.badge(t.tag1, variant="soft", size="1", color_scheme="gray")),
580
+ rx.cond(t.tag2 != "", rx.badge(t.tag2, variant="soft", size="1", color_scheme="gray")),
581
+ rx.cond(t.tag3 != "", rx.badge(t.tag3, variant="soft", size="1", color_scheme="gray")),
582
+ spacing="1",
583
+ )
584
+ ),
585
+ rx.table.cell(review_badge(t.review_status)),
586
+ rx.table.cell(priority_badge(t.priority)),
587
+ style=rx.cond(
588
+ t.thread_id == ThreadViewState.selected_thread_id, {"backgroundColor": "var(--accent-3)"}, {}
589
+ ),
590
+ ),
591
+ ),
592
+ ),
593
+ )
594
+
595
+ def _render_event_as_msg(ev): # type: ignore[valid-type]
596
+ msg = {
597
+ "id": ev.event_id,
598
+ "content": ev.content,
599
+ "created_at": ev.created_at_str,
600
+ "is_assistant": ev.role == "assistant",
601
+ "tag_label": ev.event_type,
602
+ "tags": ev.visible_tags, # tags/comments from backoffice annotations
603
+ "comments": ev.feedback_comments,
604
+ "has_tech": ev.has_technical_info,
605
+ "has_models": ev.has_models,
606
+ "has_vts": ev.has_vts,
607
+ "has_cypher": ev.has_cypher,
608
+ "models_str": ev.models_str,
609
+ "vts_str": ev.vts_str,
610
+ "cypher_str": ev.cypher_str,
611
+ "show_details": ThreadViewState.expanded_event_id == ev.event_id,
612
+ "event_data_str": ev.event_data_str,
613
+ "generic_meta": ev.generic_meta,
614
+ }
615
+
616
+ tag_dialog = rx.dialog.root(
617
+ rx.dialog.content(
618
+ rx.vstack(
619
+ rx.hstack(
620
+ rx.dialog.title("Add Tags"),
621
+ rx.dialog.close(
622
+ rx.button("Close", variant="ghost", size="1", on_click=ThreadViewState.close_tag_dialog)
623
+ ),
624
+ justify="between",
625
+ align="center",
626
+ width="100%",
627
+ ),
628
+ rx.hstack(
629
+ rx.input(
630
+ placeholder="Add new tag...",
631
+ value=ThreadViewState.new_tag_text_for_event.get(ev.event_id, ""),
632
+ on_change=lambda v: ThreadViewState.set_new_tag_text_for_event(v, event_id=ev.event_id),
633
+ width="100%",
634
+ ),
635
+ rx.button(
636
+ "Add",
637
+ size="1",
638
+ on_click=ThreadViewState.add_new_tag_to_available(event_id=ev.event_id),
639
+ ),
640
+ spacing="2",
641
+ width="100%",
642
+ ),
643
+ rx.scroll_area(
644
+ rx.vstack(
645
+ rx.foreach(
646
+ ThreadViewState.available_tags,
647
+ lambda t: rx.checkbox(
648
+ t,
649
+ checked=ThreadViewState.selected_tags_for_event.get(ev.event_id, []).contains(t), # type: ignore[attr-defined]
650
+ on_change=lambda v, tag=t: ThreadViewState.toggle_tag_selection_for_event(
651
+ tag=tag, event_id=ev.event_id, checked=v
652
+ ), # type: ignore[operator]
653
+ ),
654
+ ),
655
+ spacing="2",
656
+ width="100%",
657
+ ),
658
+ type="always",
659
+ scrollbars="vertical",
660
+ style={"width": "100%", "padding": "0"},
661
+ ),
662
+ rx.hstack(
663
+ rx.button(
664
+ "Apply",
665
+ size="1",
666
+ on_click=ThreadViewState.apply_tags_to_event(event_id=ev.event_id), # type: ignore[call-arg,func-returns-value]
667
+ ),
668
+ spacing="2",
669
+ justify="end",
670
+ width="100%",
671
+ ),
672
+ spacing="3",
673
+ ),
674
+ ),
675
+ open=ThreadViewState.tag_dialog_open_for_event == ev.event_id,
676
+ on_open_change=ThreadViewState.handle_tag_dialog_open_change, # type: ignore[operator]
677
+ )
678
+
679
+ action_line = rx.cond(
680
+ (ev.role == "assistant") & ev.content != "",
681
+ rx.hstack(
682
+ rx.button(
683
+ "Add tag",
684
+ size="1",
685
+ on_click=ThreadViewState.open_tag_dialog(event_id=ev.event_id), # type: ignore[call-arg,func-returns-value]
686
+ ),
687
+ tag_dialog,
688
+ # Add Note (single-line input)
689
+ rx.input(
690
+ placeholder="Add note...",
691
+ value=ThreadViewState.note_text_by_event.get(ev.event_id, ""),
692
+ on_change=lambda v: ThreadViewState.set_note_text_for(v, event_id=ev.event_id), # type: ignore[call-arg,func-returns-value]
693
+ width="40%",
694
+ size="1",
695
+ ),
696
+ rx.select(
697
+ items=["Low", "Medium", "High"],
698
+ value=ThreadViewState.note_severity_by_event.get(ev.event_id, "Low"),
699
+ on_change=lambda v: ThreadViewState.set_note_severity_for(v, event_id=ev.event_id), # type: ignore[call-arg,func-returns-value]
700
+ size="1",
701
+ ),
702
+ rx.button(
703
+ "Add note",
704
+ size="1",
705
+ on_click=ThreadViewState.submit_note_for(event_id=ev.event_id), # type: ignore[call-arg,func-returns-value]
706
+ ),
707
+ spacing="2",
708
+ wrap="wrap",
709
+ width="100%",
710
+ align="center",
711
+ ),
712
+ rx.box(),
713
+ )
714
+
715
+ # Comments thread displayed under the message
716
+ comments_component = rx.vstack(
717
+ rx.foreach(
718
+ ev.feedback_comments,
719
+ lambda c: rx.card(
720
+ rx.vstack(
721
+ rx.hstack(
722
+ rx.cond(
723
+ c["severity"] == "High",
724
+ rx.badge("High", variant="soft", size="1", color_scheme="tomato"),
725
+ rx.cond(
726
+ c["severity"] == "Medium",
727
+ rx.badge("Medium", variant="soft", size="1", color_scheme="amber"),
728
+ rx.badge("Low", variant="soft", size="1", color_scheme="gray"),
729
+ ),
730
+ ),
731
+ rx.text(c["created_at"], size="1", color="gray"),
732
+ rx.spacer(),
733
+ # Status badge
734
+ rx.cond(
735
+ c.get("status", "open") == "resolved",
736
+ rx.badge("Resolved", variant="soft", size="1", color_scheme="green"),
737
+ rx.cond(
738
+ c.get("status", "open") == "closed",
739
+ rx.badge("Ignored", variant="soft", size="1", color_scheme="gray"),
740
+ rx.box(),
741
+ ),
742
+ ),
743
+ spacing="2",
744
+ align="center",
745
+ width="100%",
746
+ ),
747
+ rx.text(
748
+ c["note"],
749
+ style={
750
+ "whiteSpace": "pre-wrap",
751
+ "wordBreak": "break-word",
752
+ },
753
+ ),
754
+ # Actions row
755
+ rx.hstack(
756
+ rx.button(
757
+ "✔",
758
+ variant="soft",
759
+ size="1",
760
+ color_scheme="green",
761
+ disabled=c.get("status", "open") != "open",
762
+ on_click=ThreadViewState.mark_comment_resolved(comment_id=c["id"]), # type: ignore[call-arg,func-returns-value]
763
+ ),
764
+ rx.button(
765
+ "✖",
766
+ variant="soft",
767
+ size="1",
768
+ color_scheme="gray",
769
+ disabled=c.get("status", "open") != "open",
770
+ on_click=ThreadViewState.mark_comment_closed(comment_id=c["id"]), # type: ignore[call-arg,func-returns-value]
771
+ ),
772
+ spacing="2",
773
+ ),
774
+ spacing="1",
775
+ width="100%",
776
+ ),
777
+ padding="0.5em",
778
+ variant="surface",
779
+ ),
780
+ ),
781
+ spacing="2",
782
+ width="100%",
783
+ )
784
+
785
+ extras = rx.vstack(
786
+ comments_component,
787
+ action_line,
788
+ spacing="2",
789
+ )
790
+
791
+ def _tag_badge(tag: str): # type: ignore[valid-type]
792
+ return rx.badge(
793
+ rx.hstack(
794
+ rx.text(tag),
795
+ rx.button(
796
+ "×",
797
+ variant="ghost",
798
+ size="1",
799
+ color_scheme="gray",
800
+ on_click=ThreadViewState.remove_tag(event_id=ev.event_id, tag=tag), # type: ignore[operator, call-arg,func-returns-value]
801
+ ),
802
+ spacing="1",
803
+ ),
804
+ variant="soft",
805
+ size="1",
806
+ color_scheme="gray",
807
+ )
808
+
809
+ tags_component = rx.hstack(
810
+ rx.foreach(ev.visible_tags, _tag_badge),
811
+ spacing="1",
812
+ )
813
+
814
+ return render_message_bubble(
815
+ msg,
816
+ on_toggle_details=ThreadViewState.toggle_details(event_id=ev.event_id), # type: ignore[call-arg,func-returns-value]
817
+ extras=extras,
818
+ corner_tags_component=tags_component,
819
+ )
820
+
821
+ filters_box = rx.box(
822
+ filters,
823
+ margin_bottom="1em",
824
+ )
825
+
826
+ # Left panel (thread list with its own scroll)
827
+ left_panel = rx.box(
828
+ rx.cond(
829
+ ThreadListState.threads_refreshing,
830
+ rx.center("Loading threads..."),
831
+ rx.scroll_area(
832
+ table,
833
+ type="always",
834
+ scrollbars="vertical",
835
+ style={"height": "100%"},
836
+ ),
837
+ ),
838
+ flex="1",
839
+ min_height="0",
840
+ style={"height": "100%", "overflow": "hidden"},
841
+ )
842
+
843
+ # Right panel (conversation fills height with scroll)
844
+ right_panel = rx.cond(
845
+ ThreadViewState.selected_thread_id == "",
846
+ rx.center(rx.text("Select a thread to view conversation"), style={"height": "100%"}),
847
+ rx.box(
848
+ rx.scroll_area(
849
+ rx.vstack(
850
+ rx.foreach(ThreadViewState.events, _render_event_as_msg),
851
+ spacing="3",
852
+ width="100%",
853
+ padding_bottom="1em",
854
+ style={
855
+ "maxWidth": "100%",
856
+ "overflowX": "hidden",
857
+ },
858
+ ),
859
+ type="always",
860
+ scrollbars="vertical",
861
+ style={
862
+ "height": "100%",
863
+ "maxWidth": "100%",
864
+ "overflowX": "hidden",
865
+ },
866
+ ),
867
+ flex="1",
868
+ min_height="0",
869
+ style={"height": "100%", "overflow": "hidden"},
870
+ ),
871
+ )
872
+
873
+ return rx.vstack(
874
+ app_header(),
875
+ filters_box,
876
+ rx.box(
877
+ rx.grid(
878
+ left_panel,
879
+ right_panel,
880
+ columns="2",
881
+ spacing="4",
882
+ sm_columns="1",
883
+ width="100%",
884
+ style={"height": "100%"},
885
+ ),
886
+ flex="1",
887
+ min_height="0",
888
+ width="100%",
889
+ ),
890
+ spacing="4",
891
+ height="100vh",
892
+ overflow="hidden",
893
+ width="100%",
894
+ )