hale-commenting-system 2.1.1 → 2.2.1

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 (99) hide show
  1. package/.claude/settings.local.json +7 -0
  2. package/.editorconfig +17 -0
  3. package/.eslintrc.js +75 -0
  4. package/.github/ISSUE_TEMPLATE/bug_report.md +23 -0
  5. package/.github/workflows/ci.yaml +51 -0
  6. package/.prettierignore +1 -0
  7. package/.prettierrc +4 -0
  8. package/GITHUB_OAUTH_ENV_TEMPLATE.md +53 -0
  9. package/LICENSE +21 -0
  10. package/README.md +92 -21
  11. package/package.json +74 -50
  12. package/scripts/README.md +42 -0
  13. package/scripts/integrate.js +440 -0
  14. package/src/app/AppLayout/AppLayout.tsx +248 -0
  15. package/src/app/Comments/Comments.tsx +273 -0
  16. package/src/app/Dashboard/Dashboard.tsx +10 -0
  17. package/src/app/NotFound/NotFound.tsx +35 -0
  18. package/src/app/Settings/General/GeneralSettings.tsx +16 -0
  19. package/src/app/Settings/Profile/ProfileSettings.tsx +18 -0
  20. package/src/app/Support/Support.tsx +50 -0
  21. package/src/app/__snapshots__/app.test.tsx.snap +524 -0
  22. package/src/app/app.css +11 -0
  23. package/src/app/app.test.tsx +55 -0
  24. package/src/app/bgimages/Patternfly-Logo.svg +28 -0
  25. package/src/app/commenting-system/components/CommentOverlay.tsx +93 -0
  26. package/src/app/commenting-system/components/CommentPanel.tsx +534 -0
  27. package/src/app/commenting-system/components/CommentPin.tsx +60 -0
  28. package/src/app/commenting-system/components/DetailsTab.tsx +516 -0
  29. package/src/app/commenting-system/components/FloatingWidget.tsx +130 -0
  30. package/src/app/commenting-system/components/JiraTab.tsx +696 -0
  31. package/src/app/commenting-system/contexts/CommentContext.tsx +1033 -0
  32. package/src/app/commenting-system/contexts/GitHubAuthContext.tsx +84 -0
  33. package/{dist/index.d.ts → src/app/commenting-system/index.ts} +5 -4
  34. package/src/app/commenting-system/services/githubAdapter.ts +359 -0
  35. package/src/app/commenting-system/types/index.ts +27 -0
  36. package/src/app/commenting-system/utils/version.ts +19 -0
  37. package/src/app/index.tsx +22 -0
  38. package/src/app/routes.tsx +81 -0
  39. package/src/app/utils/useDocumentTitle.ts +13 -0
  40. package/src/favicon.png +0 -0
  41. package/src/index.html +18 -0
  42. package/src/index.tsx +25 -0
  43. package/src/test/setup.ts +33 -0
  44. package/src/typings.d.ts +12 -0
  45. package/stylePaths.js +14 -0
  46. package/tsconfig.json +34 -0
  47. package/vitest.config.ts +19 -0
  48. package/webpack.common.js +139 -0
  49. package/webpack.dev.js +318 -0
  50. package/webpack.prod.js +38 -0
  51. package/bin/detect.d.ts +0 -10
  52. package/bin/detect.js +0 -134
  53. package/bin/generators.d.ts +0 -18
  54. package/bin/generators.js +0 -193
  55. package/bin/hale-commenting.js +0 -4
  56. package/bin/index.d.ts +0 -2
  57. package/bin/index.js +0 -61
  58. package/bin/onboarding.d.ts +0 -1
  59. package/bin/onboarding.js +0 -349
  60. package/bin/postinstall.d.ts +0 -2
  61. package/bin/postinstall.js +0 -65
  62. package/bin/validators.d.ts +0 -2
  63. package/bin/validators.js +0 -66
  64. package/dist/cli/detect.d.ts +0 -10
  65. package/dist/cli/detect.js +0 -134
  66. package/dist/cli/generators.d.ts +0 -18
  67. package/dist/cli/generators.js +0 -193
  68. package/dist/cli/index.d.ts +0 -2
  69. package/dist/cli/index.js +0 -61
  70. package/dist/cli/onboarding.d.ts +0 -1
  71. package/dist/cli/onboarding.js +0 -349
  72. package/dist/cli/postinstall.d.ts +0 -2
  73. package/dist/cli/postinstall.js +0 -65
  74. package/dist/cli/validators.d.ts +0 -2
  75. package/dist/cli/validators.js +0 -66
  76. package/dist/components/CommentOverlay.d.ts +0 -2
  77. package/dist/components/CommentOverlay.js +0 -101
  78. package/dist/components/CommentPanel.d.ts +0 -6
  79. package/dist/components/CommentPanel.js +0 -334
  80. package/dist/components/CommentPin.d.ts +0 -11
  81. package/dist/components/CommentPin.js +0 -64
  82. package/dist/components/DetailsTab.d.ts +0 -2
  83. package/dist/components/DetailsTab.js +0 -380
  84. package/dist/components/FloatingWidget.d.ts +0 -8
  85. package/dist/components/FloatingWidget.js +0 -128
  86. package/dist/components/JiraTab.d.ts +0 -2
  87. package/dist/components/JiraTab.js +0 -507
  88. package/dist/contexts/CommentContext.d.ts +0 -30
  89. package/dist/contexts/CommentContext.js +0 -891
  90. package/dist/contexts/GitHubAuthContext.d.ts +0 -13
  91. package/dist/contexts/GitHubAuthContext.js +0 -96
  92. package/dist/index.js +0 -27
  93. package/dist/services/githubAdapter.d.ts +0 -56
  94. package/dist/services/githubAdapter.js +0 -321
  95. package/dist/types/index.d.ts +0 -25
  96. package/dist/types/index.js +0 -2
  97. package/dist/utils/version.d.ts +0 -1
  98. package/dist/utils/version.js +0 -23
  99. package/templates/webpack-middleware.js +0 -226
@@ -0,0 +1,93 @@
1
+ import * as React from 'react';
2
+ import { useLocation } from 'react-router-dom';
3
+ import { useComments } from '../contexts/CommentContext';
4
+ import { CommentPin } from './CommentPin';
5
+ import { getVersionFromPathOrQuery } from '../utils/version';
6
+
7
+ export const CommentOverlay: React.FunctionComponent = () => {
8
+ const location = useLocation();
9
+ const { commentsEnabled, addThread, selectedThreadId, setSelectedThreadId, syncFromGitHub, getThreadsForRoute } = useComments();
10
+ const detectedVersion = getVersionFromPathOrQuery(location.pathname, location.search);
11
+ const overlayRef = React.useRef<HTMLDivElement>(null);
12
+
13
+ // Show both open and closed threads as pins (GitHub-style: closed issues still exist)
14
+ const currentThreads = getThreadsForRoute(location.pathname, detectedVersion);
15
+
16
+ const handlePageClick = (e: MouseEvent) => {
17
+ if (!commentsEnabled) return;
18
+
19
+ // Check if clicking on a pin or any interactive element
20
+ const target = e.target as HTMLElement;
21
+ if (
22
+ target.closest('button') ||
23
+ target.closest('a') ||
24
+ target.closest('input') ||
25
+ target.closest('select') ||
26
+ target.closest('textarea') ||
27
+ target.closest('[role="button"]') ||
28
+ target.closest('[data-comment-controls]') ||
29
+ target.closest('[data-comment-pin]')
30
+ ) {
31
+ return; // Don't create pin if clicking interactive elements
32
+ }
33
+
34
+ // Get the overlay container dimensions (accounts for drawer being open)
35
+ if (!overlayRef.current) return;
36
+ const rect = overlayRef.current.getBoundingClientRect();
37
+
38
+ // Calculate percentage based on the content area, not the full window
39
+ const xPercent = ((e.clientX - rect.left) / rect.width) * 100;
40
+ const yPercent = ((e.clientY - rect.top) / rect.height) * 100;
41
+
42
+ const threadId = addThread(xPercent, yPercent, location.pathname, detectedVersion);
43
+ setSelectedThreadId(threadId);
44
+ };
45
+
46
+ React.useEffect(() => {
47
+ console.log('🔄 CommentOverlay useEffect triggered', { commentsEnabled, pathname: location.pathname, detectedVersion });
48
+
49
+ if (commentsEnabled) {
50
+ document.addEventListener('click', handlePageClick);
51
+ // Pull latest changes from GitHub when entering comment mode or switching routes
52
+ console.log('🔄 CommentOverlay calling syncFromGitHub...');
53
+ syncFromGitHub(location.pathname, detectedVersion).catch(() => undefined);
54
+ }
55
+
56
+ return () => {
57
+ document.removeEventListener('click', handlePageClick);
58
+ };
59
+ // eslint-disable-next-line react-hooks/exhaustive-deps
60
+ }, [commentsEnabled, location.pathname, detectedVersion]);
61
+
62
+ // Only show pins when commenting is enabled
63
+ if (!commentsEnabled) {
64
+ return null;
65
+ }
66
+
67
+ return (
68
+ <div
69
+ ref={overlayRef}
70
+ style={{
71
+ position: 'absolute',
72
+ top: 0,
73
+ left: 0,
74
+ width: '100%',
75
+ height: '100%',
76
+ pointerEvents: 'none',
77
+ zIndex: 999,
78
+ }}
79
+ >
80
+ {currentThreads.map((thread) => (
81
+ <CommentPin
82
+ key={thread.id}
83
+ xPercent={thread.xPercent}
84
+ yPercent={thread.yPercent}
85
+ commentCount={thread.comments.length}
86
+ isClosed={thread.status === 'closed'}
87
+ isSelected={selectedThreadId === thread.id}
88
+ onClick={() => setSelectedThreadId(thread.id)}
89
+ />
90
+ ))}
91
+ </div>
92
+ );
93
+ };
@@ -0,0 +1,534 @@
1
+ import * as React from 'react';
2
+ import { useLocation } from 'react-router-dom';
3
+ import {
4
+ ActionList,
5
+ ActionListItem,
6
+ Button,
7
+ Card,
8
+ CardBody,
9
+ Drawer,
10
+ DrawerActions,
11
+ DrawerCloseButton,
12
+ DrawerContent,
13
+ DrawerContentBody,
14
+ DrawerHead,
15
+ DrawerPanelBody,
16
+ DrawerPanelContent,
17
+ EmptyState,
18
+ EmptyStateBody,
19
+ Label,
20
+ Spinner,
21
+ Tab,
22
+ TabTitleText,
23
+ Tabs,
24
+ TextArea,
25
+ Title,
26
+ } from '@patternfly/react-core';
27
+ import { ExternalLinkAltIcon, GithubIcon, InfoCircleIcon, TrashIcon } from '@patternfly/react-icons';
28
+ import { useComments } from '../contexts/CommentContext';
29
+ import { DetailsTab } from './DetailsTab';
30
+ import { JiraTab } from './JiraTab';
31
+ import { FloatingWidget } from './FloatingWidget';
32
+ import { getVersionFromPathOrQuery } from '../utils/version';
33
+
34
+ interface CommentPanelProps {
35
+ children: React.ReactNode;
36
+ }
37
+
38
+ export const CommentPanel: React.FunctionComponent<CommentPanelProps> = ({ children }) => {
39
+ const {
40
+ getThreadsForRoute,
41
+ selectedThreadId,
42
+ setSelectedThreadId,
43
+ drawerPinnedOpen,
44
+ setDrawerPinnedOpen,
45
+ floatingWidgetMode,
46
+ setFloatingWidgetMode,
47
+ addReply,
48
+ updateComment,
49
+ deleteComment,
50
+ closeThread,
51
+ reopenThread,
52
+ removePin,
53
+ retrySync,
54
+ } = useComments();
55
+ const location = useLocation();
56
+ const detectedVersion = getVersionFromPathOrQuery(location.pathname, location.search);
57
+ const [newCommentText, setNewCommentText] = React.useState('');
58
+ const [replyingToCommentId, setReplyingToCommentId] = React.useState<string | null>(null);
59
+ const [replyTextByCommentId, setReplyTextByCommentId] = React.useState<Record<string, string>>({});
60
+ const [editingCommentId, setEditingCommentId] = React.useState<string | null>(null);
61
+ const [editText, setEditText] = React.useState('');
62
+ const drawerRef = React.useRef<HTMLSpanElement>(null);
63
+ const [activeTabKey, setActiveTabKey] = React.useState<string | number>('comments');
64
+
65
+ const currentThreads = getThreadsForRoute(location.pathname, detectedVersion);
66
+ const selectedThread = currentThreads.find((t) => t.id === selectedThreadId);
67
+ const isExpanded = !!selectedThreadId || drawerPinnedOpen || floatingWidgetMode;
68
+
69
+ const onExpand = () => {
70
+ drawerRef.current && drawerRef.current.focus();
71
+ };
72
+
73
+ React.useEffect(() => {
74
+ if (selectedThreadId) {
75
+ setActiveTabKey('comments');
76
+ }
77
+ }, [selectedThreadId]);
78
+
79
+ React.useEffect(() => {
80
+ if (drawerPinnedOpen && !selectedThreadId) {
81
+ setActiveTabKey('details');
82
+ }
83
+ }, [drawerPinnedOpen, selectedThreadId]);
84
+
85
+ const handleAddComment = () => {
86
+ if (newCommentText.trim() && selectedThread) {
87
+ addReply(selectedThread.id, newCommentText.trim());
88
+ setNewCommentText('');
89
+ }
90
+ };
91
+
92
+ const handleStartReply = (commentId: string) => {
93
+ setReplyingToCommentId(commentId);
94
+ setReplyTextByCommentId((prev) => ({ ...prev, [commentId]: prev[commentId] ?? '' }));
95
+ };
96
+
97
+ const handleCancelReply = () => {
98
+ setReplyingToCommentId(null);
99
+ };
100
+
101
+ const handleSubmitReply = (parentCommentId: string) => {
102
+ if (!selectedThread) return;
103
+ const text = (replyTextByCommentId[parentCommentId] || '').trim();
104
+ if (!text) return;
105
+ addReply(selectedThread.id, text, parentCommentId);
106
+ setReplyTextByCommentId((prev) => ({ ...prev, [parentCommentId]: '' }));
107
+ setReplyingToCommentId(null);
108
+ };
109
+
110
+ const handleStartEdit = (commentId: string, currentText: string) => {
111
+ setEditingCommentId(commentId);
112
+ setEditText(currentText);
113
+ };
114
+
115
+ const handleSaveEdit = (commentId: string) => {
116
+ if (editText.trim() && selectedThread) {
117
+ updateComment(selectedThread.id, commentId, editText.trim());
118
+ setEditingCommentId(null);
119
+ setEditText('');
120
+ }
121
+ };
122
+
123
+ const handleCancelEdit = () => {
124
+ setEditingCommentId(null);
125
+ setEditText('');
126
+ };
127
+
128
+ const handleDeleteComment = (commentId: string) => {
129
+ if (selectedThread) {
130
+ deleteComment(selectedThread.id, commentId);
131
+ }
132
+ };
133
+
134
+ const handleCloseThread = () => {
135
+ if (selectedThread) {
136
+ closeThread(selectedThread.id);
137
+ }
138
+ };
139
+
140
+ const handleReopenThread = () => {
141
+ if (selectedThread) {
142
+ reopenThread(selectedThread.id);
143
+ }
144
+ };
145
+
146
+ const handleRemovePin = () => {
147
+ if (!selectedThread) return;
148
+ removePin(selectedThread.id);
149
+ };
150
+
151
+ const handleClose = () => {
152
+ setSelectedThreadId(null);
153
+ if (floatingWidgetMode) {
154
+ setFloatingWidgetMode(false);
155
+ } else {
156
+ setDrawerPinnedOpen(false);
157
+ }
158
+ setEditingCommentId(null);
159
+ setEditText('');
160
+ setNewCommentText('');
161
+ setReplyingToCommentId(null);
162
+ setReplyTextByCommentId({});
163
+ };
164
+
165
+ const formatCommentDate = (isoDate: string): string => {
166
+ const date = new Date(isoDate);
167
+ return date.toLocaleString(undefined, {
168
+ month: 'short',
169
+ day: 'numeric',
170
+ hour: '2-digit',
171
+ minute: '2-digit',
172
+ });
173
+ };
174
+
175
+ const stripMarkersForDisplay = (text: string): string => {
176
+ return text
177
+ .replace(/<!--\s*hale-reply-to:\d+\s*-->\s*\n?/g, '')
178
+ .replace(/<!--\s*hale-reply-to-local\s*-->\s*\n?/g, '')
179
+ .trimEnd();
180
+ };
181
+
182
+ const deriveStatus = () => {
183
+ if (!selectedThread) return 'local' as const;
184
+ if (selectedThread.syncStatus === 'error') return 'error' as const;
185
+ // If we have an issue and any comment hasn't synced yet, treat as pending.
186
+ if (selectedThread.issueNumber && selectedThread.comments.some((c) => !c.githubCommentId)) return 'pending' as const;
187
+ if (selectedThread.issueNumber) return 'synced' as const;
188
+ return selectedThread.syncStatus || 'local';
189
+ };
190
+
191
+ const renderSyncLabel = (status?: 'synced' | 'local' | 'pending' | 'syncing' | 'error') => {
192
+ switch (status) {
193
+ case 'synced':
194
+ return (
195
+ <Label color="green" icon={<GithubIcon />}>
196
+ Synced
197
+ </Label>
198
+ );
199
+ case 'local':
200
+ return <Label color="grey">Local</Label>;
201
+ case 'pending':
202
+ return <Label color="blue">Pending…</Label>;
203
+ case 'syncing':
204
+ return (
205
+ <Label color="blue" icon={<Spinner size="sm" />}>
206
+ Syncing…
207
+ </Label>
208
+ );
209
+ case 'error':
210
+ return <Label color="red">Sync error</Label>;
211
+ default:
212
+ return null;
213
+ }
214
+ };
215
+
216
+ const panelContent = (
217
+ <>
218
+ <Tabs
219
+ activeKey={activeTabKey}
220
+ onSelect={(_event, tabKey) => setActiveTabKey(tabKey)}
221
+ aria-label="Hale Commenting System drawer tabs"
222
+ >
223
+ <Tab eventKey="details" title={<TabTitleText>Details</TabTitleText>}>
224
+ <div style={{ paddingTop: '1rem' }}>
225
+ <DetailsTab />
226
+ </div>
227
+ </Tab>
228
+ <Tab eventKey="jira" title={<TabTitleText>Jira</TabTitleText>}>
229
+ <div style={{ paddingTop: '1rem' }}>
230
+ <JiraTab />
231
+ </div>
232
+ </Tab>
233
+ <Tab eventKey="comments" title={<TabTitleText>Comments</TabTitleText>}>
234
+ <div style={{ paddingTop: '1rem' }}>
235
+ {!selectedThread ? (
236
+ <EmptyState icon={InfoCircleIcon} titleText="No pin selected" headingLevel="h3">
237
+ <EmptyStateBody>Select or create a comment pin to start a thread.</EmptyStateBody>
238
+ </EmptyState>
239
+ ) : (
240
+ <>
241
+ {/* Thread summary header (scaffold) */}
242
+ <Card style={{ marginBottom: '1rem' }}>
243
+ <CardBody>
244
+ <div style={{ display: 'grid', gap: '0.5rem' }}>
245
+ <div style={{ fontSize: '0.875rem' }}>
246
+ <strong>Location:</strong> ({selectedThread.xPercent.toFixed(1)}%, {selectedThread.yPercent.toFixed(1)}%)
247
+ </div>
248
+ <div style={{ fontSize: '0.875rem' }}>
249
+ <strong>Comments:</strong> {selectedThread.comments.length}
250
+ </div>
251
+ <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.875rem' }}>
252
+ <strong>Status:</strong>
253
+ {renderSyncLabel(deriveStatus()) ?? <Label color="grey">Local</Label>}
254
+ </div>
255
+
256
+ <div style={{ fontSize: '0.875rem' }}>
257
+ {selectedThread.issueNumber && selectedThread.issueUrl ? (
258
+ <a
259
+ href={selectedThread.issueUrl}
260
+ target="_blank"
261
+ rel="noopener noreferrer"
262
+ style={{ display: 'inline-flex', alignItems: 'center', gap: '0.25rem' }}
263
+ >
264
+ <GithubIcon />
265
+ Issue #{selectedThread.issueNumber}
266
+ <ExternalLinkAltIcon style={{ fontSize: '0.75rem' }} />
267
+ </a>
268
+ ) : (
269
+ <span style={{ display: 'inline-flex', alignItems: 'center', gap: '0.25rem', color: 'var(--pf-t--global--text--color--subtle)' }}>
270
+ <GithubIcon />
271
+ Issue pending…
272
+ </span>
273
+ )}
274
+ </div>
275
+
276
+ <div>
277
+ {/* AI summarize removed for now */}
278
+ </div>
279
+ </div>
280
+ </CardBody>
281
+ </Card>
282
+
283
+ {/* Comments List */}
284
+ {selectedThread.comments.length > 0 && (
285
+ <div style={{ marginBottom: '1.5rem' }}>
286
+ {(() => {
287
+ const comments = selectedThread.comments;
288
+
289
+ const byId = new Map(comments.map((c) => [c.id, c]));
290
+ const byGitHubId = new Map<number, string>();
291
+ for (const c of comments) {
292
+ if (c.githubCommentId) byGitHubId.set(c.githubCommentId, c.id);
293
+ }
294
+
295
+ const childrenByParent = new Map<string, string[]>();
296
+ const topLevel: string[] = [];
297
+
298
+ for (const c of comments) {
299
+ const parentLocal =
300
+ c.parentCommentId ||
301
+ (c.parentGitHubCommentId ? byGitHubId.get(c.parentGitHubCommentId) : undefined);
302
+
303
+ if (parentLocal && byId.has(parentLocal)) {
304
+ const list = childrenByParent.get(parentLocal) || [];
305
+ list.push(c.id);
306
+ childrenByParent.set(parentLocal, list);
307
+ } else {
308
+ topLevel.push(c.id);
309
+ }
310
+ }
311
+
312
+ const sortByCreatedAt = (aId: string, bId: string) => {
313
+ const a = byId.get(aId);
314
+ const b = byId.get(bId);
315
+ const at = a ? Date.parse(a.createdAt) : 0;
316
+ const bt = b ? Date.parse(b.createdAt) : 0;
317
+ return at - bt;
318
+ };
319
+
320
+ topLevel.sort(sortByCreatedAt);
321
+ childrenByParent.forEach((list, parentId) => {
322
+ list.sort(sortByCreatedAt);
323
+ childrenByParent.set(parentId, list);
324
+ });
325
+
326
+ const renderNode = (id: string, depth: number, topIndex?: number) => {
327
+ const comment = byId.get(id);
328
+ if (!comment) return null;
329
+
330
+ const isReply = depth > 0;
331
+ const title = isReply ? 'Reply' : `Comment #${(topIndex ?? 0) + 1}`;
332
+
333
+ const children = childrenByParent.get(id) || [];
334
+
335
+ return (
336
+ <div
337
+ key={id}
338
+ style={{
339
+ marginLeft: depth * 16,
340
+ marginTop: depth > 0 ? '8px' : undefined,
341
+ marginBottom: depth > 0 ? '8px' : '1rem',
342
+ }}
343
+ >
344
+ <Card>
345
+ <CardBody style={{ position: 'relative' }}>
346
+ <Button
347
+ variant="plain"
348
+ icon={<TrashIcon />}
349
+ isDanger
350
+ aria-label="Delete comment"
351
+ title="Delete comment"
352
+ onClick={() => handleDeleteComment(comment.id)}
353
+ style={{ position: 'absolute', top: '12px', right: '12px' }}
354
+ />
355
+ <Title headingLevel="h3" size={isReply ? 'lg' : 'xl'} style={{ paddingRight: '2.5rem' }}>
356
+ {title}
357
+ </Title>
358
+ <div
359
+ style={{
360
+ marginTop: '0.25rem',
361
+ fontSize: '0.875rem',
362
+ color: 'var(--pf-t--global--text--color--subtle)',
363
+ paddingRight: '2.5rem',
364
+ }}
365
+ >
366
+ @{comment.author ?? '—'} &nbsp; {formatCommentDate(comment.createdAt)}
367
+ </div>
368
+
369
+ {editingCommentId === comment.id ? (
370
+ <div style={{ marginTop: '0.5rem' }}>
371
+ <TextArea
372
+ value={editText}
373
+ onChange={(_event, value) => setEditText(value)}
374
+ aria-label="Edit comment"
375
+ rows={3}
376
+ />
377
+ <ActionList style={{ marginTop: '0.5rem' }}>
378
+ <ActionListItem>
379
+ <Button variant="primary" onClick={() => handleSaveEdit(comment.id)}>
380
+ Save
381
+ </Button>
382
+ </ActionListItem>
383
+ <ActionListItem>
384
+ <Button variant="link" onClick={handleCancelEdit}>
385
+ Cancel
386
+ </Button>
387
+ </ActionListItem>
388
+ </ActionList>
389
+ </div>
390
+ ) : (
391
+ <div>
392
+ <div style={{ marginTop: '0.75rem', whiteSpace: 'pre-wrap' }}>
393
+ {stripMarkersForDisplay(comment.text)}
394
+ </div>
395
+ <div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginTop: '0.5rem' }}>
396
+ <Button variant="primary" onClick={() => handleStartReply(comment.id)}>
397
+ Reply
398
+ </Button>
399
+ <Button variant="link" onClick={() => handleStartEdit(comment.id, stripMarkersForDisplay(comment.text))}>
400
+ Edit
401
+ </Button>
402
+ </div>
403
+ </div>
404
+ )}
405
+
406
+ {replyingToCommentId === comment.id && (
407
+ <div style={{ marginTop: '0.75rem' }}>
408
+ <Title headingLevel="h4" size="md" style={{ marginBottom: '0.5rem' }}>
409
+ Reply to this comment
410
+ </Title>
411
+ <TextArea
412
+ value={replyTextByCommentId[comment.id] || ''}
413
+ onChange={(_event, value) =>
414
+ setReplyTextByCommentId((prev) => ({ ...prev, [comment.id]: value }))
415
+ }
416
+ placeholder="Type your reply..."
417
+ aria-label="Reply to comment"
418
+ rows={3}
419
+ />
420
+ <div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginTop: '0.5rem' }}>
421
+ <Button
422
+ variant="primary"
423
+ onClick={() => handleSubmitReply(comment.id)}
424
+ isDisabled={!(replyTextByCommentId[comment.id] || '').trim()}
425
+ >
426
+ Post reply
427
+ </Button>
428
+ <Button variant="link" onClick={handleCancelReply}>
429
+ Cancel
430
+ </Button>
431
+ </div>
432
+ </div>
433
+ )}
434
+ </CardBody>
435
+ </Card>
436
+
437
+ {children.map((childId) => renderNode(childId, depth + 1))}
438
+ </div>
439
+ );
440
+ };
441
+
442
+ return <>{topLevel.map((id, idx) => renderNode(id, 0, idx))}</>;
443
+ })()}
444
+ </div>
445
+ )}
446
+
447
+ {/* Add New Comment */}
448
+ <div>
449
+ {selectedThread.status === 'closed' ? (
450
+ <div style={{ marginBottom: '1rem', padding: '1rem', backgroundColor: 'var(--pf-t--global--background--color--secondary--default)', borderRadius: 'var(--pf-t--global--border--radius--medium)' }}>
451
+ <Title headingLevel="h3" size="md" style={{ marginBottom: '0.5rem' }}>
452
+ 🔒 Thread Closed
453
+ </Title>
454
+ <p style={{ color: 'var(--pf-t--global--text--color--subtle)', marginBottom: '1rem' }}>
455
+ This thread has been closed and locked. Reopen it to add new comments.
456
+ </p>
457
+ <div style={{ display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' }}>
458
+ <Button variant="primary" onClick={handleReopenThread}>
459
+ Reopen Thread
460
+ </Button>
461
+ <Button variant="link" isDanger onClick={handleRemovePin}>
462
+ Remove pin
463
+ </Button>
464
+ </div>
465
+ </div>
466
+ ) : (
467
+ <>
468
+ <Title headingLevel="h3" size="md" style={{ marginBottom: '0.5rem' }}>
469
+ Add comment
470
+ </Title>
471
+ <TextArea
472
+ value={newCommentText}
473
+ onChange={(_event, value) => setNewCommentText(value)}
474
+ placeholder="Type your comment..."
475
+ aria-label="New comment"
476
+ rows={4}
477
+ />
478
+ <div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginTop: '1rem' }}>
479
+ <Button variant="primary" onClick={handleAddComment} isDisabled={!newCommentText.trim()}>
480
+ Add Comment
481
+ </Button>
482
+ <Button variant="secondary" onClick={handleCloseThread}>
483
+ Close Thread
484
+ </Button>
485
+ <Button variant="link" isDanger onClick={handleRemovePin}>
486
+ Remove pin
487
+ </Button>
488
+ </div>
489
+ </>
490
+ )}
491
+ </div>
492
+ </>
493
+ )}
494
+ </div>
495
+ </Tab>
496
+ </Tabs>
497
+ </>
498
+ );
499
+
500
+ if (floatingWidgetMode && isExpanded) {
501
+ return (
502
+ <>
503
+ <FloatingWidget onClose={handleClose} title="Hale Commenting System">
504
+ <div style={{ padding: '1rem' }}>{panelContent}</div>
505
+ </FloatingWidget>
506
+ <div style={{ position: 'relative' }}>{children}</div>
507
+ </>
508
+ );
509
+ }
510
+
511
+ const drawerPanelContent = isExpanded ? (
512
+ <DrawerPanelContent isResizable defaultSize={'500px'} minSize={'300px'}>
513
+ <DrawerHead>
514
+ <span tabIndex={isExpanded ? 0 : -1} ref={drawerRef}>
515
+ <Title headingLevel="h2" size="lg">
516
+ Hale Commenting System
517
+ </Title>
518
+ </span>
519
+ <DrawerActions>
520
+ <DrawerCloseButton onClick={handleClose} />
521
+ </DrawerActions>
522
+ </DrawerHead>
523
+ <DrawerPanelBody>{panelContent}</DrawerPanelBody>
524
+ </DrawerPanelContent>
525
+ ) : null;
526
+
527
+ return (
528
+ <Drawer isExpanded={isExpanded} isInline onExpand={onExpand}>
529
+ <DrawerContent panelContent={drawerPanelContent}>
530
+ <DrawerContentBody style={{ position: 'relative' }}>{children}</DrawerContentBody>
531
+ </DrawerContent>
532
+ </Drawer>
533
+ );
534
+ };
@@ -0,0 +1,60 @@
1
+ import * as React from 'react';
2
+ import { Button } from '@patternfly/react-core';
3
+ import { CommentIcon } from '@patternfly/react-icons';
4
+
5
+ interface CommentPinProps {
6
+ xPercent: number;
7
+ yPercent: number;
8
+ commentCount: number;
9
+ isClosed?: boolean;
10
+ isSelected: boolean;
11
+ onClick: () => void;
12
+ }
13
+
14
+ export const CommentPin: React.FunctionComponent<CommentPinProps> = ({
15
+ xPercent,
16
+ yPercent,
17
+ commentCount,
18
+ isClosed = false,
19
+ isSelected,
20
+ onClick,
21
+ }) => {
22
+ return (
23
+ <Button
24
+ variant="plain"
25
+ data-comment-pin
26
+ style={{
27
+ position: 'absolute',
28
+ left: `${xPercent}%`,
29
+ top: `${yPercent}%`,
30
+ transform: 'translate(-50%, -50%)',
31
+ width: '32px',
32
+ height: '32px',
33
+ borderRadius: '50%',
34
+ backgroundColor: isClosed ? 'var(--pf-t--global--icon--color--subtle)' : '#C9190B',
35
+ color: 'white',
36
+ border: isSelected ? '3px solid #0066CC' : '2px solid white',
37
+ boxShadow: isSelected
38
+ ? '0 0 0 3px rgba(0, 102, 204, 0.3), 0 2px 8px rgba(0,0,0,0.3)'
39
+ : '0 2px 8px rgba(0,0,0,0.3)',
40
+ cursor: 'pointer',
41
+ padding: 0,
42
+ display: 'flex',
43
+ alignItems: 'center',
44
+ justifyContent: 'center',
45
+ transition: 'all 0.2s ease',
46
+ pointerEvents: 'auto',
47
+ }}
48
+ onClick={onClick}
49
+ aria-label={`${isClosed ? 'Closed ' : ''}comment thread with ${commentCount} comment${commentCount !== 1 ? 's' : ''}`}
50
+ >
51
+ {commentCount === 0 ? (
52
+ <CommentIcon style={{ fontSize: '16px' }} />
53
+ ) : commentCount === 1 ? (
54
+ <CommentIcon style={{ fontSize: '16px' }} />
55
+ ) : (
56
+ <span style={{ fontSize: '14px', fontWeight: 'bold' }}>{commentCount}</span>
57
+ )}
58
+ </Button>
59
+ );
60
+ };