@tldiagram/core-ui 1.95.0 → 2.0.0

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 (102) hide show
  1. package/dist/api/client.d.ts +184 -3
  2. package/dist/components/ConnectorPanel.d.ts +5 -1
  3. package/dist/components/CrossBranchControls.d.ts +4 -3
  4. package/dist/components/ElementNode.d.ts +5 -0
  5. package/dist/components/ElementPanel.d.ts +6 -1
  6. package/dist/components/LayoutSection.d.ts +2 -1
  7. package/dist/components/MergeDialog.d.ts +16 -0
  8. package/dist/components/MiniZoomOnboarding.d.ts +2 -1
  9. package/dist/components/NodeContainer.d.ts +2 -0
  10. package/dist/components/ProxyConnectorPanel.d.ts +4 -1
  11. package/dist/components/ViewExplorer/index.d.ts +1 -1
  12. package/dist/components/ViewFloatingMenu-vscode.d.ts +5 -0
  13. package/dist/components/ViewFloatingMenu.d.ts +8 -1
  14. package/dist/components/ViewGridNode.d.ts +3 -0
  15. package/dist/components/ViewPanel.d.ts +2 -1
  16. package/dist/components/WorkspacePanel.d.ts +2 -0
  17. package/dist/components/ZUI/ZUICanvas.d.ts +5 -0
  18. package/dist/components/ZUI/focus.d.ts +32 -0
  19. package/dist/components/ZUI/focus.test.d.ts +1 -0
  20. package/dist/components/ZUI/layout.d.ts +2 -2
  21. package/dist/components/ZUI/proxy.d.ts +20 -4
  22. package/dist/components/ZUI/renderer.d.ts +35 -1
  23. package/dist/components/ZUI/types.d.ts +6 -0
  24. package/dist/components/ZUI/useZUIInteraction.d.ts +1 -0
  25. package/dist/context/WorkspaceVersionContext.d.ts +49 -0
  26. package/dist/crossBranch/resolve.d.ts +39 -2
  27. package/dist/crossBranch/resolve.test.d.ts +1 -0
  28. package/dist/crossBranch/settings.d.ts +6 -1
  29. package/dist/crossBranch/types.d.ts +8 -0
  30. package/dist/hooks/useElementSearch.d.ts +8 -0
  31. package/dist/index.d.ts +1 -0
  32. package/dist/index.js +14597 -12083
  33. package/dist/pages/InfiniteZoom.d.ts +1 -0
  34. package/dist/pages/ViewEditor/hooks/useCanvasInteractions.d.ts +6 -1
  35. package/dist/pages/ViewEditor/hooks/useViewContextNeighbours.d.ts +2 -0
  36. package/dist/pages/ViewEditor/hooks/useViewData.d.ts +4 -2
  37. package/dist/pages/ViewEditor/hooks/useViewEditHistory.d.ts +13 -0
  38. package/dist/pages/viewsJumpSearch.d.ts +22 -0
  39. package/dist/pages/viewsJumpSearch.test.d.ts +1 -0
  40. package/dist/store/useStore.d.ts +3 -0
  41. package/dist/types/index.d.ts +9 -0
  42. package/dist/utils/elementIcon.d.ts +2 -0
  43. package/dist/utils/elementIcon.test.d.ts +1 -0
  44. package/dist/utils/sourceEditor.d.ts +7 -0
  45. package/dist/utils/watchDiffSummary.d.ts +34 -0
  46. package/package.json +2 -2
  47. package/src/App.tsx +12 -8
  48. package/src/api/client.ts +488 -26
  49. package/src/components/CodePreviewPanel.tsx +90 -16
  50. package/src/components/ConnectorPanel.tsx +34 -3
  51. package/src/components/ContextNeighborElement.tsx +2 -5
  52. package/src/components/CrossBranchControls.tsx +46 -17
  53. package/src/components/ElementNode.tsx +98 -47
  54. package/src/components/ElementPanel.tsx +62 -25
  55. package/src/components/InlineElementAdder.tsx +8 -3
  56. package/src/components/LayoutSection.tsx +4 -1
  57. package/src/components/MergeDialog.tsx +269 -0
  58. package/src/components/MiniZoomOnboarding.tsx +29 -22
  59. package/src/components/NodeContainer.tsx +55 -17
  60. package/src/components/ProxyConnectorPanel.tsx +58 -16
  61. package/src/components/ViewBezierConnector.tsx +116 -21
  62. package/src/components/ViewExplorer/index.tsx +1 -1
  63. package/src/components/ViewFloatingMenu-vscode.tsx +5 -0
  64. package/src/components/ViewFloatingMenu.tsx +110 -1
  65. package/src/components/ViewGridNode.tsx +59 -8
  66. package/src/components/ViewPanel.tsx +3 -2
  67. package/src/components/WorkspacePanel.tsx +938 -0
  68. package/src/components/ZUI/ZUICanvas.tsx +226 -127
  69. package/src/components/ZUI/focus.test.ts +534 -0
  70. package/src/components/ZUI/focus.ts +293 -0
  71. package/src/components/ZUI/layout.ts +7 -11
  72. package/src/components/ZUI/proxy.ts +470 -114
  73. package/src/components/ZUI/renderer.ts +510 -134
  74. package/src/components/ZUI/types.ts +6 -0
  75. package/src/components/ZUI/useZUIInteraction.ts +66 -29
  76. package/src/context/WorkspaceVersionContext.tsx +126 -0
  77. package/src/crossBranch/resolve.test.ts +342 -0
  78. package/src/crossBranch/resolve.ts +368 -68
  79. package/src/crossBranch/settings.ts +49 -3
  80. package/src/crossBranch/types.ts +9 -0
  81. package/src/hooks/useElementSearch.ts +45 -0
  82. package/src/index.css +11 -0
  83. package/src/index.ts +7 -0
  84. package/src/pages/AppearanceSettings.tsx +24 -1
  85. package/src/pages/Dependencies.tsx +231 -65
  86. package/src/pages/InfiniteZoom.tsx +76 -27
  87. package/src/pages/Settings.tsx +1 -1
  88. package/src/pages/ViewEditor/hooks/useCanvasInteractions.ts +103 -24
  89. package/src/pages/ViewEditor/hooks/useViewContextNeighbours.ts +102 -6
  90. package/src/pages/ViewEditor/hooks/useViewData.ts +42 -26
  91. package/src/pages/ViewEditor/hooks/useViewEditHistory.ts +62 -0
  92. package/src/pages/ViewEditor/index.tsx +549 -59
  93. package/src/pages/Views.tsx +112 -41
  94. package/src/pages/ViewsGrid.tsx +332 -113
  95. package/src/pages/viewsJumpSearch.test.ts +193 -0
  96. package/src/pages/viewsJumpSearch.ts +111 -0
  97. package/src/store/useStore.ts +58 -0
  98. package/src/types/index.ts +10 -0
  99. package/src/utils/elementIcon.test.ts +28 -0
  100. package/src/utils/elementIcon.ts +20 -0
  101. package/src/utils/sourceEditor.ts +46 -0
  102. package/src/utils/watchDiffSummary.ts +159 -0
@@ -0,0 +1,938 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2
+ import { useLocation, useNavigate } from 'react-router-dom'
3
+ import { useQueryClient } from '@tanstack/react-query'
4
+ import {
5
+ Badge,
6
+ Box,
7
+ Button,
8
+ Collapse,
9
+ HStack,
10
+ IconButton,
11
+ Menu,
12
+ MenuButton,
13
+ MenuList,
14
+ MenuItem,
15
+ Popover,
16
+ PopoverBody,
17
+ PopoverContent,
18
+ PopoverTrigger,
19
+ Portal,
20
+ Text,
21
+ Tooltip,
22
+ VStack,
23
+ } from '@chakra-ui/react'
24
+ import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon, ChevronUpIcon, CloseIcon, RepeatIcon, TimeIcon, ViewIcon, ViewOffIcon } from '@chakra-ui/icons'
25
+ import {
26
+ api,
27
+ type WatchDiff,
28
+ type WatchEvent,
29
+ type WatchLock,
30
+ type WatchRepresentationSummary,
31
+ type WatchRepository,
32
+ type WatchVersion,
33
+ type WorkspaceVersion,
34
+ } from '../api/client'
35
+ import { buildWorkspaceVersionPreview, useWorkspaceVersionPreview } from '../context/WorkspaceVersionContext'
36
+ import {
37
+ buildWatchDiffLocations,
38
+ summarizeWatchDiffs,
39
+ type WatchDiffLocation,
40
+ type WatchDiffSummary,
41
+ } from '../utils/watchDiffSummary'
42
+
43
+ export const WATCH_REPRESENTATION_UPDATED_EVENT = 'tld:watch-representation-updated'
44
+
45
+ // ─── Watch helpers ────────────────────────────────────────────────────────────
46
+
47
+ type WatchLine = {
48
+ id: number
49
+ at: string
50
+ text: string
51
+ tone: 'info' | 'success' | 'warning' | 'error'
52
+ }
53
+
54
+ function PauseGlyph() {
55
+ return (
56
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
57
+ <rect x="6" y="5" width="4" height="14" rx="1" />
58
+ <rect x="14" y="5" width="4" height="14" rx="1" />
59
+ </svg>
60
+ )
61
+ }
62
+
63
+ function summarizeEvent(event: WatchEvent): WatchLine | null {
64
+ const id = Date.now() + Math.random()
65
+ const at = event.at || new Date().toISOString()
66
+ const type = event.type
67
+ if (type === 'watch.heartbeat') return null
68
+ if (type === 'watch.connected') return { id, at, text: 'Watch stream connected', tone: 'success' }
69
+ if (type === 'watch.paused') return { id, at, text: 'Watch paused', tone: 'warning' }
70
+ if (type === 'watch.stopped') return { id, at, text: 'Watch stopped', tone: 'warning' }
71
+ if (type === 'watch.error') return { id, at, text: event.message || 'Watch error', tone: 'error' }
72
+ if (type === 'lock.disabled') return null
73
+ if (type === 'lock.enabled') return { id, at, text: 'Workspace locked for watch updates', tone: 'info' }
74
+ if (type === 'version.created') return null
75
+ if (type === 'representation.updated') {
76
+ const data = event.data as Partial<WatchRepresentationSummary> | undefined
77
+ const changed = [
78
+ data?.views_created ? `views +${data.views_created}` : '',
79
+ data?.elements_created || data?.elements_updated ? `elements +${data.elements_created ?? 0}/${data.elements_updated ?? 0}` : '',
80
+ data?.connectors_created || data?.connectors_updated ? `connectors +${data.connectors_created ?? 0}/${data.connectors_updated ?? 0}` : '',
81
+ ].filter(Boolean).join(', ')
82
+ return { id, at, text: changed ? `Workspace updated: ${changed}` : 'Workspace refreshed', tone: 'success' }
83
+ }
84
+ if (type === 'scan.started') {
85
+ const files = event.changed_files ? ` · ${event.changed_files} files` : ''
86
+ return { id, at, text: `Scanning${files}`, tone: 'info' }
87
+ }
88
+ if (type === 'scan.completed') {
89
+ const warnings = event.warnings?.length ? ` · ${event.warnings[0]}` : ''
90
+ return { id, at, text: `Scan complete${warnings}`, tone: event.warnings?.length ? 'warning' : 'success' }
91
+ }
92
+ if (type === 'source.changed') {
93
+ const data = event.data as { change?: { path?: string; change_type?: string }; representation_changed?: boolean } | undefined
94
+ const path = data?.change?.path ?? 'source file'
95
+ const suffix = data?.representation_changed ? 'changed the diagram' : 'did not change the diagram'
96
+ return { id, at, text: `${path} ${suffix}`, tone: data?.representation_changed ? 'success' : 'info' }
97
+ }
98
+ return { id, at, text: type, tone: 'info' }
99
+ }
100
+
101
+ function shortPath(path: string | undefined): string {
102
+ if (!path) return 'repository'
103
+ const parts = path.split(/[/\\]/).filter(Boolean)
104
+ return parts.slice(-2).join('/') || path
105
+ }
106
+
107
+ function versionLabel(version: WatchVersion) {
108
+ const subject = version.commit_message?.trim()
109
+ return subject || `Version ${new Date(version.created_at).toLocaleTimeString()}`
110
+ }
111
+
112
+ function normalizeDiffs(value: WatchDiff[] | null | undefined): WatchDiff[] {
113
+ return Array.isArray(value) ? value : []
114
+ }
115
+
116
+ function mergeRepositoryOption(repos: WatchRepository[], repo: WatchRepository | null | undefined): WatchRepository[] {
117
+ if (!repo) return repos
118
+ const existing = repos.find((item) => item.id === repo.id)
119
+ if (existing) {
120
+ return repos.map((item) => item.id === repo.id ? { ...item, ...repo } : item)
121
+ }
122
+ return [repo, ...repos]
123
+ }
124
+
125
+ function ResourceCountDisplay({ summary }: { summary: WatchDiffSummary }) {
126
+ const rows = [
127
+ { label: 'Elements', stat: summary.elements },
128
+ { label: 'Connectors', stat: summary.connectors },
129
+ ]
130
+ const total = rows.reduce((sum, row) => (
131
+ sum + row.stat.added + row.stat.updated + row.stat.deleted + row.stat.initialized
132
+ ), 0)
133
+ const changes = [
134
+ { key: 'added', label: 'added', color: 'green.300' },
135
+ { key: 'updated', label: 'updated', color: 'yellow.300' },
136
+ { key: 'deleted', label: 'deleted', color: 'red.300' },
137
+ { key: 'initialized', label: 'initialized', color: 'blue.300' },
138
+ ] as const
139
+
140
+ return (
141
+ <Box
142
+ px={4}
143
+ py={3}
144
+ border="1px solid"
145
+ borderColor="whiteAlpha.100"
146
+ borderRadius="md"
147
+ bg="whiteAlpha.50"
148
+ >
149
+ <HStack justify="space-between" mb={2} spacing={3}>
150
+ <Text fontSize="12px" color="gray.400" fontWeight="700" textTransform="uppercase">
151
+ Diagram resources
152
+ </Text>
153
+ <Text fontSize="11px" color="gray.500">{total} total</Text>
154
+ </HStack>
155
+ <VStack align="stretch" spacing={1.5}>
156
+ {rows.map((row) => (
157
+ <HStack key={row.label} spacing={2} minW={0} justify="space-between">
158
+ <Text fontSize="12px" color="gray.300" fontWeight="600" minW="76px">{row.label}</Text>
159
+ <HStack spacing={2} justify="flex-end" flexWrap="wrap">
160
+ {changes.map((change) => {
161
+ const count = row.stat[change.key]
162
+ return count > 0 ? (
163
+ <Text key={change.key} fontSize="11px" color={change.color} fontFamily="mono">
164
+ {count} {change.label}
165
+ </Text>
166
+ ) : null
167
+ })}
168
+ {row.stat.added + row.stat.updated + row.stat.deleted + row.stat.initialized === 0 && (
169
+ <Text fontSize="11px" color="gray.600" fontFamily="mono">none</Text>
170
+ )}
171
+ </HStack>
172
+ </HStack>
173
+ ))}
174
+ </VStack>
175
+ </Box>
176
+ )
177
+ }
178
+
179
+ // ─── Themed dropdown ──────────────────────────────────────────────────────────
180
+
181
+ interface ThemedSelectProps<T extends string | number> {
182
+ value: T | ''
183
+ options: { value: T; label: string }[]
184
+ placeholder?: string
185
+ onChange: (value: T | '') => void
186
+ isDisabled?: boolean
187
+ flex?: number
188
+ }
189
+
190
+ function ThemedSelect<T extends string | number>({ value, options, placeholder, onChange, isDisabled, flex }: ThemedSelectProps<T>) {
191
+ const selected = options.find((o) => o.value === value)
192
+ return (
193
+ <Menu placement="bottom-start" strategy="fixed">
194
+ <MenuButton
195
+ as={Button}
196
+ rightIcon={<ChevronDownIcon />}
197
+ size="sm"
198
+ variant="ghost"
199
+ isDisabled={isDisabled}
200
+ flex={flex}
201
+ minW={0}
202
+ h="32px"
203
+ px={3}
204
+ fontSize="13px"
205
+ fontWeight="500"
206
+ color={selected ? 'gray.100' : 'gray.500'}
207
+ bg="whiteAlpha.50"
208
+ border="1px solid"
209
+ borderColor="whiteAlpha.100"
210
+ borderRadius="md"
211
+ _hover={{ bg: 'whiteAlpha.100', borderColor: 'whiteAlpha.200' }}
212
+ _active={{ bg: 'whiteAlpha.150' }}
213
+ textAlign="left"
214
+ justifyContent="flex-start"
215
+ overflow="hidden"
216
+ sx={{ '> span:first-of-type': { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }}
217
+ >
218
+ {selected?.label ?? placeholder ?? '—'}
219
+ </MenuButton>
220
+ <Portal>
221
+ <MenuList
222
+ data-zui-native-wheel="true"
223
+ bg="rgba(var(--bg-main-rgb), 0.98)"
224
+ border="1px solid"
225
+ borderColor="whiteAlpha.200"
226
+ borderRadius="lg"
227
+ boxShadow="0 12px 32px rgba(0,0,0,0.5)"
228
+ backdropFilter="blur(18px)"
229
+ minW="200px"
230
+ maxH="240px"
231
+ overflowY="auto"
232
+ zIndex={2000}
233
+ py={1}
234
+ sx={{ overscrollBehavior: 'contain', WebkitOverflowScrolling: 'touch', touchAction: 'pan-y' }}
235
+ >
236
+ {options.length === 0 && (
237
+ <MenuItem isDisabled fontSize="13px" color="gray.500" bg="transparent">No options</MenuItem>
238
+ )}
239
+ {options.map((opt) => (
240
+ <MenuItem
241
+ key={String(opt.value)}
242
+ fontSize="13px"
243
+ color={opt.value === value ? 'var(--accent)' : 'gray.200'}
244
+ fontWeight={opt.value === value ? '600' : '400'}
245
+ bg="transparent"
246
+ _hover={{ bg: 'whiteAlpha.100' }}
247
+ _focus={{ bg: 'whiteAlpha.100' }}
248
+ py={2}
249
+ px={3}
250
+ onClick={() => onChange(opt.value)}
251
+ >
252
+ {opt.label}
253
+ </MenuItem>
254
+ ))}
255
+ </MenuList>
256
+ </Portal>
257
+ </Menu>
258
+ )
259
+ }
260
+
261
+ // ─── Main combined panel ──────────────────────────────────────────────────────
262
+
263
+ export default function WorkspacePanel() {
264
+ const navigate = useNavigate()
265
+ const location = useLocation()
266
+ const queryClient = useQueryClient()
267
+
268
+ // ── Version state ─────────────────────────────────────────────────────────
269
+ const { preview, setPreview, clearPreview, requestFollow } = useWorkspaceVersionPreview()
270
+ const [versionsOpen, setVersionsOpen] = useState(false)
271
+ const [diffVisible, setDiffVisible] = useState(false)
272
+ const [repos, setRepos] = useState<WatchRepository[]>([])
273
+ const [versions, setVersions] = useState<WatchVersion[]>([])
274
+ const [workspaceVersions, setWorkspaceVersions] = useState<WorkspaceVersion[]>([])
275
+ const [repoId, setRepoId] = useState<number | ''>('')
276
+ const [versionId, setVersionId] = useState<number | ''>('')
277
+ const [diffs, setDiffs] = useState<WatchDiff[]>([])
278
+ const [diffLocations, setDiffLocations] = useState<WatchDiffLocation[]>([])
279
+ const [activeDiffLocationKey, setActiveDiffLocationKey] = useState<string | null>(null)
280
+ const [watchActive, setWatchActive] = useState(false)
281
+ const [watchPaused, setWatchPaused] = useState(false)
282
+ const [watchRepository, setWatchRepository] = useState<WatchRepository | null>(null)
283
+ const [watchLock, setWatchLock] = useState<WatchLock | null>(null)
284
+ const [watchConnected, setWatchConnected] = useState(false)
285
+ const [watcherMode, setWatcherMode] = useState('')
286
+ const [languages, setLanguages] = useState<string[]>([])
287
+ const [watchLines, setWatchLines] = useState<WatchLine[]>([])
288
+ const [runtimeOpen, setRuntimeOpen] = useState(true)
289
+
290
+ const repoOptions = useMemo(() => mergeRepositoryOption(repos, watchRepository), [repos, watchRepository])
291
+ const selectedRepo = useMemo(() => {
292
+ const selected = repoOptions.find((r) => r.id === repoId)
293
+ if (selected) return selected
294
+ if (!repoId || watchRepository?.id === repoId) return watchRepository ?? null
295
+ return null
296
+ }, [repoOptions, repoId, watchRepository])
297
+ const selectedVersion = useMemo(() => versions.find((v) => v.id === versionId) ?? null, [versions, versionId])
298
+
299
+ const selectLatestWatchVersion = useCallback(async (targetRepoId: number) => {
300
+ const nextVersions = await api.watch.versions(targetRepoId)
301
+ setVersions(nextVersions)
302
+ const latest = nextVersions[0] ?? null
303
+ setVersionId(latest?.id ?? '')
304
+ if (!latest) {
305
+ setDiffs([])
306
+ return
307
+ }
308
+ const latestDiffs = await api.watch.diffs(latest.id).catch(() => [] as WatchDiff[])
309
+ setDiffs(normalizeDiffs(latestDiffs))
310
+ }, [])
311
+
312
+ const loadVersions = useCallback(async () => {
313
+ const [nextRepos, nextWsVersions] = await Promise.all([
314
+ api.watch.repositories().catch(() => [] as WatchRepository[]),
315
+ api.versions.list(50).catch(() => [] as WorkspaceVersion[]),
316
+ ])
317
+ const mergedRepos = mergeRepositoryOption(nextRepos, watchRepository)
318
+ setRepos(mergedRepos)
319
+ setWorkspaceVersions(nextWsVersions)
320
+ const nextRepoId = repoId || watchRepository?.id || mergedRepos[0]?.id || ''
321
+ setRepoId(nextRepoId)
322
+ if (nextRepoId) {
323
+ const nextVersions = await api.watch.versions(nextRepoId)
324
+ setVersions(nextVersions)
325
+ setVersionId(versionId || nextVersions[0]?.id || '')
326
+ }
327
+ }, [repoId, versionId, watchRepository])
328
+
329
+ useEffect(() => {
330
+ if (!versionsOpen && !preview) return
331
+ void loadVersions()
332
+ // eslint-disable-next-line react-hooks/exhaustive-deps
333
+ }, [versionsOpen])
334
+
335
+ useEffect(() => {
336
+ if (!repoId) { setVersions([]); setVersionId(''); return }
337
+ api.watch.versions(repoId).then((next) => {
338
+ setVersions(next)
339
+ setVersionId(next[0]?.id ?? '')
340
+ }).catch(() => { setVersions([]); setVersionId('') })
341
+ }, [repoId])
342
+
343
+ useEffect(() => {
344
+ if (!versionId) { setDiffs([]); return }
345
+ api.watch.diffs(versionId).then((next) => setDiffs(normalizeDiffs(next))).catch(() => setDiffs([]))
346
+ }, [versionId])
347
+
348
+ useEffect(() => {
349
+ if (!diffs.length) {
350
+ setDiffLocations([])
351
+ return
352
+ }
353
+ let cancelled = false
354
+ api.explore.load().then((data) => {
355
+ if (!cancelled) setDiffLocations(buildWatchDiffLocations(data, diffs))
356
+ }).catch(() => {
357
+ if (!cancelled) setDiffLocations([])
358
+ })
359
+ return () => { cancelled = true }
360
+ }, [diffs])
361
+
362
+ const displayedDiffLocations = useMemo(() => diffLocations.slice(0, 24), [diffLocations])
363
+ const navigableDiffLocations = useMemo(() => {
364
+ const elementLocations = diffLocations.filter((target) => target.resourceType === 'element')
365
+ return elementLocations.length > 0 ? elementLocations : diffLocations
366
+ }, [diffLocations])
367
+ const activeDiffLocationIndex = useMemo(() => {
368
+ if (!activeDiffLocationKey) return -1
369
+ const index = navigableDiffLocations.findIndex((target) => target.key === activeDiffLocationKey)
370
+ return index >= 0 ? index : -1
371
+ }, [activeDiffLocationKey, navigableDiffLocations])
372
+
373
+ useEffect(() => {
374
+ if (!selectedVersion || !diffVisible) {
375
+ clearPreview()
376
+ return
377
+ }
378
+ setPreview(buildWorkspaceVersionPreview({ repository: selectedRepo, version: selectedVersion, workspaceVersions, diffs }))
379
+ }, [clearPreview, diffVisible, diffs, selectedRepo, selectedVersion, setPreview, workspaceVersions])
380
+
381
+ const navigateToDiffLocation = useCallback((target: WatchDiffLocation) => {
382
+ setActiveDiffLocationKey(target.key)
383
+ requestFollow({
384
+ resourceType: target.resourceType,
385
+ resourceId: target.resourceId,
386
+ viewId: target.viewId,
387
+ changeType: target.changeType,
388
+ })
389
+ if (location.pathname === '/dependencies' && target.resourceType === 'element' && target.resourceId) {
390
+ navigate(`/dependencies?element=${target.resourceId}`)
391
+ return
392
+ }
393
+ if (location.pathname.startsWith('/views/') && !location.pathname.startsWith('/views?')) {
394
+ const elementQuery = target.resourceType === 'element' && target.resourceId ? `?element=${target.resourceId}` : ''
395
+ navigate(`/views/${target.viewId}${elementQuery}`)
396
+ return
397
+ }
398
+ const elementQuery = target.resourceType === 'element' && target.resourceId ? `&element=${target.resourceId}` : ''
399
+ navigate(`/views?view=explore&focus=${target.viewId}${elementQuery}`)
400
+ }, [location.pathname, navigate, requestFollow])
401
+
402
+ const navigateDiffLocationByOffset = useCallback((offset: number) => {
403
+ if (navigableDiffLocations.length === 0) return
404
+ const nextIndex = activeDiffLocationIndex < 0
405
+ ? offset > 0 ? 0 : navigableDiffLocations.length - 1
406
+ : (activeDiffLocationIndex + offset + navigableDiffLocations.length) % navigableDiffLocations.length
407
+ navigateToDiffLocation(navigableDiffLocations[nextIndex])
408
+ }, [activeDiffLocationIndex, navigableDiffLocations, navigateToDiffLocation])
409
+
410
+ const activeVersion = preview?.version ?? selectedVersion
411
+ const diffSummary = useMemo(() => summarizeWatchDiffs(diffs), [diffs])
412
+ const totalFileChanges = diffSummary.files.added + diffSummary.files.updated + diffSummary.files.deleted + diffSummary.files.initialized
413
+ const totalTldChanges = diffSummary.elements.added + diffSummary.elements.updated + diffSummary.elements.deleted + diffSummary.elements.initialized +
414
+ diffSummary.connectors.added + diffSummary.connectors.updated + diffSummary.connectors.deleted + diffSummary.connectors.initialized
415
+ const activeDiffLocation = activeDiffLocationIndex >= 0 ? navigableDiffLocations[activeDiffLocationIndex] : null
416
+ const headerAddedLines = activeDiffLocation?.addedLines ?? diffSummary.elements.addedLines + diffSummary.connectors.addedLines
417
+ const headerRemovedLines = activeDiffLocation?.removedLines ?? diffSummary.elements.removedLines + diffSummary.connectors.removedLines
418
+
419
+ // ── Watch state ───────────────────────────────────────────────────────────
420
+ const socketRef = useRef<WebSocket | null>(null)
421
+ const reconnectTimerRef = useRef<number | null>(null)
422
+ const reconnectAttemptRef = useRef(0)
423
+ const lastWatchMessageAtRef = useRef(0)
424
+ const socketHealthTimerRef = useRef<number | null>(null)
425
+ const lastRepresentationHashRef = useRef('')
426
+ const addLine = useCallback((line: WatchLine | null) => {
427
+ if (!line) return
428
+ setWatchLines((current) => {
429
+ if (current[0]?.text === line.text && current[0]?.tone === line.tone) return current
430
+ return [line, ...current].slice(0, 8)
431
+ })
432
+ }, [])
433
+
434
+ const refreshWorkspace = useCallback((event: WatchEvent) => {
435
+ const data = event.data as Partial<WatchRepresentationSummary> | undefined
436
+ const hash = data?.representation_hash ?? ''
437
+ if (hash && hash === lastRepresentationHashRef.current) return
438
+ if (hash) lastRepresentationHashRef.current = hash
439
+ void queryClient.invalidateQueries({ queryKey: ['workspace', 'views'] })
440
+ void queryClient.invalidateQueries({ queryKey: ['elements', 'list'] })
441
+ window.dispatchEvent(new CustomEvent(WATCH_REPRESENTATION_UPDATED_EVENT, { detail: event }))
442
+ }, [queryClient])
443
+
444
+ const handleEvent = useCallback((event: WatchEvent) => {
445
+ const eventLock = event.data && typeof event.data === 'object' && 'status' in event.data
446
+ ? event.data as WatchLock : null
447
+ if (event.repository_id) setWatchLock((current) => eventLock ?? current)
448
+ if (eventLock) setWatchPaused(eventLock.status === 'paused')
449
+ if (event.watcher_mode) setWatcherMode(event.watcher_mode)
450
+ if (event.languages?.length) setLanguages(event.languages)
451
+ if (event.type === 'watch.paused') setWatchPaused(true)
452
+ if (event.type === 'watch.heartbeat') {
453
+ setWatchActive(true)
454
+ if (eventLock) setWatchPaused(eventLock.status === 'paused')
455
+ }
456
+ if (event.type === 'watch.stopped') { setWatchActive(false); setWatchPaused(false) }
457
+ if (event.type === 'representation.updated') {
458
+ const data = event.data as Partial<WatchRepresentationSummary> | undefined
459
+ if ('diffs' in (data ?? {})) setDiffs(normalizeDiffs(data?.diffs))
460
+ refreshWorkspace(event)
461
+ }
462
+ if (event.type === 'version.created') {
463
+ const version = event.data as Partial<WatchVersion> | undefined
464
+ const targetRepoId = event.repository_id || version?.repository_id || watchLock?.repository_id || watchRepository?.id || 0
465
+ clearPreview()
466
+ setDiffs([])
467
+ if (targetRepoId > 0) {
468
+ setRepoId(targetRepoId)
469
+ void selectLatestWatchVersion(targetRepoId)
470
+ }
471
+ }
472
+ if (event.type !== 'watch.stopped' || watchActive) addLine(summarizeEvent(event))
473
+ }, [watchActive, addLine, clearPreview, refreshWorkspace, selectLatestWatchVersion, watchLock?.repository_id, watchRepository?.id])
474
+ const handleEventRef = useRef(handleEvent)
475
+
476
+ useEffect(() => {
477
+ handleEventRef.current = handleEvent
478
+ }, [handleEvent])
479
+
480
+ useEffect(() => {
481
+ let cancelled = false
482
+ const poll = async () => {
483
+ const status = await api.watch.status().catch(() => null)
484
+ if (!status || cancelled) return
485
+ setWatchActive(status.active)
486
+ setWatchRepository(status.repository ?? null)
487
+ setWatchLock(status.lock ?? null)
488
+ setWatchPaused(status.lock?.status === 'paused')
489
+ if (status.repository) {
490
+ setRepos((current) => mergeRepositoryOption(current, status.repository))
491
+ setRepoId((current) => current || status.repository?.id || '')
492
+ }
493
+ }
494
+ void poll()
495
+ const interval = window.setInterval(poll, 5000)
496
+ return () => { cancelled = true; window.clearInterval(interval) }
497
+ }, [])
498
+
499
+ useEffect(() => {
500
+ let disposed = false
501
+ const scheduleReconnect = () => {
502
+ if (disposed) return
503
+ const delay = Math.min(5000, 1000 * 2 ** Math.min(reconnectAttemptRef.current, 3))
504
+ reconnectAttemptRef.current += 1
505
+ reconnectTimerRef.current = window.setTimeout(connect, delay)
506
+ }
507
+ const connect = () => {
508
+ if (disposed) return
509
+ const socket = new WebSocket(api.watch.websocketUrl())
510
+ socketRef.current = socket
511
+ lastWatchMessageAtRef.current = Date.now()
512
+ socket.onopen = () => {
513
+ setWatchConnected(true)
514
+ reconnectAttemptRef.current = 0
515
+ lastWatchMessageAtRef.current = Date.now()
516
+ addLine({ id: Date.now() + Math.random(), at: new Date().toISOString(), text: 'Watch stream connected', tone: 'success' })
517
+ try { socket.send(JSON.stringify({ type: 'watch.status' })) } catch { /* ignore */ }
518
+ }
519
+ socket.onclose = () => {
520
+ setWatchConnected(false)
521
+ if (!disposed) {
522
+ addLine({ id: Date.now() + Math.random(), at: new Date().toISOString(), text: 'Watch stream reconnecting', tone: 'warning' })
523
+ scheduleReconnect()
524
+ }
525
+ }
526
+ socket.onerror = () => socket.close()
527
+ socket.onmessage = (msg) => {
528
+ lastWatchMessageAtRef.current = Date.now()
529
+ try { handleEventRef.current(JSON.parse(msg.data) as WatchEvent) } catch { /* ignore */ }
530
+ }
531
+ }
532
+ connect()
533
+ socketHealthTimerRef.current = window.setInterval(() => {
534
+ const socket = socketRef.current
535
+ if (socket?.readyState === WebSocket.OPEN && Date.now() - lastWatchMessageAtRef.current > 10000) {
536
+ socket.close()
537
+ }
538
+ }, 5000)
539
+ return () => {
540
+ disposed = true
541
+ if (reconnectTimerRef.current !== null) window.clearTimeout(reconnectTimerRef.current)
542
+ if (socketHealthTimerRef.current !== null) window.clearInterval(socketHealthTimerRef.current)
543
+ socketRef.current?.close()
544
+ socketRef.current = null
545
+ }
546
+ }, [addLine])
547
+
548
+ const sendControl = useCallback((type: 'watch.pause' | 'watch.resume' | 'watch.stop') => {
549
+ const socket = socketRef.current
550
+ if (!socket || socket.readyState !== WebSocket.OPEN) return
551
+ socket.send(JSON.stringify({ type, repository_id: watchLock?.repository_id ?? watchRepository?.id ?? 0 }))
552
+ if (type === 'watch.pause') setWatchPaused(true)
553
+ if (type === 'watch.resume') setWatchPaused(false)
554
+ if (type === 'watch.stop') setWatchActive(false)
555
+ }, [watchLock?.repository_id, watchRepository?.id])
556
+
557
+ const watchStatusColor = !watchActive ? 'gray' : watchPaused ? 'yellow' : watchConnected ? 'green' : 'orange'
558
+ const watchStatusLabel = !watchActive ? 'Stopped' : watchPaused ? 'Paused' : 'Live'
559
+ const watchTitle = useMemo(() => shortPath(watchRepository?.repo_root), [watchRepository?.repo_root])
560
+ const watchMode = [watcherMode || (watchConnected ? 'live' : 'connecting'), languages.length ? languages.join(', ') : ''].filter(Boolean).join(' · ')
561
+ const triggerLabel = watchActive ? `${watchStatusLabel}: ${watchTitle}` : 'Workspace versions'
562
+
563
+ const showRuntimeSection = watchActive || watchLines.length > 0
564
+
565
+ // ── Render ────────────────────────────────────────────────────────────────
566
+ return (
567
+ <Popover placement="bottom-end" isLazy>
568
+ <Tooltip label={triggerLabel} placement="bottom" openDelay={400}>
569
+ <Box>
570
+ <PopoverTrigger>
571
+ {watchActive ? (
572
+ <Button
573
+ aria-label="Workspace watch panel"
574
+ size="sm"
575
+ h="34px"
576
+ minW={0}
577
+ px={2.5}
578
+ gap={2}
579
+ borderRadius="full"
580
+ bg="whiteAlpha.100"
581
+ color="whiteAlpha.900"
582
+ border="1px solid"
583
+ borderColor={watchStatusColor === 'green' ? 'green.400' : watchStatusColor === 'yellow' ? 'yellow.400' : 'orange.300'}
584
+ boxShadow={watchStatusColor === 'green' ? '0 0 18px rgba(72,187,120,0.28)' : '0 6px 18px rgba(0,0,0,0.32)'}
585
+ _hover={{ bg: 'whiteAlpha.200', transform: 'translateY(-1px)' }}
586
+ _active={{ transform: 'translateY(0)' }}
587
+ >
588
+ <Badge
589
+ bg={watchStatusColor === 'green' ? 'green.900' : watchStatusColor === 'yellow' ? 'yellow.900' : 'orange.900'}
590
+ color={watchStatusColor === 'green' ? 'green.200' : watchStatusColor === 'yellow' ? 'yellow.200' : 'orange.100'}
591
+ borderRadius="full"
592
+ textTransform="none"
593
+ fontSize="10px"
594
+ px={1.5}
595
+ py={0.5}
596
+ flexShrink={0}
597
+ >
598
+ {watchStatusLabel}
599
+ </Badge>
600
+ <Text
601
+ as="span"
602
+ maxW={{ base: '96px', lg: '160px' }}
603
+ fontSize="12px"
604
+ fontWeight="600"
605
+ color="gray.100"
606
+ noOfLines={1}
607
+ >
608
+ {watchTitle}
609
+ </Text>
610
+ <ChevronDownIcon boxSize={4} color="whiteAlpha.700" />
611
+ </Button>
612
+ ) : (
613
+ <IconButton
614
+ aria-label="Workspace versions"
615
+ icon={<TimeIcon boxSize={4} />}
616
+ size="sm"
617
+ borderRadius="full"
618
+ bg={preview ? 'rgba(var(--accent-rgb), 0.22)' : 'whiteAlpha.100'}
619
+ color={preview ? 'var(--accent)' : 'whiteAlpha.700'}
620
+ border="1px solid"
621
+ borderColor={preview ? 'rgba(var(--accent-rgb), 0.45)' : 'whiteAlpha.100'}
622
+ _hover={{ bg: 'whiteAlpha.200', color: 'white', transform: 'translateY(-1px)' }}
623
+ />
624
+ )}
625
+ </PopoverTrigger>
626
+ </Box>
627
+ </Tooltip>
628
+ <Portal>
629
+ <PopoverContent
630
+ data-zui-native-wheel="true"
631
+ w={{ base: 'calc(100vw - 24px)', md: watchActive ? '460px' : '420px' }}
632
+ maxW="calc(100vw - 24px)"
633
+ mt={2}
634
+ mr={{ base: 2, sm: 0 }}
635
+ bg="rgba(var(--bg-main-rgb), 0.96)"
636
+ border="1px solid"
637
+ borderColor={preview ? 'rgba(var(--accent-rgb), 0.45)' : 'whiteAlpha.200'}
638
+ borderRadius="lg"
639
+ boxShadow="0 18px 48px rgba(0,0,0,0.45)"
640
+ backdropFilter="blur(18px)"
641
+ overflow="hidden"
642
+ zIndex={2100}
643
+ _focus={{ boxShadow: '0 18px 48px rgba(0,0,0,0.45)' }}
644
+ sx={{
645
+ overscrollBehavior: 'contain',
646
+ WebkitOverflowScrolling: 'touch',
647
+ touchAction: 'pan-y',
648
+ }}
649
+ >
650
+ <PopoverBody p={0}>
651
+ {/* ── Versions header ── */}
652
+ <VStack align="stretch" spacing={3} px={4} py={4}>
653
+ <HStack spacing={3} align="center">
654
+ <HStack flex={1} spacing={2} minW={0}>
655
+ <ThemedSelect<number>
656
+ value={repoId}
657
+ placeholder="Repository"
658
+ options={repoOptions.map((r) => ({ value: r.id, label: r.display_name || shortPath(r.repo_root) }))}
659
+ onChange={(v) => setRepoId(v)}
660
+ flex={1}
661
+ />
662
+ <ThemedSelect<number>
663
+ value={versionId}
664
+ placeholder="Branch"
665
+ options={versions.map((v) => ({
666
+ value: v.id,
667
+ label: `${v.branch || 'detached'} (${new Date(v.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })})`,
668
+ }))}
669
+ onChange={(v) => setVersionId(v)}
670
+ flex={1}
671
+ />
672
+ </HStack>
673
+ <HStack spacing={1}>
674
+ {activeVersion && (
675
+ <Tooltip label={diffVisible ? 'Hide diff' : 'Show diff'} placement="top">
676
+ <IconButton
677
+ aria-label="Toggle diff"
678
+ icon={diffVisible ? <ViewOffIcon boxSize={3.5} /> : <ViewIcon boxSize={3.5} />}
679
+ size="sm"
680
+ variant="ghost"
681
+ color={diffVisible ? 'var(--accent)' : 'whiteAlpha.700'}
682
+ onClick={() => setDiffVisible((visible) => !visible)}
683
+ />
684
+ </Tooltip>
685
+ )}
686
+ <Tooltip label={versionsOpen ? 'Collapse list' : 'Expand list'} placement="top">
687
+ <IconButton
688
+ aria-label="Toggle list"
689
+ icon={versionsOpen ? <ChevronDownIcon boxSize={4} /> : <ChevronUpIcon boxSize={4} />}
690
+ size="sm"
691
+ variant="ghost"
692
+ color={versionsOpen ? 'var(--accent)' : 'whiteAlpha.700'}
693
+ onClick={() => setVersionsOpen((v) => !v)}
694
+ />
695
+ </Tooltip>
696
+ </HStack>
697
+ </HStack>
698
+
699
+ <HStack
700
+ px={3}
701
+ py={2.5}
702
+ bg="whiteAlpha.50"
703
+ borderRadius="md"
704
+ border="1px solid"
705
+ borderColor="whiteAlpha.100"
706
+ justify="space-between"
707
+ align="center"
708
+ >
709
+ <HStack spacing={3} minW={0} flex={1}>
710
+ <Text fontSize="13px" color="green.400" fontWeight="700" fontFamily="mono">
711
+ +{headerAddedLines}
712
+ </Text>
713
+ <Text fontSize="13px" color="red.400" fontWeight="700" fontFamily="mono">
714
+ -{headerRemovedLines}
715
+ </Text>
716
+ <Text fontSize="12px" color="gray.400" fontWeight="500" noOfLines={1} flex={1}>
717
+ {activeDiffLocation
718
+ ? `${activeDiffLocationIndex + 1} of ${navigableDiffLocations.length}: ${activeDiffLocation.label}`
719
+ : `${totalTldChanges} changed elements`}
720
+ </Text>
721
+ </HStack>
722
+ <HStack spacing={1} flexShrink={0}>
723
+ <Tooltip label="Previous element" placement="top">
724
+ <IconButton
725
+ aria-label="Previous"
726
+ icon={<ChevronLeftIcon boxSize={5} />}
727
+ size="sm"
728
+ variant="solid"
729
+ h="32px"
730
+ w="32px"
731
+ bg="whiteAlpha.200"
732
+ _hover={{ bg: 'whiteAlpha.300' }}
733
+ _active={{ bg: 'whiteAlpha.400' }}
734
+ isDisabled={navigableDiffLocations.length === 0}
735
+ onClick={() => navigateDiffLocationByOffset(-1)}
736
+ />
737
+ </Tooltip>
738
+ <Tooltip label="Next element" placement="top">
739
+ <IconButton
740
+ aria-label="Next"
741
+ icon={<ChevronRightIcon boxSize={5} />}
742
+ size="sm"
743
+ variant="solid"
744
+ h="32px"
745
+ w="32px"
746
+ bg="whiteAlpha.200"
747
+ _hover={{ bg: 'whiteAlpha.300' }}
748
+ _active={{ bg: 'whiteAlpha.400' }}
749
+ isDisabled={navigableDiffLocations.length === 0}
750
+ onClick={() => navigateDiffLocationByOffset(1)}
751
+ />
752
+ </Tooltip>
753
+ </HStack>
754
+ </HStack>
755
+ </VStack>
756
+
757
+ {/* ── Versions body ── */}
758
+ <Collapse in={versionsOpen} animateOpacity>
759
+ <VStack align="stretch" spacing={3} px={4} pb={4} borderTop="1px solid" borderColor="whiteAlpha.100">
760
+ <Box
761
+ mt={3}
762
+ px={4}
763
+ py={3}
764
+ border="1px solid"
765
+ borderColor="whiteAlpha.100"
766
+ borderRadius="md"
767
+ bg="whiteAlpha.50"
768
+ >
769
+ <Text fontSize="13px" color="gray.400" noOfLines={1} mb={2} fontWeight="500">
770
+ {activeVersion ? versionLabel(activeVersion) : 'Repository snapshot'}
771
+ </Text>
772
+ <HStack spacing={2} fontFamily="mono" fontSize="13px" minW={0} align="center">
773
+ <Text color="gray.300" fontWeight="500">
774
+ {totalFileChanges} files
775
+ </Text>
776
+ <Text color="green.400">+{diffSummary.files.addedLines}</Text>
777
+ <Text color="red.400">-{diffSummary.files.removedLines}</Text>
778
+ <Text color="gray.500" ml="auto" fontSize="11px">{workspaceVersions.length} snapshots</Text>
779
+ </HStack>
780
+ </Box>
781
+
782
+ <ResourceCountDisplay summary={diffSummary} />
783
+
784
+ {displayedDiffLocations.length > 0 && (
785
+ <VStack
786
+ data-zui-native-wheel="true"
787
+ align="stretch"
788
+ spacing={1}
789
+ maxH="180px"
790
+ overflowY="auto"
791
+ borderTop="1px solid"
792
+ borderColor="whiteAlpha.100"
793
+ pt={3}
794
+ sx={{ overscrollBehavior: 'contain', WebkitOverflowScrolling: 'touch', touchAction: 'pan-y' }}
795
+ >
796
+ {displayedDiffLocations.map((target) => (
797
+ <Button
798
+ key={target.key}
799
+ variant="ghost"
800
+ size="sm"
801
+ h="auto"
802
+ minH="32px"
803
+ justifyContent="flex-start"
804
+ px={3}
805
+ py={1.5}
806
+ fontSize="12px"
807
+ color={activeDiffLocationKey === target.key ? 'white' : 'gray.200'}
808
+ bg={activeDiffLocationKey === target.key ? 'whiteAlpha.100' : 'transparent'}
809
+ onClick={() => navigateToDiffLocation(target)}
810
+ >
811
+ <HStack w="full" spacing={3} minW={0}>
812
+ <Badge
813
+ colorScheme={target.changeType === 'added' ? 'green' : target.changeType === 'deleted' ? 'red' : 'yellow'}
814
+ fontSize="9px"
815
+ >
816
+ {target.resourceType}
817
+ </Badge>
818
+ <Box minW={0} flex={1} textAlign="left">
819
+ <Text noOfLines={1}>{target.summary || target.label}</Text>
820
+ <Text color="gray.500" noOfLines={1}>{target.viewName}</Text>
821
+ </Box>
822
+ {(target.addedLines > 0 || target.removedLines > 0) && (
823
+ <HStack spacing={1.5} flexShrink={0}>
824
+ {target.addedLines > 0 && <Text color="green.400">+{target.addedLines}</Text>}
825
+ {target.removedLines > 0 && <Text color="red.400">-{target.removedLines}</Text>}
826
+ </HStack>
827
+ )}
828
+ </HStack>
829
+ </Button>
830
+ ))}
831
+ </VStack>
832
+ )}
833
+ </VStack>
834
+ </Collapse>
835
+
836
+ {/* ── Runtime section (collapsible) ── */}
837
+ {showRuntimeSection && (
838
+ <Box borderTop="1px solid" borderColor="whiteAlpha.100">
839
+ <HStack
840
+ px={4}
841
+ py={3}
842
+ justify="space-between"
843
+ cursor="pointer"
844
+ onClick={() => setRuntimeOpen((v) => !v)}
845
+ _hover={{ bg: 'whiteAlpha.50' }}
846
+ transition="background 0.15s"
847
+ >
848
+ <HStack spacing={3} minW={0} flex={1}>
849
+ <Badge
850
+ bg={watchStatusColor === 'green' ? 'green.900' : 'whiteAlpha.200'}
851
+ color={watchStatusColor === 'green' ? 'green.200' : 'white'}
852
+ borderRadius="sm"
853
+ textTransform="none"
854
+ fontSize="10px"
855
+ px={1.5}
856
+ py={0.5}
857
+ >
858
+ {watchStatusLabel.toUpperCase()}
859
+ </Badge>
860
+ <Text fontSize="13px" fontWeight="500" color="gray.300" noOfLines={1}>{watchTitle}</Text>
861
+ {watchMode ? <Text fontSize="12px" color="gray.500" noOfLines={1}>{watchMode}</Text> : null}
862
+ </HStack>
863
+ <HStack spacing={1} onClick={(e) => e.stopPropagation()}>
864
+ {watchActive && (
865
+ <>
866
+ <Tooltip label={watchPaused ? 'Resume watch' : 'Pause watch'} placement="top">
867
+ <IconButton
868
+ aria-label={watchPaused ? 'Resume watch' : 'Pause watch'}
869
+ icon={watchPaused ? <RepeatIcon boxSize={3.5} /> : <PauseGlyph />}
870
+ size="sm"
871
+ variant="ghost"
872
+ color="gray.400"
873
+ _hover={{ color: 'white', bg: 'whiteAlpha.100' }}
874
+ onClick={() => sendControl(watchPaused ? 'watch.resume' : 'watch.pause')}
875
+ />
876
+ </Tooltip>
877
+ <Tooltip label="Stop watch" placement="top">
878
+ <IconButton
879
+ aria-label="Stop watch"
880
+ icon={<CloseIcon boxSize={2.5} />}
881
+ size="sm"
882
+ variant="ghost"
883
+ color="gray.400"
884
+ _hover={{ color: 'white', bg: 'whiteAlpha.100' }}
885
+ onClick={() => sendControl('watch.stop')}
886
+ />
887
+ </Tooltip>
888
+ </>
889
+ )}
890
+ <IconButton
891
+ aria-label={runtimeOpen ? 'Collapse runtime' : 'Expand runtime'}
892
+ icon={runtimeOpen ? <ChevronDownIcon boxSize={4} /> : <ChevronUpIcon boxSize={4} />}
893
+ size="sm"
894
+ variant="ghost"
895
+ color="gray.400"
896
+ _hover={{ color: 'white', bg: 'whiteAlpha.100' }}
897
+ onClick={() => setRuntimeOpen((v) => !v)}
898
+ />
899
+ </HStack>
900
+ </HStack>
901
+
902
+ <Collapse in={runtimeOpen} animateOpacity>
903
+ <VStack
904
+ data-zui-native-wheel="true"
905
+ align="stretch"
906
+ spacing={0}
907
+ maxH="180px"
908
+ overflowY="auto"
909
+ pb={2}
910
+ sx={{ overscrollBehavior: 'contain', WebkitOverflowScrolling: 'touch', touchAction: 'pan-y' }}
911
+ >
912
+ {watchLines.length === 0 ? (
913
+ <Text px={4} py={2} fontSize="13px" color="gray.500">Waiting for watch output…</Text>
914
+ ) : watchLines.map((line) => (
915
+ <HStack key={line.id} px={4} py={2} spacing={3} borderTop="1px solid" borderColor="whiteAlpha.50" align="flex-start">
916
+ <Text fontSize="12px" color="gray.500" fontFamily="mono" flexShrink={0} pt={0.5}>
917
+ {new Date(line.at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
918
+ </Text>
919
+ <Text
920
+ fontSize="13px"
921
+ color={line.tone === 'error' ? 'red.300' : line.tone === 'warning' ? 'yellow.300' : line.tone === 'success' ? 'green.300' : 'gray.400'}
922
+ noOfLines={2}
923
+ lineHeight="1.4"
924
+ >
925
+ {line.text}
926
+ </Text>
927
+ </HStack>
928
+ ))}
929
+ </VStack>
930
+ </Collapse>
931
+ </Box>
932
+ )}
933
+ </PopoverBody>
934
+ </PopoverContent>
935
+ </Portal>
936
+ </Popover>
937
+ )
938
+ }