@omniradiology/omnirad 0.1.3

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.
Files changed (155) hide show
  1. package/README.md +438 -0
  2. package/app/api/ai-config/route.ts +131 -0
  3. package/app/api/ai-config/test/route.ts +49 -0
  4. package/app/api/auth/auto-login/route.ts +66 -0
  5. package/app/api/auth/check/route.ts +17 -0
  6. package/app/api/auth/login/route.ts +72 -0
  7. package/app/api/auth/logout/route.ts +25 -0
  8. package/app/api/auth/me/route.ts +75 -0
  9. package/app/api/auth/password/route.ts +49 -0
  10. package/app/api/auth/setup/route.ts +63 -0
  11. package/app/api/auth/users/route.ts +100 -0
  12. package/app/api/auth/wipe/route.ts +27 -0
  13. package/app/api/compliance/anonymize/patient/[id]/route.ts +104 -0
  14. package/app/api/compliance/audit/route.ts +110 -0
  15. package/app/api/compliance/export/patient/[id]/route.ts +108 -0
  16. package/app/api/compliance/restrict/patient/[id]/route.ts +59 -0
  17. package/app/api/compliance/settings/route.ts +93 -0
  18. package/app/api/copilot/annotate/route.ts +94 -0
  19. package/app/api/copilot/chat/route.ts +238 -0
  20. package/app/api/copilot/history/route.ts +95 -0
  21. package/app/api/copilot/reports/route.ts +81 -0
  22. package/app/api/fhir/Bundle/report/[id]/route.ts +85 -0
  23. package/app/api/fhir/DiagnosticReport/[id]/route.ts +45 -0
  24. package/app/api/fhir/ImagingStudy/[id]/route.ts +57 -0
  25. package/app/api/fhir/Patient/[id]/route.ts +26 -0
  26. package/app/api/fhir/ServiceRequest/route.ts +85 -0
  27. package/app/api/fhir/config/route.ts +102 -0
  28. package/app/api/fhir/config/test-connection/route.ts +49 -0
  29. package/app/api/fhir/metadata/route.ts +51 -0
  30. package/app/api/pacs/metadata/route.ts +32 -0
  31. package/app/api/pacs/qido/instances/route.ts +39 -0
  32. package/app/api/pacs/qido/series/route.ts +38 -0
  33. package/app/api/pacs/qido/studies/route.ts +37 -0
  34. package/app/api/pacs/test/route.ts +30 -0
  35. package/app/api/pacs/wado/render/route.ts +51 -0
  36. package/app/api/patients/[id]/reports/route.ts +18 -0
  37. package/app/api/patients/[id]/route.ts +43 -0
  38. package/app/api/patients/merge/route.ts +57 -0
  39. package/app/api/patients/route.ts +67 -0
  40. package/app/api/patients/search/route.ts +25 -0
  41. package/app/api/reports/[id]/route.ts +84 -0
  42. package/app/api/reports/[id]/status/route.ts +87 -0
  43. package/app/api/reports/clear/route.ts +16 -0
  44. package/app/api/reports/route.ts +112 -0
  45. package/app/api/segmentation-config/route.ts +238 -0
  46. package/app/api/settings/route.ts +245 -0
  47. package/app/api/settings/test-supabase/route.ts +103 -0
  48. package/app/api/upload/route.ts +48 -0
  49. package/app/copilot/page.tsx +30 -0
  50. package/app/globals.css +141 -0
  51. package/app/history/page.tsx +242 -0
  52. package/app/icon.svg +3 -0
  53. package/app/layout.tsx +47 -0
  54. package/app/login/page.tsx +175 -0
  55. package/app/pacs/page.tsx +78 -0
  56. package/app/page.tsx +125 -0
  57. package/app/patients/[id]/page.tsx +315 -0
  58. package/app/patients/page.tsx +110 -0
  59. package/app/profile/page.tsx +208 -0
  60. package/app/reports/page.tsx +432 -0
  61. package/app/settings/page.tsx +454 -0
  62. package/app/setup/page.tsx +199 -0
  63. package/components/admin/AuditLogTable.tsx +293 -0
  64. package/components/copilot/ActivityIndicator.tsx +215 -0
  65. package/components/copilot/ChatHistoryPanel.tsx +140 -0
  66. package/components/copilot/ChatMessage.tsx +251 -0
  67. package/components/copilot/ClickableReference.tsx +40 -0
  68. package/components/copilot/CopilotCornerstoneViewer.tsx +562 -0
  69. package/components/copilot/CopilotPanel.tsx +311 -0
  70. package/components/copilot/FindingsList.tsx +75 -0
  71. package/components/copilot/ViewerPanel.tsx +460 -0
  72. package/components/copilot/WorkspaceLayout.tsx +398 -0
  73. package/components/dashboard/AIConfigPanel.tsx +339 -0
  74. package/components/dashboard/AppearancePanel.tsx +491 -0
  75. package/components/dashboard/ApprovalModal.tsx +163 -0
  76. package/components/dashboard/CollaborationPanel.tsx +134 -0
  77. package/components/dashboard/CopilotConfigPanel.tsx +337 -0
  78. package/components/dashboard/DicomViewer.tsx +645 -0
  79. package/components/dashboard/FhirIntegrationPanel.tsx +331 -0
  80. package/components/dashboard/FullReportOverlay.tsx +269 -0
  81. package/components/dashboard/ImageViewer.tsx +541 -0
  82. package/components/dashboard/PatientForm.tsx +597 -0
  83. package/components/dashboard/RejectionModal.tsx +74 -0
  84. package/components/dashboard/ReportEditor.tsx +160 -0
  85. package/components/dashboard/ReportTemplates.tsx +729 -0
  86. package/components/dashboard/ReportView.tsx +539 -0
  87. package/components/dashboard/SegmentationConfigPanel.tsx +490 -0
  88. package/components/dashboard/StudyPlaceholder.tsx +17 -0
  89. package/components/dashboard/SupabaseIntegrationPanel.tsx +345 -0
  90. package/components/dashboard/UserManagementPanel.tsx +272 -0
  91. package/components/layout/ClientLayout.tsx +39 -0
  92. package/components/layout/Header.tsx +20 -0
  93. package/components/layout/Sidebar.tsx +119 -0
  94. package/components/pacs/PacsImageViewerModal.tsx +121 -0
  95. package/components/pacs/PacsSearchFilters.tsx +117 -0
  96. package/components/pacs/PacsSeriesViewer.tsx +190 -0
  97. package/components/pacs/PacsStudyTable.tsx +113 -0
  98. package/components/patients/patient-card.tsx +117 -0
  99. package/components/patients/patient-header.tsx +122 -0
  100. package/components/patients/patient-search.tsx +137 -0
  101. package/components/patients/patient-timeline.tsx +153 -0
  102. package/components/settings/ComplianceSettingsPanel.tsx +278 -0
  103. package/components/settings/SecurityPanel.tsx +418 -0
  104. package/components/ui/badge.tsx +19 -0
  105. package/components/ui/basic.tsx +156 -0
  106. package/db/index.ts +350 -0
  107. package/db/migrations/0000_odd_quasimodo.sql +117 -0
  108. package/db/migrations/meta/0000_snapshot.json +778 -0
  109. package/db/migrations/meta/_journal.json +13 -0
  110. package/db/schema.ts +239 -0
  111. package/drizzle.config.ts +10 -0
  112. package/lib/api.ts +689 -0
  113. package/lib/auth.ts +22 -0
  114. package/lib/copilot/action-executor.ts +94 -0
  115. package/lib/copilot/action-types.ts +72 -0
  116. package/lib/copilot/coordinate-mapper.ts +84 -0
  117. package/lib/dicomImageExtractor.ts +103 -0
  118. package/lib/dicomMetadataParser.ts +111 -0
  119. package/lib/fhir/client.ts +25 -0
  120. package/lib/fhir/constants.ts +21 -0
  121. package/lib/fhir/diagnostic-report.ts +88 -0
  122. package/lib/fhir/helpers.ts +73 -0
  123. package/lib/fhir/imaging-study.ts +49 -0
  124. package/lib/fhir/patient.ts +55 -0
  125. package/lib/fhir/service-request.ts +85 -0
  126. package/lib/fhir.ts +6 -0
  127. package/lib/pacs/dicom-utils.ts +72 -0
  128. package/lib/pacs/dicomweb.ts +72 -0
  129. package/lib/pacs/server-utils.ts +37 -0
  130. package/lib/patients.ts +25 -0
  131. package/lib/pdfHelper.ts +119 -0
  132. package/lib/reportHtmlGenerator.ts +581 -0
  133. package/lib/security/audit.ts +180 -0
  134. package/lib/security/authz.ts +246 -0
  135. package/lib/security/phi-redaction.ts +156 -0
  136. package/lib/security/rate-limit.ts +106 -0
  137. package/lib/security/secrets.ts +179 -0
  138. package/lib/supabase.ts +72 -0
  139. package/lib/utils.ts +6 -0
  140. package/next.config.ts +35 -0
  141. package/package.json +76 -0
  142. package/public/file.svg +1 -0
  143. package/public/globe.svg +1 -0
  144. package/public/logo.svg +8 -0
  145. package/public/next.svg +1 -0
  146. package/public/omnirad-favicon.svg +8 -0
  147. package/public/vercel.svg +1 -0
  148. package/public/window.svg +1 -0
  149. package/tsconfig.json +34 -0
  150. package/types/copilot-viewer.ts +155 -0
  151. package/types/copilot.ts +105 -0
  152. package/types/fhir.ts +21 -0
  153. package/types/html2pdf.d.ts +20 -0
  154. package/types/index.ts +139 -0
  155. package/types/pacs.ts +41 -0
@@ -0,0 +1,398 @@
1
+ "use client";
2
+
3
+ import { useState, useCallback, useEffect, useRef } from "react";
4
+ import { ViewerAction, ViewerTab, ChatMessage, CopilotPatientContext, Reference, ActivityState, INITIAL_ACTIVITY_STATE } from "@/types/copilot";
5
+ import type { AIViewerAction, CopilotViewerRef, FindingSummary } from "@/types/copilot-viewer";
6
+ import { executeViewerActions } from "@/lib/copilot/action-executor";
7
+ import ViewerPanel from "./ViewerPanel";
8
+ import CopilotPanel from "./CopilotPanel";
9
+ import FindingsList from "./FindingsList";
10
+
11
+ interface WorkspaceLayoutProps {
12
+ initialPatientId?: string;
13
+ initialReportId?: string;
14
+ }
15
+
16
+ export default function WorkspaceLayout({ initialPatientId, initialReportId }: WorkspaceLayoutProps) {
17
+ // ─── Viewer State ────────────────────────────────────────────────────────
18
+ const [activeTab, setActiveTab] = useState<ViewerTab>("report");
19
+ const [currentReportId, setCurrentReportId] = useState<string | null>(initialReportId || null);
20
+ const [currentStudyId, setCurrentStudyId] = useState<string | null>(null);
21
+ const [currentSlice, setCurrentSlice] = useState<number>(1);
22
+ const [currentPatientId, setCurrentPatientId] = useState<string | null>(initialPatientId || null);
23
+ const [currentPatientName, setCurrentPatientName] = useState<string | null>(null);
24
+
25
+ // ─── Cornerstone Viewer Ref ──────────────────────────────────────────────
26
+ const viewerRef = useRef<CopilotViewerRef | null>(null);
27
+
28
+ // ─── AI Findings State ───────────────────────────────────────────────────
29
+ const [aiFindings, setAiFindings] = useState<FindingSummary[]>([]);
30
+
31
+ // ─── Chat State ──────────────────────────────────────────────────────────
32
+ const [chatSessionId, setChatSessionId] = useState<string>(() => {
33
+ if (typeof window !== "undefined") {
34
+ return sessionStorage.getItem("copilot_session_id") || `session_${Date.now()}`;
35
+ }
36
+ return `session_${Date.now()}`;
37
+ });
38
+
39
+ const [chatMessages, setChatMessages] = useState<ChatMessage[]>(() => {
40
+ if (typeof window !== "undefined") {
41
+ const saved = sessionStorage.getItem("copilot_messages");
42
+ if (saved) {
43
+ try { return JSON.parse(saved); } catch (e) { console.error(e); }
44
+ }
45
+ }
46
+ return [];
47
+ });
48
+
49
+ const [isLoading, setIsLoading] = useState(false);
50
+ const [activityState, setActivityState] = useState<ActivityState>(INITIAL_ACTIVITY_STATE);
51
+
52
+ // Persist chat state
53
+ useEffect(() => {
54
+ if (typeof window !== "undefined") {
55
+ sessionStorage.setItem("copilot_session_id", chatSessionId);
56
+ sessionStorage.setItem("copilot_messages", JSON.stringify(chatMessages));
57
+ }
58
+ }, [chatSessionId, chatMessages]);
59
+
60
+ // ─── Core Bridge Function: Legacy AI → Viewer ────────────────────────────
61
+ const executeLegacyViewerAction = useCallback((action: ViewerAction) => {
62
+ if (!action) return;
63
+
64
+ switch (action.type) {
65
+ case "OPEN_REPORT":
66
+ setCurrentReportId(action.reportId);
67
+ if (action.patientName) setCurrentPatientName(action.patientName);
68
+ setActiveTab("report");
69
+ break;
70
+ case "OPEN_DICOM":
71
+ setCurrentStudyId(action.studyId);
72
+ if (action.reportId) setCurrentReportId(action.reportId);
73
+ if (action.slice) setCurrentSlice(action.slice);
74
+ setActiveTab("dicom");
75
+ break;
76
+ case "OPEN_METADATA":
77
+ setCurrentPatientId(action.patientId);
78
+ setActiveTab("metadata");
79
+ break;
80
+ case "SWITCH_TAB":
81
+ setActiveTab(action.tab);
82
+ break;
83
+ case "COMPARE_VIEW":
84
+ // Future: side-by-side comparison
85
+ setCurrentReportId(action.reportId1);
86
+ setActiveTab("report");
87
+ break;
88
+ }
89
+ }, []);
90
+
91
+ // ─── New AI Viewer Actions Executor ──────────────────────────────────────
92
+ const executeAIViewerActions = useCallback(async (actions: AIViewerAction[]) => {
93
+ if (!actions || actions.length === 0) return;
94
+
95
+ // Switch to DICOM tab when we have annotation/segmentation actions
96
+ const hasVisualActions = actions.some(
97
+ (a) => a.type === "annotation" || a.type === "segmentation" || a.type === "navigate" || a.type === "viewport"
98
+ );
99
+ if (hasVisualActions) {
100
+ setActiveTab("dicom");
101
+ }
102
+
103
+ // Small delay to let the tab switch and viewer mount
104
+ await new Promise((resolve) => setTimeout(resolve, 100));
105
+
106
+ // Execute through the action executor
107
+ await executeViewerActions(actions, viewerRef.current);
108
+ }, []);
109
+
110
+ const handleReferenceClick = useCallback((ref: Reference) => {
111
+ executeLegacyViewerAction(ref.viewerAction);
112
+ }, [executeLegacyViewerAction]);
113
+
114
+ // ─── Unified Action Executor ─────────────────────────────────────────────
115
+ const handleExecuteActions = useCallback(async (actions: any[]) => {
116
+ if (!actions || actions.length === 0) return;
117
+
118
+ const legacyActions: ViewerAction[] = [];
119
+ const aiActions: AIViewerAction[] = [];
120
+
121
+ for (const action of actions) {
122
+ if (action.type === "OPEN_REPORT" || action.type === "OPEN_DICOM" ||
123
+ action.type === "OPEN_METADATA" || action.type === "SWITCH_TAB" ||
124
+ action.type === "COMPARE_VIEW") {
125
+ legacyActions.push(action as ViewerAction);
126
+ } else if (action.type === "annotation" || action.type === "segmentation" ||
127
+ action.type === "navigate" || action.type === "viewport" ||
128
+ action.type === "clear") {
129
+ aiActions.push(action as AIViewerAction);
130
+ }
131
+ }
132
+
133
+ // Execute legacy actions first (e.g. mounting the right study)
134
+ for (const action of legacyActions) {
135
+ executeLegacyViewerAction(action);
136
+ }
137
+
138
+ // If there are visual overlay actions, clear old ones so they don't stack visually
139
+ if (aiActions.some(a => a.type === "annotation" || a.type === "segmentation")) {
140
+ viewerRef.current?.clearAIFindings();
141
+ }
142
+
143
+ // Execute new AI viewer actions
144
+ if (aiActions.length > 0) {
145
+ await executeAIViewerActions(aiActions);
146
+ }
147
+ }, [executeLegacyViewerAction, executeAIViewerActions]);
148
+
149
+ // ─── Send Chat Message ───────────────────────────────────────────────────
150
+ const sendMessage = useCallback(async (messageText: string) => {
151
+ if (!messageText.trim() || isLoading) return;
152
+
153
+ const userMessage: ChatMessage = {
154
+ id: `msg_${Date.now()}`,
155
+ role: "user",
156
+ content: messageText,
157
+ timestamp: new Date().toISOString(),
158
+ };
159
+
160
+ setChatMessages(prev => [...prev, userMessage]);
161
+ setIsLoading(true);
162
+ setActivityState({
163
+ isActive: true,
164
+ currentStatus: "thinking",
165
+ currentLabel: "Thinking...",
166
+ currentTool: null,
167
+ completedSteps: [],
168
+ startedAt: Date.now(),
169
+ });
170
+
171
+ try {
172
+ const response = await fetch("/api/copilot/chat", {
173
+ method: "POST",
174
+ headers: { "Content-Type": "application/json" },
175
+ body: JSON.stringify({
176
+ message: messageText,
177
+ stream: true,
178
+ patientContext: {
179
+ patientId: currentPatientId,
180
+ currentReportId: currentReportId,
181
+ patientName: currentPatientName,
182
+ imageBase64: viewerRef.current?.getCurrentImageBase64?.() || undefined,
183
+ },
184
+ study_context: {
185
+ reportId: currentReportId,
186
+ currentSlice: currentSlice > 0 ? currentSlice - 1 : 0,
187
+ totalSlices: viewerRef.current?.getTotalSlices?.() || 0,
188
+ modality: null,
189
+ },
190
+ chatHistory: chatMessages.map(m => ({
191
+ role: m.role,
192
+ content: m.content,
193
+ })),
194
+ sessionId: chatSessionId,
195
+ }),
196
+ });
197
+
198
+ // Check if we got an SSE stream back
199
+ const contentType = response.headers.get("content-type") || "";
200
+
201
+ if (contentType.includes("text/event-stream") && response.body) {
202
+ // ─── SSE Streaming Mode ──────────────────────────────────
203
+ const reader = response.body.getReader();
204
+ const decoder = new TextDecoder();
205
+ let buffer = "";
206
+
207
+ while (true) {
208
+ const { done, value } = await reader.read();
209
+ if (done) break;
210
+
211
+ buffer += decoder.decode(value, { stream: true });
212
+ const lines = buffer.split("\n");
213
+ buffer = lines.pop() || "";
214
+
215
+ for (const line of lines) {
216
+ if (!line.startsWith("data: ")) continue;
217
+ try {
218
+ const event = JSON.parse(line.slice(6));
219
+
220
+ if (event.type === "status") {
221
+ setActivityState(prev => ({
222
+ ...prev,
223
+ currentStatus: event.status,
224
+ currentLabel: event.label,
225
+ currentTool: event.tool,
226
+ completedSteps: prev.currentLabel && prev.currentLabel !== event.label
227
+ ? [...prev.completedSteps, { label: prev.currentLabel, tool: prev.currentTool, timestamp: Date.now() }]
228
+ : prev.completedSteps,
229
+ }));
230
+ } else if (event.type === "complete") {
231
+ if (event.sessionId) {
232
+ setChatSessionId(event.sessionId);
233
+ }
234
+
235
+ const assistantMessage: ChatMessage = {
236
+ id: `msg_${Date.now()}_assistant`,
237
+ role: "assistant",
238
+ content: event.message || "No response received.",
239
+ viewerActions: event.viewer_actions || [],
240
+ references: event.references || [],
241
+ timestamp: new Date().toISOString(),
242
+ };
243
+
244
+ setChatMessages(prev => [...prev, assistantMessage]);
245
+
246
+ if (event.viewer_actions && event.viewer_actions.length > 0) {
247
+ await handleExecuteActions(event.viewer_actions);
248
+ }
249
+
250
+ if (event.findings_summary && event.findings_summary.length > 0) {
251
+ setAiFindings(prev => [...prev, ...event.findings_summary]);
252
+ }
253
+ } else if (event.type === "error") {
254
+ const errorMessage: ChatMessage = {
255
+ id: `msg_${Date.now()}_error`,
256
+ role: "assistant",
257
+ content: event.message || "⚠️ An error occurred.",
258
+ timestamp: new Date().toISOString(),
259
+ };
260
+ setChatMessages(prev => [...prev, errorMessage]);
261
+ }
262
+ } catch {
263
+ // Skip malformed JSON lines
264
+ }
265
+ }
266
+ }
267
+ } else {
268
+ // ─── Fallback: Non-Streaming JSON Mode ──────────────────
269
+ const data = await response.json();
270
+
271
+ if (data.sessionId) {
272
+ setChatSessionId(data.sessionId);
273
+ }
274
+
275
+ const assistantMessage: ChatMessage = {
276
+ id: `msg_${Date.now()}_assistant`,
277
+ role: "assistant",
278
+ content: data.message || "No response received.",
279
+ viewerActions: data.viewerActions || [],
280
+ references: data.references || [],
281
+ timestamp: new Date().toISOString(),
282
+ };
283
+
284
+ setChatMessages(prev => [...prev, assistantMessage]);
285
+
286
+ if (data.viewerActions && data.viewerActions.length > 0) {
287
+ await handleExecuteActions(data.viewerActions);
288
+ }
289
+
290
+ if (data.findingsSummary && data.findingsSummary.length > 0) {
291
+ setAiFindings(prev => [...prev, ...data.findingsSummary]);
292
+ }
293
+ }
294
+ } catch (error) {
295
+ console.error("[Copilot] Chat error:", error);
296
+ const errorMessage: ChatMessage = {
297
+ id: `msg_${Date.now()}_error`,
298
+ role: "assistant",
299
+ content: "⚠️ Could not connect to the AI Copilot. Make sure the backend is running.",
300
+ timestamp: new Date().toISOString(),
301
+ };
302
+ setChatMessages(prev => [...prev, errorMessage]);
303
+ } finally {
304
+ setIsLoading(false);
305
+ setActivityState(prev => ({ ...prev, isActive: false }));
306
+ }
307
+ }, [isLoading, currentPatientId, currentReportId, currentPatientName, currentSlice, chatMessages, chatSessionId, handleExecuteActions]);
308
+
309
+ // ─── Clear AI Findings ───────────────────────────────────────────────────
310
+ const clearAllFindings = useCallback(() => {
311
+ viewerRef.current?.clearAIFindings();
312
+ setAiFindings([]);
313
+ }, []);
314
+
315
+ // ─── New Chat Session ────────────────────────────────────────────────────
316
+ const startNewChat = useCallback(() => {
317
+ setChatSessionId(`session_${Date.now()}`);
318
+ setChatMessages([]);
319
+ clearAllFindings();
320
+ }, [clearAllFindings]);
321
+
322
+ // ─── Patient Context ─────────────────────────────────────────────────────
323
+ const patientContext: CopilotPatientContext = {
324
+ patientId: currentPatientId,
325
+ currentReportId: currentReportId,
326
+ patientName: currentPatientName,
327
+ };
328
+
329
+ // ─── Load Chat Session ───────────────────────────────────────────────────
330
+ const loadSession = useCallback(async (sessionId: string) => {
331
+ setIsLoading(true);
332
+ try {
333
+ const res = await fetch(`/api/copilot/history?sessionId=${encodeURIComponent(sessionId)}`);
334
+ const data = await res.json();
335
+ if (Array.isArray(data)) {
336
+ setChatSessionId(sessionId);
337
+ setChatMessages(data);
338
+ }
339
+ } catch (err) {
340
+ console.error("[Copilot] Error loading session:", err);
341
+ } finally {
342
+ setIsLoading(false);
343
+ }
344
+ }, [setChatSessionId, setChatMessages, setIsLoading]);
345
+
346
+ const handleReportSelect = useCallback((reportId: string) => {
347
+ setCurrentReportId(reportId);
348
+ setActiveTab("report");
349
+ }, []);
350
+
351
+ const handlePatientContext = useCallback((pid: string, pname?: string) => {
352
+ setCurrentPatientId(prev => prev !== pid ? pid : prev);
353
+ if (pname) {
354
+ setCurrentPatientName(prev => prev !== pname ? pname : prev);
355
+ }
356
+ }, []);
357
+
358
+ return (
359
+ <div className="flex h-full overflow-hidden">
360
+ {/* Left Panel — 60% Viewer */}
361
+ <div className="w-[60%] min-w-[400px] border-r border-border-primary flex flex-col bg-bg-primary overflow-hidden">
362
+ <ViewerPanel
363
+ activeTab={activeTab}
364
+ onTabChange={setActiveTab}
365
+ currentReportId={currentReportId}
366
+ currentStudyId={currentStudyId}
367
+ currentSlice={currentSlice}
368
+ currentPatientId={currentPatientId}
369
+ onReportSelect={handleReportSelect}
370
+ onPatientContext={handlePatientContext}
371
+ viewerRef={viewerRef}
372
+ />
373
+ {/* AI Findings List (below viewer when findings exist) */}
374
+ <FindingsList
375
+ findings={aiFindings}
376
+ viewerRef={viewerRef.current}
377
+ onClearAll={clearAllFindings}
378
+ />
379
+ </div>
380
+
381
+ {/* Right Panel — 40% AI Copilot */}
382
+ <div className="w-[40%] min-w-[340px] flex flex-col bg-bg-surface overflow-hidden">
383
+ <CopilotPanel
384
+ messages={chatMessages}
385
+ isLoading={isLoading}
386
+ activityState={activityState}
387
+ onSendMessage={sendMessage}
388
+ onReferenceClick={handleReferenceClick}
389
+ onExecuteActions={handleExecuteActions}
390
+ onNewChat={startNewChat}
391
+ onLoadSession={loadSession}
392
+ patientContext={patientContext}
393
+ findingsCount={aiFindings.length}
394
+ />
395
+ </div>
396
+ </div>
397
+ );
398
+ }