markupr 2.1.8

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 (299) hide show
  1. package/.claude/commands/review-feedback.md +47 -0
  2. package/.eslintrc.json +35 -0
  3. package/.github/CODEOWNERS +16 -0
  4. package/.github/FUNDING.yml +1 -0
  5. package/.github/ISSUE_TEMPLATE/bug_report.md +56 -0
  6. package/.github/ISSUE_TEMPLATE/feature_request.md +54 -0
  7. package/.github/PULL_REQUEST_TEMPLATE.md +89 -0
  8. package/.github/dependabot.yml +70 -0
  9. package/.github/workflows/ci.yml +184 -0
  10. package/.github/workflows/deploy-landing.yml +134 -0
  11. package/.github/workflows/nightly.yml +288 -0
  12. package/.github/workflows/release.yml +318 -0
  13. package/CHANGELOG.md +127 -0
  14. package/CLAUDE.md +137 -0
  15. package/CODE_OF_CONDUCT.md +9 -0
  16. package/CONTRIBUTING.md +390 -0
  17. package/LICENSE +21 -0
  18. package/PRODUCT_VISION.md +277 -0
  19. package/README.md +517 -0
  20. package/SECURITY.md +51 -0
  21. package/SIGNING_INSTRUCTIONS.md +284 -0
  22. package/assets/DMG_BACKGROUND_INSTRUCTIONS.md +130 -0
  23. package/assets/svg-source/dmg-background.svg +70 -0
  24. package/assets/svg-source/icon.svg +20 -0
  25. package/assets/svg-source/tray-icon-processing.svg +7 -0
  26. package/assets/svg-source/tray-icon-recording.svg +7 -0
  27. package/assets/svg-source/tray-icon.svg +6 -0
  28. package/assets/tray-complete.png +0 -0
  29. package/assets/tray-complete@2x.png +0 -0
  30. package/assets/tray-completeTemplate.png +0 -0
  31. package/assets/tray-completeTemplate@2x.png +0 -0
  32. package/assets/tray-error.png +0 -0
  33. package/assets/tray-error@2x.png +0 -0
  34. package/assets/tray-errorTemplate.png +0 -0
  35. package/assets/tray-errorTemplate@2x.png +0 -0
  36. package/assets/tray-icon-processing.png +0 -0
  37. package/assets/tray-icon-processing@2x.png +0 -0
  38. package/assets/tray-icon-processingTemplate.png +0 -0
  39. package/assets/tray-icon-processingTemplate@2x.png +0 -0
  40. package/assets/tray-icon-recording.png +0 -0
  41. package/assets/tray-icon-recording@2x.png +0 -0
  42. package/assets/tray-icon-recordingTemplate.png +0 -0
  43. package/assets/tray-icon-recordingTemplate@2x.png +0 -0
  44. package/assets/tray-icon.png +0 -0
  45. package/assets/tray-icon@2x.png +0 -0
  46. package/assets/tray-iconTemplate.png +0 -0
  47. package/assets/tray-iconTemplate@2x.png +0 -0
  48. package/assets/tray-idle.png +0 -0
  49. package/assets/tray-idle@2x.png +0 -0
  50. package/assets/tray-idleTemplate.png +0 -0
  51. package/assets/tray-idleTemplate@2x.png +0 -0
  52. package/assets/tray-processing-0.png +0 -0
  53. package/assets/tray-processing-0@2x.png +0 -0
  54. package/assets/tray-processing-0Template.png +0 -0
  55. package/assets/tray-processing-0Template@2x.png +0 -0
  56. package/assets/tray-processing-1.png +0 -0
  57. package/assets/tray-processing-1@2x.png +0 -0
  58. package/assets/tray-processing-1Template.png +0 -0
  59. package/assets/tray-processing-1Template@2x.png +0 -0
  60. package/assets/tray-processing-2.png +0 -0
  61. package/assets/tray-processing-2@2x.png +0 -0
  62. package/assets/tray-processing-2Template.png +0 -0
  63. package/assets/tray-processing-2Template@2x.png +0 -0
  64. package/assets/tray-processing-3.png +0 -0
  65. package/assets/tray-processing-3@2x.png +0 -0
  66. package/assets/tray-processing-3Template.png +0 -0
  67. package/assets/tray-processing-3Template@2x.png +0 -0
  68. package/assets/tray-processing.png +0 -0
  69. package/assets/tray-processing@2x.png +0 -0
  70. package/assets/tray-processingTemplate.png +0 -0
  71. package/assets/tray-processingTemplate@2x.png +0 -0
  72. package/assets/tray-recording.png +0 -0
  73. package/assets/tray-recording@2x.png +0 -0
  74. package/assets/tray-recordingTemplate.png +0 -0
  75. package/assets/tray-recordingTemplate@2x.png +0 -0
  76. package/build/DMG_BACKGROUND_SPEC.md +50 -0
  77. package/build/dmg-background.png +0 -0
  78. package/build/dmg-background@2x.png +0 -0
  79. package/build/entitlements.mac.inherit.plist +27 -0
  80. package/build/entitlements.mac.plist +41 -0
  81. package/build/favicon-16.png +0 -0
  82. package/build/favicon-180.png +0 -0
  83. package/build/favicon-192.png +0 -0
  84. package/build/favicon-32.png +0 -0
  85. package/build/favicon-48.png +0 -0
  86. package/build/favicon-512.png +0 -0
  87. package/build/favicon-64.png +0 -0
  88. package/build/icon-128.png +0 -0
  89. package/build/icon-16.png +0 -0
  90. package/build/icon-24.png +0 -0
  91. package/build/icon-256.png +0 -0
  92. package/build/icon-32.png +0 -0
  93. package/build/icon-48.png +0 -0
  94. package/build/icon-64.png +0 -0
  95. package/build/icon.icns +0 -0
  96. package/build/icon.ico +0 -0
  97. package/build/icon.iconset/icon_128x128.png +0 -0
  98. package/build/icon.iconset/icon_128x128@2x.png +0 -0
  99. package/build/icon.iconset/icon_16x16.png +0 -0
  100. package/build/icon.iconset/icon_16x16@2x.png +0 -0
  101. package/build/icon.iconset/icon_256x256.png +0 -0
  102. package/build/icon.iconset/icon_256x256@2x.png +0 -0
  103. package/build/icon.iconset/icon_32x32.png +0 -0
  104. package/build/icon.iconset/icon_32x32@2x.png +0 -0
  105. package/build/icon.iconset/icon_512x512.png +0 -0
  106. package/build/icon.iconset/icon_512x512@2x.png +0 -0
  107. package/build/icon.png +0 -0
  108. package/build/installer-header.bmp +0 -0
  109. package/build/installer-header.png +0 -0
  110. package/build/installer-sidebar.bmp +0 -0
  111. package/build/installer-sidebar.png +0 -0
  112. package/build/installer.nsh +45 -0
  113. package/build/overlay-processing.png +0 -0
  114. package/build/overlay-recording.png +0 -0
  115. package/build/toolbar-record.png +0 -0
  116. package/build/toolbar-screenshot.png +0 -0
  117. package/build/toolbar-settings.png +0 -0
  118. package/build/toolbar-stop.png +0 -0
  119. package/dist/main/index.mjs +12612 -0
  120. package/dist/preload/index.mjs +907 -0
  121. package/dist/renderer/assets/index-CCmUjl9K.js +19495 -0
  122. package/dist/renderer/assets/index-CUqz_Gs6.css +2270 -0
  123. package/dist/renderer/index.html +27 -0
  124. package/docs/AI_AGENT_QUICKSTART.md +42 -0
  125. package/docs/AI_PIPELINE_DESIGN.md +595 -0
  126. package/docs/API.md +514 -0
  127. package/docs/ARCHITECTURE.md +460 -0
  128. package/docs/CONFIGURATION.md +336 -0
  129. package/docs/DEVELOPMENT.md +508 -0
  130. package/docs/EXPORT_FORMATS.md +451 -0
  131. package/docs/GETTING_STARTED.md +236 -0
  132. package/docs/KEYBOARD_SHORTCUTS.md +334 -0
  133. package/docs/TROUBLESHOOTING.md +418 -0
  134. package/docs/landing/index.html +672 -0
  135. package/docs/landing/script.js +342 -0
  136. package/docs/landing/styles.css +1543 -0
  137. package/electron-builder.yml +140 -0
  138. package/electron.vite.config.ts +63 -0
  139. package/package.json +108 -0
  140. package/railway.json +12 -0
  141. package/scripts/build.mjs +51 -0
  142. package/scripts/generate-icons.mjs +314 -0
  143. package/scripts/generate-installer-images.cjs +253 -0
  144. package/scripts/generate-tray-icons.mjs +258 -0
  145. package/scripts/notarize.cjs +180 -0
  146. package/scripts/one-click-clean-test.sh +147 -0
  147. package/scripts/postinstall.mjs +36 -0
  148. package/scripts/setup-markupr.sh +55 -0
  149. package/setup +17 -0
  150. package/site/index.html +1835 -0
  151. package/site/package.json +11 -0
  152. package/site/railway.json +12 -0
  153. package/site/server.js +31 -0
  154. package/src/main/AutoUpdater.ts +392 -0
  155. package/src/main/CrashRecovery.ts +655 -0
  156. package/src/main/ErrorHandler.ts +703 -0
  157. package/src/main/HotkeyManager.ts +399 -0
  158. package/src/main/MenuManager.ts +529 -0
  159. package/src/main/PermissionManager.ts +420 -0
  160. package/src/main/SessionController.ts +1465 -0
  161. package/src/main/TrayManager.ts +540 -0
  162. package/src/main/ai/AIPipelineManager.ts +199 -0
  163. package/src/main/ai/ClaudeAnalyzer.ts +339 -0
  164. package/src/main/ai/ImageOptimizer.ts +176 -0
  165. package/src/main/ai/StructuredMarkdownBuilder.ts +379 -0
  166. package/src/main/ai/index.ts +16 -0
  167. package/src/main/ai/types.ts +258 -0
  168. package/src/main/analysis/ClarificationGenerator.ts +385 -0
  169. package/src/main/analysis/FeedbackAnalyzer.ts +531 -0
  170. package/src/main/analysis/index.ts +19 -0
  171. package/src/main/audio/AudioCapture.ts +978 -0
  172. package/src/main/audio/audioUtils.ts +100 -0
  173. package/src/main/audio/index.ts +20 -0
  174. package/src/main/capture/index.ts +1 -0
  175. package/src/main/index.ts +1693 -0
  176. package/src/main/ipc/captureHandlers.ts +272 -0
  177. package/src/main/ipc/index.ts +45 -0
  178. package/src/main/ipc/outputHandlers.ts +302 -0
  179. package/src/main/ipc/sessionHandlers.ts +56 -0
  180. package/src/main/ipc/settingsHandlers.ts +471 -0
  181. package/src/main/ipc/types.ts +56 -0
  182. package/src/main/ipc/windowHandlers.ts +277 -0
  183. package/src/main/output/ClipboardService.ts +369 -0
  184. package/src/main/output/ExportService.ts +539 -0
  185. package/src/main/output/FileManager.ts +416 -0
  186. package/src/main/output/MarkdownGenerator.ts +791 -0
  187. package/src/main/output/MarkdownPatcher.ts +299 -0
  188. package/src/main/output/index.ts +186 -0
  189. package/src/main/output/sessionAdapter.ts +207 -0
  190. package/src/main/output/templates/html-template.ts +553 -0
  191. package/src/main/pipeline/FrameExtractor.ts +330 -0
  192. package/src/main/pipeline/PostProcessor.ts +399 -0
  193. package/src/main/pipeline/TranscriptAnalyzer.ts +226 -0
  194. package/src/main/pipeline/index.ts +36 -0
  195. package/src/main/platform/WindowsTaskbar.ts +600 -0
  196. package/src/main/platform/index.ts +16 -0
  197. package/src/main/settings/SettingsManager.ts +730 -0
  198. package/src/main/settings/index.ts +19 -0
  199. package/src/main/transcription/ModelDownloadManager.ts +494 -0
  200. package/src/main/transcription/TierManager.ts +219 -0
  201. package/src/main/transcription/TranscriptionRecoveryService.ts +340 -0
  202. package/src/main/transcription/WhisperService.ts +748 -0
  203. package/src/main/transcription/index.ts +56 -0
  204. package/src/main/transcription/types.ts +135 -0
  205. package/src/main/windows/PopoverManager.ts +284 -0
  206. package/src/main/windows/TaskbarIntegration.ts +452 -0
  207. package/src/main/windows/index.ts +23 -0
  208. package/src/preload/index.ts +1047 -0
  209. package/src/renderer/App.tsx +515 -0
  210. package/src/renderer/AppWrapper.tsx +28 -0
  211. package/src/renderer/assets/logo-dark.svg +7 -0
  212. package/src/renderer/assets/logo.svg +7 -0
  213. package/src/renderer/audio/AudioCaptureRenderer.ts +454 -0
  214. package/src/renderer/capture/ScreenRecordingRenderer.ts +492 -0
  215. package/src/renderer/components/AnnotationOverlay.tsx +836 -0
  216. package/src/renderer/components/AudioWaveform.tsx +811 -0
  217. package/src/renderer/components/ClarificationQuestions.tsx +656 -0
  218. package/src/renderer/components/CountdownTimer.tsx +495 -0
  219. package/src/renderer/components/CrashRecoveryDialog.tsx +632 -0
  220. package/src/renderer/components/DonateButton.tsx +127 -0
  221. package/src/renderer/components/ErrorBoundary.tsx +308 -0
  222. package/src/renderer/components/ExportDialog.tsx +872 -0
  223. package/src/renderer/components/HotkeyHint.tsx +261 -0
  224. package/src/renderer/components/KeyboardShortcuts.tsx +787 -0
  225. package/src/renderer/components/ModelDownloadDialog.tsx +844 -0
  226. package/src/renderer/components/Onboarding.tsx +1830 -0
  227. package/src/renderer/components/ProcessingOverlay.tsx +157 -0
  228. package/src/renderer/components/RecordingOverlay.tsx +423 -0
  229. package/src/renderer/components/SessionHistory.tsx +1746 -0
  230. package/src/renderer/components/SessionReview.tsx +1321 -0
  231. package/src/renderer/components/SettingsPanel.tsx +217 -0
  232. package/src/renderer/components/Skeleton.tsx +347 -0
  233. package/src/renderer/components/StatusIndicator.tsx +86 -0
  234. package/src/renderer/components/ThemeProvider.tsx +429 -0
  235. package/src/renderer/components/Tooltip.tsx +370 -0
  236. package/src/renderer/components/TranscriptionPreview.tsx +183 -0
  237. package/src/renderer/components/TranscriptionTierSelector.tsx +640 -0
  238. package/src/renderer/components/UpdateNotification.tsx +377 -0
  239. package/src/renderer/components/WindowSelector.tsx +947 -0
  240. package/src/renderer/components/index.ts +99 -0
  241. package/src/renderer/components/primitives/ApiKeyInput.tsx +98 -0
  242. package/src/renderer/components/primitives/ColorPicker.tsx +65 -0
  243. package/src/renderer/components/primitives/DangerButton.tsx +45 -0
  244. package/src/renderer/components/primitives/DirectoryPicker.tsx +41 -0
  245. package/src/renderer/components/primitives/Dropdown.tsx +34 -0
  246. package/src/renderer/components/primitives/KeyRecorder.tsx +117 -0
  247. package/src/renderer/components/primitives/SettingsSection.tsx +32 -0
  248. package/src/renderer/components/primitives/Slider.tsx +43 -0
  249. package/src/renderer/components/primitives/Toggle.tsx +36 -0
  250. package/src/renderer/components/primitives/index.ts +10 -0
  251. package/src/renderer/components/settings/AdvancedTab.tsx +174 -0
  252. package/src/renderer/components/settings/AppearanceTab.tsx +77 -0
  253. package/src/renderer/components/settings/GeneralTab.tsx +40 -0
  254. package/src/renderer/components/settings/HotkeysTab.tsx +79 -0
  255. package/src/renderer/components/settings/RecordingTab.tsx +84 -0
  256. package/src/renderer/components/settings/index.ts +9 -0
  257. package/src/renderer/components/settings/settingsStyles.ts +673 -0
  258. package/src/renderer/components/settings/tabConfig.tsx +85 -0
  259. package/src/renderer/components/settings/useSettingsPanel.ts +447 -0
  260. package/src/renderer/contexts/ProcessingContext.tsx +227 -0
  261. package/src/renderer/contexts/RecordingContext.tsx +683 -0
  262. package/src/renderer/contexts/UIContext.tsx +326 -0
  263. package/src/renderer/contexts/index.ts +24 -0
  264. package/src/renderer/donateMessages.ts +69 -0
  265. package/src/renderer/hooks/index.ts +75 -0
  266. package/src/renderer/hooks/useAnimation.tsx +544 -0
  267. package/src/renderer/hooks/useTheme.ts +313 -0
  268. package/src/renderer/index.html +26 -0
  269. package/src/renderer/main.tsx +52 -0
  270. package/src/renderer/styles/animations.css +1093 -0
  271. package/src/renderer/styles/app-shell.css +662 -0
  272. package/src/renderer/styles/globals.css +515 -0
  273. package/src/renderer/styles/theme.ts +578 -0
  274. package/src/renderer/types/electron.d.ts +385 -0
  275. package/src/shared/hotkeys.ts +283 -0
  276. package/src/shared/types.ts +809 -0
  277. package/tests/clipboard.test.ts +228 -0
  278. package/tests/e2e/criticalPaths.test.ts +594 -0
  279. package/tests/feedbackAnalyzer.test.ts +303 -0
  280. package/tests/integration/sessionFlow.test.ts +583 -0
  281. package/tests/markdownGenerator.test.ts +418 -0
  282. package/tests/output.test.ts +96 -0
  283. package/tests/setup.ts +486 -0
  284. package/tests/unit/appIntegration.test.ts +676 -0
  285. package/tests/unit/appViewState.test.ts +281 -0
  286. package/tests/unit/audioIpcChannels.test.ts +17 -0
  287. package/tests/unit/exportService.test.ts +492 -0
  288. package/tests/unit/hotkeys.test.ts +92 -0
  289. package/tests/unit/navigationPreload.test.ts +94 -0
  290. package/tests/unit/onboardingFlow.test.ts +345 -0
  291. package/tests/unit/permissionManager.test.ts +175 -0
  292. package/tests/unit/permissionManagerExpanded.test.ts +296 -0
  293. package/tests/unit/screenRecordingRenderer.test.ts +368 -0
  294. package/tests/unit/sessionController.test.ts +515 -0
  295. package/tests/unit/tierManager.test.ts +61 -0
  296. package/tests/unit/tierManagerExpanded.test.ts +142 -0
  297. package/tests/unit/transcriptAnalyzer.test.ts +64 -0
  298. package/tsconfig.json +25 -0
  299. package/vitest.config.ts +46 -0
@@ -0,0 +1,1746 @@
1
+ /**
2
+ * SessionHistory - Professional Data Management Experience
3
+ *
4
+ * A comprehensive session browser for viewing, searching, and managing past feedback sessions.
5
+ *
6
+ * Features:
7
+ * - List all sessions with thumbnails and metadata
8
+ * - Search by content (transcription text, project name)
9
+ * - Sort/Filter by date, name, item count
10
+ * - Quick preview on hover
11
+ * - Actions: Open, delete, export, copy, open folder
12
+ * - Bulk actions: Select multiple, delete/export batch
13
+ * - Virtual scrolling for large lists
14
+ * - Full keyboard navigation
15
+ * - Context menu support
16
+ */
17
+
18
+ import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
19
+ import { Skeleton, SkeletonText } from './Skeleton';
20
+ import { useTheme } from '../hooks/useTheme';
21
+
22
+ // ============================================================================
23
+ // Types
24
+ // ============================================================================
25
+
26
+ /**
27
+ * Re-export SessionMetadata for external usage
28
+ * The actual interface is defined in electron.d.ts as SessionHistoryItem
29
+ */
30
+ export interface SessionMetadata {
31
+ id: string;
32
+ startTime: number;
33
+ endTime: number;
34
+ itemCount: number;
35
+ screenshotCount: number;
36
+ sourceName: string;
37
+ firstThumbnail?: string;
38
+ folder: string;
39
+ transcriptionPreview?: string;
40
+ }
41
+
42
+ interface SessionHistoryProps {
43
+ isOpen: boolean;
44
+ onClose: () => void;
45
+ onOpenSession: (session: SessionMetadata) => void;
46
+ }
47
+
48
+ type SortOption = 'date' | 'name' | 'items' | 'duration';
49
+ type SortDirection = 'asc' | 'desc';
50
+
51
+ interface ContextMenuState {
52
+ visible: boolean;
53
+ x: number;
54
+ y: number;
55
+ sessionId: string | null;
56
+ }
57
+
58
+ // ============================================================================
59
+ // Helper Functions
60
+ // ============================================================================
61
+
62
+ function formatDuration(ms: number): string {
63
+ const totalSeconds = Math.floor(ms / 1000);
64
+ const mins = Math.floor(totalSeconds / 60);
65
+ const secs = totalSeconds % 60;
66
+ if (mins === 0) {
67
+ return `${secs}s`;
68
+ }
69
+ return `${mins}:${secs.toString().padStart(2, '0')}`;
70
+ }
71
+
72
+ function formatRelativeDate(timestamp: number): string {
73
+ const date = new Date(timestamp);
74
+ const now = new Date();
75
+ const diffMs = now.getTime() - date.getTime();
76
+ const diffMins = Math.floor(diffMs / (1000 * 60));
77
+ const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
78
+ const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
79
+
80
+ if (diffMins < 1) return 'Just now';
81
+ if (diffMins < 60) return `${diffMins}m ago`;
82
+ if (diffHours < 24) return `${diffHours}h ago`;
83
+ if (diffDays < 7) return `${diffDays}d ago`;
84
+ return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
85
+ }
86
+
87
+ // ============================================================================
88
+ // Sub-Components
89
+ // ============================================================================
90
+
91
+ /**
92
+ * Session Card - Individual session item
93
+ */
94
+ interface SessionCardProps {
95
+ session: SessionMetadata;
96
+ isSelected: boolean;
97
+ isFocused: boolean;
98
+ onSelect: (shift: boolean, ctrl: boolean) => void;
99
+ onOpen: () => void;
100
+ onDelete: () => void;
101
+ onExport: () => void;
102
+ onOpenFolder: () => void;
103
+ onContextMenu: (e: React.MouseEvent) => void;
104
+ }
105
+
106
+ const SessionCard: React.FC<SessionCardProps> = ({
107
+ session,
108
+ isSelected,
109
+ isFocused,
110
+ onSelect,
111
+ onOpen,
112
+ onDelete,
113
+ onExport,
114
+ onOpenFolder,
115
+ onContextMenu,
116
+ }) => {
117
+ const [isHovered, setIsHovered] = useState(false);
118
+ const { colors } = useTheme();
119
+ const duration = session.endTime - session.startTime;
120
+
121
+ const handleClick = useCallback(
122
+ (e: React.MouseEvent) => {
123
+ e.stopPropagation();
124
+ onSelect(e.shiftKey, e.metaKey || e.ctrlKey);
125
+ },
126
+ [onSelect]
127
+ );
128
+
129
+ const handleDoubleClick = useCallback(
130
+ (e: React.MouseEvent) => {
131
+ e.stopPropagation();
132
+ onOpen();
133
+ },
134
+ [onOpen]
135
+ );
136
+
137
+ const handleKeyDown = useCallback(
138
+ (e: React.KeyboardEvent) => {
139
+ if (e.key === 'Enter') {
140
+ e.preventDefault();
141
+ onOpen();
142
+ } else if (e.key === 'Delete' || e.key === 'Backspace') {
143
+ e.preventDefault();
144
+ onDelete();
145
+ }
146
+ },
147
+ [onOpen, onDelete]
148
+ );
149
+
150
+ return (
151
+ <div
152
+ role="row"
153
+ tabIndex={0}
154
+ onClick={handleClick}
155
+ onDoubleClick={handleDoubleClick}
156
+ onContextMenu={onContextMenu}
157
+ onKeyDown={handleKeyDown}
158
+ onMouseEnter={() => setIsHovered(true)}
159
+ onMouseLeave={() => setIsHovered(false)}
160
+ style={{
161
+ ...styles.sessionCard,
162
+ backgroundColor: isSelected
163
+ ? colors.accent.subtle
164
+ : isHovered
165
+ ? colors.bg.subtle
166
+ : colors.surface.inset,
167
+ borderColor: isSelected
168
+ ? colors.accent.muted
169
+ : isFocused
170
+ ? colors.border.focus
171
+ : colors.border.subtle,
172
+ boxShadow: isSelected ? `0 4px 12px -2px ${colors.accent.subtle}` : 'none',
173
+ }}
174
+ >
175
+ {/* Checkbox */}
176
+ <div
177
+ style={{
178
+ ...styles.checkbox,
179
+ backgroundColor: isSelected ? colors.accent.default : 'transparent',
180
+ borderColor: isSelected ? colors.accent.default : colors.text.tertiary,
181
+ }}
182
+ >
183
+ {isSelected && (
184
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="none">
185
+ <path
186
+ d="M2.5 6l2.5 2.5 4.5-4.5"
187
+ stroke={colors.text.inverse}
188
+ strokeWidth="2"
189
+ strokeLinecap="round"
190
+ strokeLinejoin="round"
191
+ />
192
+ </svg>
193
+ )}
194
+ </div>
195
+
196
+ {/* Thumbnail */}
197
+ <div style={styles.thumbnail}>
198
+ {session.firstThumbnail ? (
199
+ <img
200
+ src={`file://${session.firstThumbnail}`}
201
+ alt="Session thumbnail"
202
+ style={styles.thumbnailImage}
203
+ onError={(e) => {
204
+ (e.target as HTMLImageElement).style.display = 'none';
205
+ }}
206
+ />
207
+ ) : (
208
+ <div style={styles.thumbnailPlaceholder}>
209
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" style={{ color: 'var(--text-tertiary)' }}>
210
+ <rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
211
+ <circle cx="8.5" cy="8.5" r="1.5" />
212
+ <polyline points="21 15 16 10 5 21" />
213
+ </svg>
214
+ </div>
215
+ )}
216
+ </div>
217
+
218
+ {/* Content */}
219
+ <div style={styles.sessionContent}>
220
+ <div style={styles.sessionHeader}>
221
+ <span style={styles.sessionName}>{session.sourceName || 'Untitled Session'}</span>
222
+ <span style={styles.sessionDate}>{formatRelativeDate(session.startTime)}</span>
223
+ </div>
224
+ <div style={styles.sessionMeta}>
225
+ <span style={styles.metaItem}>
226
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
227
+ <path d="M12 2v10l4 2" />
228
+ <circle cx="12" cy="12" r="10" />
229
+ </svg>
230
+ {formatDuration(duration)}
231
+ </span>
232
+ <span style={styles.metaItem}>
233
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
234
+ <rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
235
+ <circle cx="8.5" cy="8.5" r="1.5" />
236
+ <polyline points="21 15 16 10 5 21" />
237
+ </svg>
238
+ {session.screenshotCount}
239
+ </span>
240
+ <span style={styles.metaItem}>
241
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
242
+ <path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
243
+ <polyline points="14 2 14 8 20 8" />
244
+ <line x1="16" y1="13" x2="8" y2="13" />
245
+ <line x1="16" y1="17" x2="8" y2="17" />
246
+ </svg>
247
+ {session.itemCount} items
248
+ </span>
249
+ </div>
250
+ {session.transcriptionPreview && (
251
+ <p style={styles.transcriptionPreview}>
252
+ {session.transcriptionPreview.slice(0, 80)}
253
+ {session.transcriptionPreview.length > 80 ? '...' : ''}
254
+ </p>
255
+ )}
256
+ </div>
257
+
258
+ {/* Actions (on hover) */}
259
+ {isHovered && (
260
+ <div style={styles.actionButtons}>
261
+ <button
262
+ style={styles.actionButton}
263
+ onClick={(e) => {
264
+ e.stopPropagation();
265
+ onOpen();
266
+ }}
267
+ title="Open Session"
268
+ >
269
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
270
+ <path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6" />
271
+ <polyline points="15 3 21 3 21 9" />
272
+ <line x1="10" y1="14" x2="21" y2="3" />
273
+ </svg>
274
+ </button>
275
+ <button
276
+ style={styles.actionButton}
277
+ onClick={(e) => {
278
+ e.stopPropagation();
279
+ onOpenFolder();
280
+ }}
281
+ title="Open Folder"
282
+ >
283
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
284
+ <path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z" />
285
+ </svg>
286
+ </button>
287
+ <button
288
+ style={styles.actionButton}
289
+ onClick={(e) => {
290
+ e.stopPropagation();
291
+ onExport();
292
+ }}
293
+ title="Export"
294
+ >
295
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
296
+ <path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
297
+ <polyline points="7 10 12 15 17 10" />
298
+ <line x1="12" y1="15" x2="12" y2="3" />
299
+ </svg>
300
+ </button>
301
+ <button
302
+ style={{ ...styles.actionButton, color: colors.status.error }}
303
+ onClick={(e) => {
304
+ e.stopPropagation();
305
+ onDelete();
306
+ }}
307
+ title="Delete"
308
+ >
309
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
310
+ <path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2" />
311
+ </svg>
312
+ </button>
313
+ </div>
314
+ )}
315
+ </div>
316
+ );
317
+ };
318
+
319
+ /**
320
+ * Search Input with clear button
321
+ */
322
+ const SearchInput: React.FC<{
323
+ value: string;
324
+ onChange: (value: string) => void;
325
+ placeholder?: string;
326
+ }> = ({ value, onChange, placeholder = 'Search sessions...' }) => {
327
+ const inputRef = useRef<HTMLInputElement>(null);
328
+
329
+ return (
330
+ <div style={styles.searchContainer}>
331
+ <svg
332
+ width="16"
333
+ height="16"
334
+ viewBox="0 0 24 24"
335
+ fill="none"
336
+ stroke="currentColor"
337
+ strokeWidth="2"
338
+ style={{ ...styles.searchIcon, color: 'var(--text-tertiary)' }}
339
+ >
340
+ <circle cx="11" cy="11" r="8" />
341
+ <path d="M21 21l-4.35-4.35" />
342
+ </svg>
343
+ <input
344
+ ref={inputRef}
345
+ type="text"
346
+ value={value}
347
+ onChange={(e) => onChange(e.target.value)}
348
+ placeholder={placeholder}
349
+ style={styles.searchInput}
350
+ />
351
+ {value && (
352
+ <button
353
+ style={styles.clearButton}
354
+ onClick={() => {
355
+ onChange('');
356
+ inputRef.current?.focus();
357
+ }}
358
+ >
359
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
360
+ <circle cx="12" cy="12" r="10" />
361
+ <line x1="15" y1="9" x2="9" y2="15" />
362
+ <line x1="9" y1="9" x2="15" y2="15" />
363
+ </svg>
364
+ </button>
365
+ )}
366
+ </div>
367
+ );
368
+ };
369
+
370
+ /**
371
+ * Sort Dropdown
372
+ */
373
+ const SortDropdown: React.FC<{
374
+ sortBy: SortOption;
375
+ direction: SortDirection;
376
+ onSortChange: (sort: SortOption) => void;
377
+ onDirectionToggle: () => void;
378
+ }> = ({ sortBy, direction, onSortChange, onDirectionToggle }) => {
379
+ const [isOpen, setIsOpen] = useState(false);
380
+ const dropdownRef = useRef<HTMLDivElement>(null);
381
+
382
+ const sortOptions: { value: SortOption; label: string }[] = [
383
+ { value: 'date', label: 'Date' },
384
+ { value: 'name', label: 'Name' },
385
+ { value: 'items', label: 'Item Count' },
386
+ { value: 'duration', label: 'Duration' },
387
+ ];
388
+
389
+ const currentLabel = sortOptions.find((opt) => opt.value === sortBy)?.label || 'Date';
390
+
391
+ useEffect(() => {
392
+ const handleClickOutside = (e: MouseEvent) => {
393
+ if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
394
+ setIsOpen(false);
395
+ }
396
+ };
397
+ document.addEventListener('mousedown', handleClickOutside);
398
+ return () => document.removeEventListener('mousedown', handleClickOutside);
399
+ }, []);
400
+
401
+ return (
402
+ <div ref={dropdownRef} style={styles.sortDropdown}>
403
+ <button style={styles.sortButton} onClick={() => setIsOpen(!isOpen)}>
404
+ <span>Sort: {currentLabel}</span>
405
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="none">
406
+ <path
407
+ d="M3 5l3 3 3-3"
408
+ stroke="currentColor"
409
+ strokeWidth="1.5"
410
+ strokeLinecap="round"
411
+ strokeLinejoin="round"
412
+ />
413
+ </svg>
414
+ </button>
415
+ <button
416
+ style={{
417
+ ...styles.directionButton,
418
+ transform: direction === 'asc' ? 'rotate(180deg)' : 'none',
419
+ }}
420
+ onClick={onDirectionToggle}
421
+ title={direction === 'desc' ? 'Newest first' : 'Oldest first'}
422
+ >
423
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
424
+ <polyline points="6 9 12 15 18 9" />
425
+ </svg>
426
+ </button>
427
+ {isOpen && (
428
+ <div style={styles.sortDropdownMenu}>
429
+ {sortOptions.map((option) => (
430
+ <button
431
+ key={option.value}
432
+ style={{
433
+ ...styles.sortDropdownItem,
434
+ backgroundColor: sortBy === option.value ? 'var(--accent-subtle)' : 'transparent',
435
+ }}
436
+ onClick={() => {
437
+ onSortChange(option.value);
438
+ setIsOpen(false);
439
+ }}
440
+ >
441
+ {option.label}
442
+ {sortBy === option.value && (
443
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--accent-default)" strokeWidth="2">
444
+ <polyline points="20 6 9 17 4 12" />
445
+ </svg>
446
+ )}
447
+ </button>
448
+ ))}
449
+ </div>
450
+ )}
451
+ </div>
452
+ );
453
+ };
454
+
455
+ /**
456
+ * Context Menu
457
+ */
458
+ const ContextMenu: React.FC<{
459
+ state: ContextMenuState;
460
+ onClose: () => void;
461
+ onOpen: () => void;
462
+ onOpenFolder: () => void;
463
+ onExport: () => void;
464
+ onDelete: () => void;
465
+ onSelectAll: () => void;
466
+ }> = ({ state, onClose, onOpen, onOpenFolder, onExport, onDelete, onSelectAll }) => {
467
+ const menuRef = useRef<HTMLDivElement>(null);
468
+
469
+ useEffect(() => {
470
+ const handleClickOutside = (e: MouseEvent) => {
471
+ if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
472
+ onClose();
473
+ }
474
+ };
475
+ document.addEventListener('mousedown', handleClickOutside);
476
+ return () => document.removeEventListener('mousedown', handleClickOutside);
477
+ }, [onClose]);
478
+
479
+ useEffect(() => {
480
+ const handleKeyDown = (e: KeyboardEvent) => {
481
+ if (e.key === 'Escape') {
482
+ onClose();
483
+ }
484
+ };
485
+ document.addEventListener('keydown', handleKeyDown);
486
+ return () => document.removeEventListener('keydown', handleKeyDown);
487
+ }, [onClose]);
488
+
489
+ if (!state.visible) return null;
490
+
491
+ return (
492
+ <div
493
+ ref={menuRef}
494
+ style={{
495
+ ...styles.contextMenu,
496
+ top: state.y,
497
+ left: state.x,
498
+ }}
499
+ >
500
+ <button style={styles.contextMenuItem} onClick={onOpen}>
501
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
502
+ <path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6" />
503
+ <polyline points="15 3 21 3 21 9" />
504
+ <line x1="10" y1="14" x2="21" y2="3" />
505
+ </svg>
506
+ Open
507
+ </button>
508
+ <button style={styles.contextMenuItem} onClick={onOpenFolder}>
509
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
510
+ <path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z" />
511
+ </svg>
512
+ Open Folder
513
+ </button>
514
+ <div style={styles.contextMenuDivider} />
515
+ <button style={styles.contextMenuItem} onClick={onExport}>
516
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
517
+ <path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
518
+ <polyline points="7 10 12 15 17 10" />
519
+ <line x1="12" y1="15" x2="12" y2="3" />
520
+ </svg>
521
+ Export
522
+ </button>
523
+ <button style={styles.contextMenuItem} onClick={onSelectAll}>
524
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
525
+ <rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
526
+ <path d="M9 9h6v6H9z" />
527
+ </svg>
528
+ Select All
529
+ </button>
530
+ <div style={styles.contextMenuDivider} />
531
+ <button style={{ ...styles.contextMenuItem, color: 'var(--status-error)' }} onClick={onDelete}>
532
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
533
+ <path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2" />
534
+ </svg>
535
+ Delete
536
+ </button>
537
+ </div>
538
+ );
539
+ };
540
+
541
+ /**
542
+ * Delete Confirmation Dialog
543
+ */
544
+ const DeleteConfirmDialog: React.FC<{
545
+ isOpen: boolean;
546
+ count: number;
547
+ onConfirm: () => void;
548
+ onCancel: () => void;
549
+ }> = ({ isOpen, count, onConfirm, onCancel }) => {
550
+ if (!isOpen) return null;
551
+
552
+ return (
553
+ <div style={styles.dialogOverlay}>
554
+ <div style={styles.dialogBackdrop} onClick={onCancel} />
555
+ <div style={styles.dialog}>
556
+ <div style={styles.dialogIcon}>
557
+ <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="var(--status-error)" strokeWidth="1.5">
558
+ <path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2" />
559
+ <line x1="10" y1="11" x2="10" y2="17" />
560
+ <line x1="14" y1="11" x2="14" y2="17" />
561
+ </svg>
562
+ </div>
563
+ <h3 style={styles.dialogTitle}>
564
+ Delete {count} session{count > 1 ? 's' : ''}?
565
+ </h3>
566
+ <p style={styles.dialogMessage}>
567
+ This will permanently delete the session{count > 1 ? 's' : ''} and all associated screenshots. This action
568
+ cannot be undone.
569
+ </p>
570
+ <div style={styles.dialogButtons}>
571
+ <button style={styles.dialogCancelButton} onClick={onCancel}>
572
+ Cancel
573
+ </button>
574
+ <button style={styles.dialogDeleteButton} onClick={onConfirm}>
575
+ Delete
576
+ </button>
577
+ </div>
578
+ </div>
579
+ </div>
580
+ );
581
+ };
582
+
583
+ /**
584
+ * Empty State
585
+ */
586
+ const EmptyState: React.FC<{ hasSearch: boolean; onClear: () => void }> = ({ hasSearch, onClear }) => (
587
+ <div style={styles.emptyState}>
588
+ <div style={styles.emptyIcon}>
589
+ <svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" style={{ color: 'var(--text-tertiary)' }}>
590
+ {hasSearch ? (
591
+ <>
592
+ <circle cx="11" cy="11" r="8" />
593
+ <path d="M21 21l-4.35-4.35" />
594
+ <path d="M8 11h6" strokeWidth="1.5" />
595
+ </>
596
+ ) : (
597
+ <>
598
+ <path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z" />
599
+ <path d="M12 11v6M9 14h6" strokeWidth="1.5" strokeLinecap="round" />
600
+ </>
601
+ )}
602
+ </svg>
603
+ </div>
604
+ <h3 style={styles.emptyTitle}>{hasSearch ? 'No sessions found' : 'No sessions yet'}</h3>
605
+ <p style={styles.emptyMessage}>
606
+ {hasSearch
607
+ ? "Try adjusting your search terms or clear the filter to see all sessions."
608
+ : 'Start recording feedback to see your sessions here.'}
609
+ </p>
610
+ {hasSearch && (
611
+ <button style={styles.emptyClearButton} onClick={onClear}>
612
+ Clear Search
613
+ </button>
614
+ )}
615
+ </div>
616
+ );
617
+
618
+ /**
619
+ * Loading State
620
+ */
621
+ const LoadingState: React.FC = () => (
622
+ <div style={styles.loadingContainer}>
623
+ {Array.from({ length: 5 }).map((_, index) => (
624
+ <div
625
+ key={index}
626
+ style={{
627
+ ...styles.skeletonCard,
628
+ animationDelay: `${index * 100}ms`,
629
+ }}
630
+ className="ff-list-item-enter"
631
+ >
632
+ <Skeleton width={20} height={20} rounded={4} />
633
+ <Skeleton width={80} height={56} rounded={8} />
634
+ <div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 8 }}>
635
+ <Skeleton width="60%" height={16} />
636
+ <Skeleton width="40%" height={12} />
637
+ <SkeletonText lines={1} animation="shimmer" />
638
+ </div>
639
+ </div>
640
+ ))}
641
+ </div>
642
+ );
643
+
644
+ // ============================================================================
645
+ // Main Component
646
+ // ============================================================================
647
+
648
+ export function SessionHistory({ isOpen, onClose, onOpenSession }: SessionHistoryProps) {
649
+ // State
650
+ const [sessions, setSessions] = useState<SessionMetadata[]>([]);
651
+ const [search, setSearch] = useState('');
652
+ const [sortBy, setSortBy] = useState<SortOption>('date');
653
+ const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
654
+ const [selected, setSelected] = useState<Set<string>>(new Set());
655
+ const [focusedIndex, setFocusedIndex] = useState<number>(-1);
656
+ const [isLoading, setIsLoading] = useState(true);
657
+ const [contextMenu, setContextMenu] = useState<ContextMenuState>({
658
+ visible: false,
659
+ x: 0,
660
+ y: 0,
661
+ sessionId: null,
662
+ });
663
+ const [deleteConfirm, setDeleteConfirm] = useState<{
664
+ isOpen: boolean;
665
+ sessionIds: string[];
666
+ }>({ isOpen: false, sessionIds: [] });
667
+
668
+ const containerRef = useRef<HTMLDivElement>(null);
669
+ const listRef = useRef<HTMLDivElement>(null);
670
+
671
+ // Load sessions on mount/open
672
+ useEffect(() => {
673
+ if (!isOpen) return;
674
+
675
+ async function loadSessions() {
676
+ setIsLoading(true);
677
+ try {
678
+ // Call the IPC API to list sessions
679
+ // The API is typed in electron.d.ts as window.markupr.output.listSessions()
680
+ if (window.markupr?.output?.listSessions) {
681
+ const list = await window.markupr.output.listSessions();
682
+ setSessions(list);
683
+ } else {
684
+ // Fallback for development/testing without full IPC wiring
685
+ console.warn('[SessionHistory] listSessions API not available');
686
+ setSessions([]);
687
+ }
688
+ } catch (error) {
689
+ console.error('Failed to load sessions:', error);
690
+ setSessions([]);
691
+ } finally {
692
+ setIsLoading(false);
693
+ }
694
+ }
695
+
696
+ loadSessions();
697
+ setSelected(new Set());
698
+ setFocusedIndex(-1);
699
+ }, [isOpen]);
700
+
701
+ // Filter and sort sessions
702
+ const filteredSessions = useMemo(() => {
703
+ let result = sessions;
704
+
705
+ // Filter by search
706
+ if (search) {
707
+ const searchLower = search.toLowerCase();
708
+ result = result.filter(
709
+ (s) =>
710
+ s.sourceName.toLowerCase().includes(searchLower) ||
711
+ (s.transcriptionPreview && s.transcriptionPreview.toLowerCase().includes(searchLower))
712
+ );
713
+ }
714
+
715
+ // Sort
716
+ result = [...result].sort((a, b) => {
717
+ let comparison = 0;
718
+
719
+ switch (sortBy) {
720
+ case 'date':
721
+ comparison = b.startTime - a.startTime;
722
+ break;
723
+ case 'name':
724
+ comparison = (a.sourceName || '').localeCompare(b.sourceName || '');
725
+ break;
726
+ case 'items':
727
+ comparison = b.itemCount - a.itemCount;
728
+ break;
729
+ case 'duration':
730
+ comparison = (b.endTime - b.startTime) - (a.endTime - a.startTime);
731
+ break;
732
+ }
733
+
734
+ return sortDirection === 'desc' ? comparison : -comparison;
735
+ });
736
+
737
+ return result;
738
+ }, [sessions, search, sortBy, sortDirection]);
739
+
740
+ // Handlers
741
+ const handleSelectSession = useCallback(
742
+ (sessionId: string, shift: boolean, ctrl: boolean) => {
743
+ setSelected((prev) => {
744
+ const newSet = new Set(prev);
745
+
746
+ if (shift && focusedIndex >= 0) {
747
+ // Range selection
748
+ const currentIndex = filteredSessions.findIndex((s) => s.id === sessionId);
749
+ const start = Math.min(focusedIndex, currentIndex);
750
+ const end = Math.max(focusedIndex, currentIndex);
751
+
752
+ for (let i = start; i <= end; i++) {
753
+ newSet.add(filteredSessions[i].id);
754
+ }
755
+ } else if (ctrl) {
756
+ // Toggle selection
757
+ if (newSet.has(sessionId)) {
758
+ newSet.delete(sessionId);
759
+ } else {
760
+ newSet.add(sessionId);
761
+ }
762
+ } else {
763
+ // Single selection
764
+ newSet.clear();
765
+ newSet.add(sessionId);
766
+ }
767
+
768
+ return newSet;
769
+ });
770
+
771
+ // Update focused index
772
+ const index = filteredSessions.findIndex((s) => s.id === sessionId);
773
+ if (index >= 0) {
774
+ setFocusedIndex(index);
775
+ }
776
+ },
777
+ [filteredSessions, focusedIndex]
778
+ );
779
+
780
+ const handleOpenSession = useCallback(
781
+ (session: SessionMetadata) => {
782
+ onOpenSession(session);
783
+ onClose();
784
+ },
785
+ [onOpenSession, onClose]
786
+ );
787
+
788
+ const handleDeleteSessions = useCallback((sessionIds: string[]) => {
789
+ setDeleteConfirm({ isOpen: true, sessionIds });
790
+ }, []);
791
+
792
+ const handleConfirmDelete = useCallback(async () => {
793
+ const { sessionIds } = deleteConfirm;
794
+
795
+ try {
796
+ // Call the IPC API to delete sessions
797
+ if (window.markupr?.output?.deleteSessions) {
798
+ const result = await window.markupr.output.deleteSessions(sessionIds);
799
+ if (result.success) {
800
+ // Remove successfully deleted sessions from state
801
+ setSessions((prev) => prev.filter((s) => !result.deleted.includes(s.id)));
802
+ setSelected((prev) => {
803
+ const newSet = new Set(prev);
804
+ result.deleted.forEach((id) => newSet.delete(id));
805
+ return newSet;
806
+ });
807
+ }
808
+ if (result.failed.length > 0) {
809
+ console.warn('Some sessions failed to delete:', result.failed);
810
+ }
811
+ } else {
812
+ // Fallback: just remove from local state
813
+ setSessions((prev) => prev.filter((s) => !sessionIds.includes(s.id)));
814
+ setSelected((prev) => {
815
+ const newSet = new Set(prev);
816
+ sessionIds.forEach((id) => newSet.delete(id));
817
+ return newSet;
818
+ });
819
+ }
820
+ } catch (error) {
821
+ console.error('Failed to delete sessions:', error);
822
+ }
823
+
824
+ setDeleteConfirm({ isOpen: false, sessionIds: [] });
825
+ }, [deleteConfirm]);
826
+
827
+ const handleExportSessions = useCallback(async (sessionIds: string[]) => {
828
+ try {
829
+ // Call the IPC API to export sessions
830
+ if (window.markupr?.output?.exportSessions) {
831
+ const result = await window.markupr.output.exportSessions(sessionIds);
832
+ if (result.success && result.path) {
833
+ console.log('Sessions exported to:', result.path);
834
+ // Optionally open the folder containing the export
835
+ await window.markupr.output.openFolder(result.path);
836
+ } else if (result.error) {
837
+ console.error('Export failed:', result.error);
838
+ }
839
+ } else {
840
+ console.warn('[SessionHistory] exportSessions API not available');
841
+ }
842
+ } catch (error) {
843
+ console.error('Failed to export sessions:', error);
844
+ }
845
+ }, []);
846
+
847
+ const handleOpenFolder = useCallback(async (session: SessionMetadata) => {
848
+ try {
849
+ await window.markupr.output.openFolder(session.folder);
850
+ } catch (error) {
851
+ console.error('Failed to open folder:', error);
852
+ }
853
+ }, []);
854
+
855
+ const handleSelectAll = useCallback(() => {
856
+ setSelected(new Set(filteredSessions.map((s) => s.id)));
857
+ }, [filteredSessions]);
858
+
859
+ const handleDeselectAll = useCallback(() => {
860
+ setSelected(new Set());
861
+ }, []);
862
+
863
+ const handleContextMenu = useCallback((e: React.MouseEvent, sessionId: string) => {
864
+ e.preventDefault();
865
+ setContextMenu({
866
+ visible: true,
867
+ x: e.clientX,
868
+ y: e.clientY,
869
+ sessionId,
870
+ });
871
+ }, []);
872
+
873
+ // Keyboard navigation
874
+ useEffect(() => {
875
+ if (!isOpen) return;
876
+
877
+ const handleKeyDown = (e: KeyboardEvent) => {
878
+ // Close on Escape
879
+ if (e.key === 'Escape') {
880
+ if (contextMenu.visible) {
881
+ setContextMenu((prev) => ({ ...prev, visible: false }));
882
+ } else if (deleteConfirm.isOpen) {
883
+ setDeleteConfirm({ isOpen: false, sessionIds: [] });
884
+ } else {
885
+ onClose();
886
+ }
887
+ return;
888
+ }
889
+
890
+ // Navigation
891
+ if (e.key === 'ArrowDown') {
892
+ e.preventDefault();
893
+ setFocusedIndex((prev) => Math.min(prev + 1, filteredSessions.length - 1));
894
+ } else if (e.key === 'ArrowUp') {
895
+ e.preventDefault();
896
+ setFocusedIndex((prev) => Math.max(prev - 1, 0));
897
+ } else if (e.key === ' ' && focusedIndex >= 0) {
898
+ e.preventDefault();
899
+ const session = filteredSessions[focusedIndex];
900
+ handleSelectSession(session.id, e.shiftKey, true);
901
+ } else if (e.key === 'Enter' && focusedIndex >= 0) {
902
+ e.preventDefault();
903
+ const session = filteredSessions[focusedIndex];
904
+ handleOpenSession(session);
905
+ } else if (e.key === 'Delete' || e.key === 'Backspace') {
906
+ if (selected.size > 0 && !e.metaKey && !e.ctrlKey) {
907
+ e.preventDefault();
908
+ handleDeleteSessions(Array.from(selected));
909
+ }
910
+ } else if ((e.metaKey || e.ctrlKey) && e.key === 'a') {
911
+ e.preventDefault();
912
+ handleSelectAll();
913
+ }
914
+ };
915
+
916
+ window.addEventListener('keydown', handleKeyDown);
917
+ return () => window.removeEventListener('keydown', handleKeyDown);
918
+ }, [
919
+ isOpen,
920
+ filteredSessions,
921
+ focusedIndex,
922
+ selected,
923
+ contextMenu.visible,
924
+ deleteConfirm.isOpen,
925
+ handleSelectSession,
926
+ handleOpenSession,
927
+ handleDeleteSessions,
928
+ handleSelectAll,
929
+ onClose,
930
+ ]);
931
+
932
+ // Scroll focused item into view
933
+ useEffect(() => {
934
+ if (focusedIndex >= 0 && listRef.current) {
935
+ const item = listRef.current.children[focusedIndex] as HTMLElement;
936
+ if (item) {
937
+ item.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
938
+ }
939
+ }
940
+ }, [focusedIndex]);
941
+
942
+ if (!isOpen) return null;
943
+
944
+ return (
945
+ <div style={styles.overlay}>
946
+ {/* dialogEnter keyframe provided by animations.css; scrollbar styles below */}
947
+ <style>
948
+ {`
949
+ .markupr-history-scrollbar::-webkit-scrollbar {
950
+ width: 8px;
951
+ }
952
+
953
+ .markupr-history-scrollbar::-webkit-scrollbar-track {
954
+ background: var(--surface-inset);
955
+ border-radius: 4px;
956
+ }
957
+
958
+ .markupr-history-scrollbar::-webkit-scrollbar-thumb {
959
+ background: var(--border-strong);
960
+ border-radius: 4px;
961
+ }
962
+
963
+ .markupr-history-scrollbar::-webkit-scrollbar-thumb:hover {
964
+ background: var(--text-tertiary);
965
+ }
966
+ `}
967
+ </style>
968
+
969
+ <div style={styles.backdrop} onClick={onClose} />
970
+
971
+ <div ref={containerRef} style={styles.panel} className="ff-dialog-enter">
972
+ {/* Header */}
973
+ <div style={styles.header}>
974
+ <div style={styles.headerLeft}>
975
+ <h2 style={styles.headerTitle}>Session History</h2>
976
+ {!isLoading && (
977
+ <span style={styles.sessionCount}>
978
+ {filteredSessions.length} session{filteredSessions.length !== 1 ? 's' : ''}
979
+ </span>
980
+ )}
981
+ </div>
982
+ <SearchInput value={search} onChange={setSearch} />
983
+ <button style={styles.closeButton} onClick={onClose} aria-label="Close">
984
+ <svg width="20" height="20" viewBox="0 0 20 20" fill="none">
985
+ <path d="M5 5l10 10M15 5l-10 10" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
986
+ </svg>
987
+ </button>
988
+ </div>
989
+
990
+ {/* Toolbar */}
991
+ <div style={styles.toolbar}>
992
+ <div style={styles.toolbarLeft}>
993
+ <SortDropdown
994
+ sortBy={sortBy}
995
+ direction={sortDirection}
996
+ onSortChange={setSortBy}
997
+ onDirectionToggle={() => setSortDirection((d) => (d === 'desc' ? 'asc' : 'desc'))}
998
+ />
999
+ {selected.size > 0 && (
1000
+ <span style={styles.selectedCount}>
1001
+ {selected.size} selected
1002
+ <button style={styles.deselectButton} onClick={handleDeselectAll}>
1003
+ Clear
1004
+ </button>
1005
+ </span>
1006
+ )}
1007
+ </div>
1008
+ <div style={styles.toolbarRight}>
1009
+ {selected.size > 0 && (
1010
+ <>
1011
+ <button
1012
+ style={styles.bulkButton}
1013
+ onClick={() => handleExportSessions(Array.from(selected))}
1014
+ title="Export selected"
1015
+ >
1016
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
1017
+ <path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
1018
+ <polyline points="7 10 12 15 17 10" />
1019
+ <line x1="12" y1="15" x2="12" y2="3" />
1020
+ </svg>
1021
+ Export
1022
+ </button>
1023
+ <button
1024
+ style={{ ...styles.bulkButton, ...styles.deleteButton }}
1025
+ onClick={() => handleDeleteSessions(Array.from(selected))}
1026
+ title="Delete selected"
1027
+ >
1028
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
1029
+ <path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2" />
1030
+ </svg>
1031
+ Delete
1032
+ </button>
1033
+ </>
1034
+ )}
1035
+ </div>
1036
+ </div>
1037
+
1038
+ {/* Content */}
1039
+ <div ref={listRef} style={styles.content} className="markupr-history-scrollbar" role="grid">
1040
+ {isLoading ? (
1041
+ <LoadingState />
1042
+ ) : filteredSessions.length === 0 ? (
1043
+ <EmptyState hasSearch={!!search} onClear={() => setSearch('')} />
1044
+ ) : (
1045
+ filteredSessions.map((session, index) => (
1046
+ <SessionCard
1047
+ key={session.id}
1048
+ session={session}
1049
+ isSelected={selected.has(session.id)}
1050
+ isFocused={focusedIndex === index}
1051
+ onSelect={(shift, ctrl) => handleSelectSession(session.id, shift, ctrl)}
1052
+ onOpen={() => handleOpenSession(session)}
1053
+ onDelete={() => handleDeleteSessions([session.id])}
1054
+ onExport={() => handleExportSessions([session.id])}
1055
+ onOpenFolder={() => handleOpenFolder(session)}
1056
+ onContextMenu={(e) => handleContextMenu(e, session.id)}
1057
+ />
1058
+ ))
1059
+ )}
1060
+ </div>
1061
+
1062
+ {/* Footer */}
1063
+ <div style={styles.footer}>
1064
+ <div style={styles.footerLeft}>
1065
+ <span style={styles.footerHint}>
1066
+ <kbd style={styles.kbd}>Arrow</kbd> Navigate
1067
+ <kbd style={styles.kbd}>Space</kbd> Select
1068
+ <kbd style={styles.kbd}>Enter</kbd> Open
1069
+ <kbd style={styles.kbd}>Del</kbd> Delete
1070
+ </span>
1071
+ </div>
1072
+ <button style={styles.closeFooterButton} onClick={onClose}>
1073
+ Close
1074
+ </button>
1075
+ </div>
1076
+ </div>
1077
+
1078
+ {/* Context Menu */}
1079
+ <ContextMenu
1080
+ state={contextMenu}
1081
+ onClose={() => setContextMenu((prev) => ({ ...prev, visible: false }))}
1082
+ onOpen={() => {
1083
+ const session = filteredSessions.find((s) => s.id === contextMenu.sessionId);
1084
+ if (session) handleOpenSession(session);
1085
+ setContextMenu((prev) => ({ ...prev, visible: false }));
1086
+ }}
1087
+ onOpenFolder={() => {
1088
+ const session = filteredSessions.find((s) => s.id === contextMenu.sessionId);
1089
+ if (session) handleOpenFolder(session);
1090
+ setContextMenu((prev) => ({ ...prev, visible: false }));
1091
+ }}
1092
+ onExport={() => {
1093
+ if (contextMenu.sessionId) handleExportSessions([contextMenu.sessionId]);
1094
+ setContextMenu((prev) => ({ ...prev, visible: false }));
1095
+ }}
1096
+ onDelete={() => {
1097
+ if (contextMenu.sessionId) handleDeleteSessions([contextMenu.sessionId]);
1098
+ setContextMenu((prev) => ({ ...prev, visible: false }));
1099
+ }}
1100
+ onSelectAll={() => {
1101
+ handleSelectAll();
1102
+ setContextMenu((prev) => ({ ...prev, visible: false }));
1103
+ }}
1104
+ />
1105
+
1106
+ {/* Delete Confirmation Dialog */}
1107
+ <DeleteConfirmDialog
1108
+ isOpen={deleteConfirm.isOpen}
1109
+ count={deleteConfirm.sessionIds.length}
1110
+ onConfirm={handleConfirmDelete}
1111
+ onCancel={() => setDeleteConfirm({ isOpen: false, sessionIds: [] })}
1112
+ />
1113
+ </div>
1114
+ );
1115
+ }
1116
+
1117
+ // ============================================================================
1118
+ // Styles
1119
+ // ============================================================================
1120
+
1121
+ type ExtendedCSSProperties = React.CSSProperties & {
1122
+ WebkitAppRegion?: 'drag' | 'no-drag';
1123
+ };
1124
+
1125
+ const styles: Record<string, ExtendedCSSProperties> = {
1126
+ // Overlay & Panel
1127
+ overlay: {
1128
+ position: 'fixed',
1129
+ inset: 0,
1130
+ display: 'flex',
1131
+ alignItems: 'center',
1132
+ justifyContent: 'center',
1133
+ zIndex: 100,
1134
+ padding: 24,
1135
+ },
1136
+
1137
+ backdrop: {
1138
+ position: 'absolute',
1139
+ inset: 0,
1140
+ backgroundColor: 'var(--bg-overlay)',
1141
+ backdropFilter: 'blur(4px)',
1142
+ },
1143
+
1144
+ panel: {
1145
+ position: 'relative',
1146
+ width: '100%',
1147
+ maxWidth: 900,
1148
+ maxHeight: '90vh',
1149
+ backgroundColor: 'var(--surface-glass)',
1150
+ borderRadius: 16,
1151
+ boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.5), 0 0 0 1px var(--border-default)',
1152
+ display: 'flex',
1153
+ flexDirection: 'column',
1154
+ overflow: 'hidden',
1155
+ },
1156
+
1157
+ // Header
1158
+ header: {
1159
+ display: 'flex',
1160
+ alignItems: 'center',
1161
+ gap: 16,
1162
+ padding: '20px 24px',
1163
+ borderBottom: '1px solid var(--border-default)',
1164
+ WebkitAppRegion: 'drag',
1165
+ },
1166
+
1167
+ headerLeft: {
1168
+ display: 'flex',
1169
+ alignItems: 'baseline',
1170
+ gap: 12,
1171
+ WebkitAppRegion: 'no-drag',
1172
+ },
1173
+
1174
+ headerTitle: {
1175
+ fontSize: 18,
1176
+ fontWeight: 600,
1177
+ color: 'var(--text-primary)',
1178
+ margin: 0,
1179
+ },
1180
+
1181
+ sessionCount: {
1182
+ fontSize: 13,
1183
+ color: 'var(--text-tertiary)',
1184
+ fontWeight: 400,
1185
+ },
1186
+
1187
+ closeButton: {
1188
+ width: 32,
1189
+ height: 32,
1190
+ display: 'flex',
1191
+ alignItems: 'center',
1192
+ justifyContent: 'center',
1193
+ backgroundColor: 'transparent',
1194
+ border: 'none',
1195
+ borderRadius: 8,
1196
+ color: 'var(--text-secondary)',
1197
+ cursor: 'pointer',
1198
+ transition: 'all 0.2s ease',
1199
+ WebkitAppRegion: 'no-drag',
1200
+ marginLeft: 8,
1201
+ },
1202
+
1203
+ // Search
1204
+ searchContainer: {
1205
+ flex: 1,
1206
+ maxWidth: 280,
1207
+ position: 'relative',
1208
+ display: 'flex',
1209
+ alignItems: 'center',
1210
+ WebkitAppRegion: 'no-drag',
1211
+ },
1212
+
1213
+ searchIcon: {
1214
+ position: 'absolute',
1215
+ left: 12,
1216
+ pointerEvents: 'none',
1217
+ },
1218
+
1219
+ searchInput: {
1220
+ width: '100%',
1221
+ padding: '8px 36px 8px 38px',
1222
+ backgroundColor: 'var(--surface-inset)',
1223
+ border: '1px solid var(--border-strong)',
1224
+ borderRadius: 8,
1225
+ color: 'var(--text-primary)',
1226
+ fontSize: 13,
1227
+ outline: 'none',
1228
+ transition: 'border-color 0.2s ease',
1229
+ },
1230
+
1231
+ clearButton: {
1232
+ position: 'absolute',
1233
+ right: 8,
1234
+ width: 24,
1235
+ height: 24,
1236
+ display: 'flex',
1237
+ alignItems: 'center',
1238
+ justifyContent: 'center',
1239
+ backgroundColor: 'transparent',
1240
+ border: 'none',
1241
+ borderRadius: 4,
1242
+ color: 'var(--text-tertiary)',
1243
+ cursor: 'pointer',
1244
+ },
1245
+
1246
+ // Toolbar
1247
+ toolbar: {
1248
+ display: 'flex',
1249
+ alignItems: 'center',
1250
+ justifyContent: 'space-between',
1251
+ padding: '12px 24px',
1252
+ borderBottom: '1px solid var(--border-subtle)',
1253
+ backgroundColor: 'var(--surface-glass)',
1254
+ },
1255
+
1256
+ toolbarLeft: {
1257
+ display: 'flex',
1258
+ alignItems: 'center',
1259
+ gap: 16,
1260
+ },
1261
+
1262
+ toolbarRight: {
1263
+ display: 'flex',
1264
+ alignItems: 'center',
1265
+ gap: 8,
1266
+ },
1267
+
1268
+ selectedCount: {
1269
+ display: 'flex',
1270
+ alignItems: 'center',
1271
+ gap: 8,
1272
+ fontSize: 13,
1273
+ color: 'var(--text-link)',
1274
+ fontWeight: 500,
1275
+ },
1276
+
1277
+ deselectButton: {
1278
+ padding: '2px 8px',
1279
+ backgroundColor: 'transparent',
1280
+ border: '1px solid var(--accent-subtle)',
1281
+ borderRadius: 4,
1282
+ color: 'var(--text-link)',
1283
+ fontSize: 11,
1284
+ cursor: 'pointer',
1285
+ },
1286
+
1287
+ // Sort Dropdown
1288
+ sortDropdown: {
1289
+ position: 'relative',
1290
+ display: 'flex',
1291
+ alignItems: 'center',
1292
+ gap: 4,
1293
+ },
1294
+
1295
+ sortButton: {
1296
+ display: 'flex',
1297
+ alignItems: 'center',
1298
+ gap: 6,
1299
+ padding: '6px 10px',
1300
+ backgroundColor: 'var(--surface-inset)',
1301
+ border: '1px solid var(--border-strong)',
1302
+ borderRadius: 6,
1303
+ color: 'var(--text-secondary)',
1304
+ fontSize: 12,
1305
+ fontWeight: 500,
1306
+ cursor: 'pointer',
1307
+ transition: 'all 0.15s ease',
1308
+ },
1309
+
1310
+ directionButton: {
1311
+ width: 28,
1312
+ height: 28,
1313
+ display: 'flex',
1314
+ alignItems: 'center',
1315
+ justifyContent: 'center',
1316
+ backgroundColor: 'var(--surface-inset)',
1317
+ border: '1px solid var(--border-strong)',
1318
+ borderRadius: 6,
1319
+ color: 'var(--text-secondary)',
1320
+ cursor: 'pointer',
1321
+ transition: 'all 0.15s ease',
1322
+ },
1323
+
1324
+ sortDropdownMenu: {
1325
+ position: 'absolute',
1326
+ top: '100%',
1327
+ left: 0,
1328
+ marginTop: 4,
1329
+ minWidth: 140,
1330
+ backgroundColor: 'var(--bg-elevated)',
1331
+ border: '1px solid var(--border-strong)',
1332
+ borderRadius: 8,
1333
+ padding: 4,
1334
+ zIndex: 50,
1335
+ boxShadow: '0 10px 25px -5px rgba(0, 0, 0, 0.4)',
1336
+ },
1337
+
1338
+ sortDropdownItem: {
1339
+ display: 'flex',
1340
+ alignItems: 'center',
1341
+ justifyContent: 'space-between',
1342
+ width: '100%',
1343
+ padding: '8px 12px',
1344
+ backgroundColor: 'transparent',
1345
+ border: 'none',
1346
+ borderRadius: 4,
1347
+ color: 'var(--text-primary)',
1348
+ fontSize: 13,
1349
+ textAlign: 'left',
1350
+ cursor: 'pointer',
1351
+ transition: 'background-color 0.15s ease',
1352
+ },
1353
+
1354
+ // Bulk Actions
1355
+ bulkButton: {
1356
+ display: 'flex',
1357
+ alignItems: 'center',
1358
+ gap: 6,
1359
+ padding: '6px 12px',
1360
+ backgroundColor: 'var(--surface-inset)',
1361
+ border: '1px solid var(--border-strong)',
1362
+ borderRadius: 6,
1363
+ color: 'var(--text-secondary)',
1364
+ fontSize: 12,
1365
+ fontWeight: 500,
1366
+ cursor: 'pointer',
1367
+ transition: 'all 0.15s ease',
1368
+ },
1369
+
1370
+ deleteButton: {
1371
+ backgroundColor: 'var(--status-error-subtle)',
1372
+ borderColor: 'var(--status-error)',
1373
+ color: 'var(--status-error)',
1374
+ },
1375
+
1376
+ // Content
1377
+ content: {
1378
+ flex: 1,
1379
+ padding: 16,
1380
+ overflowY: 'auto',
1381
+ display: 'flex',
1382
+ flexDirection: 'column',
1383
+ gap: 8,
1384
+ },
1385
+
1386
+ // Session Card
1387
+ sessionCard: {
1388
+ display: 'flex',
1389
+ alignItems: 'center',
1390
+ gap: 12,
1391
+ padding: 12,
1392
+ borderRadius: 10,
1393
+ border: '1px solid',
1394
+ cursor: 'pointer',
1395
+ transition: 'all 0.15s ease',
1396
+ outline: 'none',
1397
+ },
1398
+
1399
+ checkbox: {
1400
+ width: 20,
1401
+ height: 20,
1402
+ borderRadius: 4,
1403
+ border: '2px solid',
1404
+ display: 'flex',
1405
+ alignItems: 'center',
1406
+ justifyContent: 'center',
1407
+ flexShrink: 0,
1408
+ transition: 'all 0.15s ease',
1409
+ },
1410
+
1411
+ thumbnail: {
1412
+ width: 80,
1413
+ height: 56,
1414
+ borderRadius: 6,
1415
+ overflow: 'hidden',
1416
+ backgroundColor: 'var(--surface-inset)',
1417
+ flexShrink: 0,
1418
+ },
1419
+
1420
+ thumbnailImage: {
1421
+ width: '100%',
1422
+ height: '100%',
1423
+ objectFit: 'cover',
1424
+ },
1425
+
1426
+ thumbnailPlaceholder: {
1427
+ width: '100%',
1428
+ height: '100%',
1429
+ display: 'flex',
1430
+ alignItems: 'center',
1431
+ justifyContent: 'center',
1432
+ backgroundColor: 'var(--surface-inset)',
1433
+ },
1434
+
1435
+ sessionContent: {
1436
+ flex: 1,
1437
+ minWidth: 0,
1438
+ display: 'flex',
1439
+ flexDirection: 'column',
1440
+ gap: 4,
1441
+ },
1442
+
1443
+ sessionHeader: {
1444
+ display: 'flex',
1445
+ alignItems: 'center',
1446
+ justifyContent: 'space-between',
1447
+ gap: 8,
1448
+ },
1449
+
1450
+ sessionName: {
1451
+ fontSize: 14,
1452
+ fontWeight: 500,
1453
+ color: 'var(--text-primary)',
1454
+ whiteSpace: 'nowrap',
1455
+ overflow: 'hidden',
1456
+ textOverflow: 'ellipsis',
1457
+ },
1458
+
1459
+ sessionDate: {
1460
+ fontSize: 11,
1461
+ color: 'var(--text-tertiary)',
1462
+ whiteSpace: 'nowrap',
1463
+ flexShrink: 0,
1464
+ },
1465
+
1466
+ sessionMeta: {
1467
+ display: 'flex',
1468
+ alignItems: 'center',
1469
+ gap: 12,
1470
+ },
1471
+
1472
+ metaItem: {
1473
+ display: 'flex',
1474
+ alignItems: 'center',
1475
+ gap: 4,
1476
+ fontSize: 12,
1477
+ color: 'var(--text-secondary)',
1478
+ },
1479
+
1480
+ transcriptionPreview: {
1481
+ margin: 0,
1482
+ fontSize: 12,
1483
+ color: 'var(--text-tertiary)',
1484
+ lineHeight: 1.4,
1485
+ overflow: 'hidden',
1486
+ textOverflow: 'ellipsis',
1487
+ whiteSpace: 'nowrap',
1488
+ },
1489
+
1490
+ actionButtons: {
1491
+ display: 'flex',
1492
+ alignItems: 'center',
1493
+ gap: 4,
1494
+ marginLeft: 8,
1495
+ flexShrink: 0,
1496
+ },
1497
+
1498
+ actionButton: {
1499
+ width: 32,
1500
+ height: 32,
1501
+ display: 'flex',
1502
+ alignItems: 'center',
1503
+ justifyContent: 'center',
1504
+ backgroundColor: 'var(--surface-inset)',
1505
+ border: 'none',
1506
+ borderRadius: 6,
1507
+ color: 'var(--text-secondary)',
1508
+ cursor: 'pointer',
1509
+ transition: 'all 0.15s ease',
1510
+ },
1511
+
1512
+ // Context Menu
1513
+ contextMenu: {
1514
+ position: 'fixed',
1515
+ minWidth: 160,
1516
+ backgroundColor: 'var(--bg-elevated)',
1517
+ border: '1px solid var(--border-strong)',
1518
+ borderRadius: 8,
1519
+ padding: 4,
1520
+ zIndex: 200,
1521
+ boxShadow: '0 10px 25px -5px rgba(0, 0, 0, 0.5)',
1522
+ },
1523
+
1524
+ contextMenuItem: {
1525
+ display: 'flex',
1526
+ alignItems: 'center',
1527
+ gap: 10,
1528
+ width: '100%',
1529
+ padding: '8px 12px',
1530
+ backgroundColor: 'transparent',
1531
+ border: 'none',
1532
+ borderRadius: 4,
1533
+ color: 'var(--text-primary)',
1534
+ fontSize: 13,
1535
+ textAlign: 'left',
1536
+ cursor: 'pointer',
1537
+ transition: 'background-color 0.15s ease',
1538
+ },
1539
+
1540
+ contextMenuDivider: {
1541
+ height: 1,
1542
+ backgroundColor: 'var(--border-subtle)',
1543
+ margin: '4px 0',
1544
+ },
1545
+
1546
+ // Delete Dialog
1547
+ dialogOverlay: {
1548
+ position: 'fixed',
1549
+ inset: 0,
1550
+ display: 'flex',
1551
+ alignItems: 'center',
1552
+ justifyContent: 'center',
1553
+ zIndex: 300,
1554
+ },
1555
+
1556
+ dialogBackdrop: {
1557
+ position: 'absolute',
1558
+ inset: 0,
1559
+ backgroundColor: 'var(--bg-overlay)',
1560
+ },
1561
+
1562
+ dialog: {
1563
+ position: 'relative',
1564
+ width: 320,
1565
+ backgroundColor: 'var(--bg-elevated)',
1566
+ borderRadius: 12,
1567
+ padding: 24,
1568
+ textAlign: 'center',
1569
+ boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.5)',
1570
+ },
1571
+
1572
+ dialogIcon: {
1573
+ width: 56,
1574
+ height: 56,
1575
+ margin: '0 auto 16px',
1576
+ display: 'flex',
1577
+ alignItems: 'center',
1578
+ justifyContent: 'center',
1579
+ backgroundColor: 'var(--status-error-subtle)',
1580
+ borderRadius: '50%',
1581
+ },
1582
+
1583
+ dialogTitle: {
1584
+ fontSize: 16,
1585
+ fontWeight: 600,
1586
+ color: 'var(--text-primary)',
1587
+ margin: '0 0 8px',
1588
+ },
1589
+
1590
+ dialogMessage: {
1591
+ fontSize: 13,
1592
+ color: 'var(--text-secondary)',
1593
+ margin: '0 0 20px',
1594
+ lineHeight: 1.5,
1595
+ },
1596
+
1597
+ dialogButtons: {
1598
+ display: 'flex',
1599
+ gap: 8,
1600
+ },
1601
+
1602
+ dialogCancelButton: {
1603
+ flex: 1,
1604
+ padding: '10px 16px',
1605
+ backgroundColor: 'var(--surface-inset)',
1606
+ border: '1px solid var(--border-strong)',
1607
+ borderRadius: 8,
1608
+ color: 'var(--text-secondary)',
1609
+ fontSize: 13,
1610
+ fontWeight: 500,
1611
+ cursor: 'pointer',
1612
+ transition: 'all 0.15s ease',
1613
+ },
1614
+
1615
+ dialogDeleteButton: {
1616
+ flex: 1,
1617
+ padding: '10px 16px',
1618
+ backgroundColor: 'var(--status-error)',
1619
+ border: 'none',
1620
+ borderRadius: 8,
1621
+ color: 'var(--text-inverse)',
1622
+ fontSize: 13,
1623
+ fontWeight: 500,
1624
+ cursor: 'pointer',
1625
+ transition: 'all 0.15s ease',
1626
+ },
1627
+
1628
+ // Empty State
1629
+ emptyState: {
1630
+ flex: 1,
1631
+ display: 'flex',
1632
+ flexDirection: 'column',
1633
+ alignItems: 'center',
1634
+ justifyContent: 'center',
1635
+ padding: 40,
1636
+ textAlign: 'center',
1637
+ },
1638
+
1639
+ emptyIcon: {
1640
+ width: 80,
1641
+ height: 80,
1642
+ marginBottom: 16,
1643
+ display: 'flex',
1644
+ alignItems: 'center',
1645
+ justifyContent: 'center',
1646
+ backgroundColor: 'var(--surface-inset)',
1647
+ borderRadius: '50%',
1648
+ },
1649
+
1650
+ emptyTitle: {
1651
+ fontSize: 16,
1652
+ fontWeight: 500,
1653
+ color: 'var(--text-secondary)',
1654
+ margin: '0 0 8px',
1655
+ },
1656
+
1657
+ emptyMessage: {
1658
+ fontSize: 13,
1659
+ color: 'var(--text-tertiary)',
1660
+ margin: '0 0 16px',
1661
+ maxWidth: 280,
1662
+ lineHeight: 1.5,
1663
+ },
1664
+
1665
+ emptyClearButton: {
1666
+ padding: '8px 16px',
1667
+ backgroundColor: 'var(--accent-subtle)',
1668
+ border: '1px solid var(--accent-muted)',
1669
+ borderRadius: 6,
1670
+ color: 'var(--text-link)',
1671
+ fontSize: 13,
1672
+ fontWeight: 500,
1673
+ cursor: 'pointer',
1674
+ transition: 'all 0.15s ease',
1675
+ },
1676
+
1677
+ // Loading
1678
+ loadingContainer: {
1679
+ display: 'flex',
1680
+ flexDirection: 'column',
1681
+ gap: 8,
1682
+ },
1683
+
1684
+ skeletonCard: {
1685
+ display: 'flex',
1686
+ alignItems: 'center',
1687
+ gap: 12,
1688
+ padding: 12,
1689
+ backgroundColor: 'var(--surface-inset)',
1690
+ borderRadius: 10,
1691
+ border: '1px solid var(--border-subtle)',
1692
+ },
1693
+
1694
+ // Footer
1695
+ footer: {
1696
+ display: 'flex',
1697
+ alignItems: 'center',
1698
+ justifyContent: 'space-between',
1699
+ padding: '12px 24px',
1700
+ borderTop: '1px solid var(--border-default)',
1701
+ backgroundColor: 'var(--surface-glass)',
1702
+ },
1703
+
1704
+ footerLeft: {
1705
+ display: 'flex',
1706
+ alignItems: 'center',
1707
+ },
1708
+
1709
+ footerHint: {
1710
+ display: 'flex',
1711
+ alignItems: 'center',
1712
+ gap: 12,
1713
+ fontSize: 11,
1714
+ color: 'var(--text-tertiary)',
1715
+ },
1716
+
1717
+ kbd: {
1718
+ display: 'inline-flex',
1719
+ alignItems: 'center',
1720
+ justifyContent: 'center',
1721
+ minWidth: 20,
1722
+ padding: '2px 6px',
1723
+ marginRight: 4,
1724
+ backgroundColor: 'var(--surface-inset)',
1725
+ border: '1px solid var(--border-strong)',
1726
+ borderRadius: 4,
1727
+ fontSize: 10,
1728
+ fontWeight: 500,
1729
+ color: 'var(--text-secondary)',
1730
+ fontFamily: 'ui-monospace, SFMono-Regular, monospace',
1731
+ },
1732
+
1733
+ closeFooterButton: {
1734
+ padding: '8px 20px',
1735
+ backgroundColor: 'var(--surface-inset)',
1736
+ border: '1px solid var(--border-strong)',
1737
+ borderRadius: 8,
1738
+ color: 'var(--text-secondary)',
1739
+ fontSize: 13,
1740
+ fontWeight: 500,
1741
+ cursor: 'pointer',
1742
+ transition: 'all 0.15s ease',
1743
+ },
1744
+ };
1745
+
1746
+ export default SessionHistory;