@beyondwork/docx-react-component 1.0.18 → 1.0.20

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 (105) hide show
  1. package/README.md +8 -2
  2. package/package.json +24 -34
  3. package/src/api/README.md +5 -1
  4. package/src/api/public-types.ts +710 -4
  5. package/src/api/session-state.ts +60 -0
  6. package/src/core/commands/formatting-commands.ts +2 -1
  7. package/src/core/commands/image-commands.ts +147 -0
  8. package/src/core/commands/index.ts +19 -3
  9. package/src/core/commands/list-commands.ts +231 -36
  10. package/src/core/commands/paragraph-layout-commands.ts +339 -0
  11. package/src/core/commands/section-layout-commands.ts +680 -0
  12. package/src/core/commands/style-commands.ts +262 -0
  13. package/src/core/search/search-text.ts +357 -0
  14. package/src/core/selection/mapping.ts +41 -0
  15. package/src/core/state/editor-state.ts +4 -1
  16. package/src/index.ts +51 -0
  17. package/src/io/docx-session.ts +623 -56
  18. package/src/io/export/serialize-comments.ts +104 -34
  19. package/src/io/export/serialize-footnotes.ts +198 -1
  20. package/src/io/export/serialize-headers-footers.ts +203 -10
  21. package/src/io/export/serialize-main-document.ts +285 -8
  22. package/src/io/export/serialize-numbering.ts +28 -7
  23. package/src/io/export/split-review-boundaries.ts +181 -19
  24. package/src/io/normalize/normalize-text.ts +144 -32
  25. package/src/io/ooxml/highlight-colors.ts +39 -0
  26. package/src/io/ooxml/numbering-sentinels.ts +44 -0
  27. package/src/io/ooxml/parse-comments.ts +85 -19
  28. package/src/io/ooxml/parse-fields.ts +396 -0
  29. package/src/io/ooxml/parse-footnotes.ts +452 -22
  30. package/src/io/ooxml/parse-headers-footers.ts +657 -29
  31. package/src/io/ooxml/parse-inline-media.ts +30 -0
  32. package/src/io/ooxml/parse-main-document.ts +807 -20
  33. package/src/io/ooxml/parse-numbering.ts +7 -0
  34. package/src/io/ooxml/parse-revisions.ts +317 -38
  35. package/src/io/ooxml/parse-settings.ts +184 -0
  36. package/src/io/ooxml/parse-shapes.ts +25 -0
  37. package/src/io/ooxml/parse-styles.ts +463 -0
  38. package/src/io/ooxml/parse-theme.ts +32 -0
  39. package/src/legal/bookmarks.ts +44 -0
  40. package/src/legal/cross-references.ts +59 -1
  41. package/src/model/canonical-document.ts +250 -4
  42. package/src/model/cds-1.0.0.ts +13 -0
  43. package/src/model/snapshot.ts +87 -2
  44. package/src/review/store/revision-store.ts +6 -0
  45. package/src/review/store/revision-types.ts +1 -0
  46. package/src/runtime/document-layout.ts +332 -0
  47. package/src/runtime/document-navigation.ts +603 -0
  48. package/src/runtime/document-runtime.ts +1754 -78
  49. package/src/runtime/document-search.ts +145 -0
  50. package/src/runtime/numbering-prefix.ts +47 -26
  51. package/src/runtime/page-layout-estimation.ts +212 -0
  52. package/src/runtime/read-only-diagnostics-runtime.ts +9 -0
  53. package/src/runtime/session-capabilities.ts +35 -3
  54. package/src/runtime/story-context.ts +164 -0
  55. package/src/runtime/story-targeting.ts +162 -0
  56. package/src/runtime/surface-projection.ts +324 -36
  57. package/src/runtime/table-schema.ts +89 -7
  58. package/src/runtime/view-state.ts +477 -0
  59. package/src/runtime/workflow-markup.ts +349 -0
  60. package/src/ui/WordReviewEditor.tsx +2469 -1344
  61. package/src/ui/browser-export.ts +52 -0
  62. package/src/ui/editor-command-bag.ts +120 -0
  63. package/src/ui/editor-runtime-boundary.ts +1422 -0
  64. package/src/ui/editor-shell-view.tsx +134 -0
  65. package/src/ui/editor-surface-controller.tsx +51 -0
  66. package/src/ui/headless/preserve-editor-selection.ts +5 -0
  67. package/src/ui/headless/revision-decoration-model.ts +4 -4
  68. package/src/ui/headless/selection-helpers.ts +20 -0
  69. package/src/ui/headless/selection-toolbar-model.ts +22 -0
  70. package/src/ui/headless/use-editor-keyboard.ts +6 -1
  71. package/src/ui/runtime-snapshot-selectors.ts +197 -0
  72. package/src/ui-tailwind/chrome/tw-alert-banner.tsx +18 -2
  73. package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +129 -0
  74. package/src/ui-tailwind/chrome/tw-layout-panel.tsx +114 -0
  75. package/src/ui-tailwind/chrome/tw-object-context-toolbar.tsx +34 -0
  76. package/src/ui-tailwind/chrome/tw-page-ruler.tsx +386 -0
  77. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +150 -14
  78. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +128 -0
  79. package/src/ui-tailwind/editor-surface/perf-probe.ts +179 -0
  80. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +46 -7
  81. package/src/ui-tailwind/editor-surface/pm-contextual-ui.ts +31 -0
  82. package/src/ui-tailwind/editor-surface/pm-decorations.ts +35 -0
  83. package/src/ui-tailwind/editor-surface/pm-position-map.ts +3 -3
  84. package/src/ui-tailwind/editor-surface/pm-schema.ts +186 -13
  85. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +191 -68
  86. package/src/ui-tailwind/editor-surface/search-plugin.ts +19 -68
  87. package/src/ui-tailwind/editor-surface/surface-build-keys.ts +51 -0
  88. package/src/ui-tailwind/editor-surface/tw-inline-token.tsx +11 -0
  89. package/src/ui-tailwind/editor-surface/tw-opaque-block.tsx +7 -1
  90. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +528 -85
  91. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +0 -1
  92. package/src/ui-tailwind/index.ts +2 -1
  93. package/src/ui-tailwind/page-chrome-model.ts +27 -0
  94. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +277 -147
  95. package/src/ui-tailwind/review/tw-health-panel.tsx +31 -2
  96. package/src/ui-tailwind/review/tw-review-rail.tsx +8 -8
  97. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +15 -15
  98. package/src/ui-tailwind/theme/editor-theme.css +127 -0
  99. package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +4 -0
  100. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +829 -12
  101. package/src/ui-tailwind/tw-review-workspace.tsx +1238 -42
  102. package/src/validation/compatibility-engine.ts +119 -24
  103. package/src/validation/compatibility-report.ts +1 -0
  104. package/src/validation/diagnostics.ts +1 -0
  105. package/src/validation/docx-comment-proof.ts +707 -0
@@ -350,7 +350,6 @@ export class TableCellNodeView {
350
350
  */
351
351
  export const tableNodeViews: { [node: string]: NodeViewConstructor } = {
352
352
  table: (node: PMNode) => new TableNodeView(node),
353
- table_row: (node: PMNode) => new TableRowNodeView(node),
354
353
  table_cell: (node: PMNode) => new TableCellNodeView(node),
355
354
  table_header_cell: (node: PMNode) => new TableCellNodeView(node),
356
355
  };
@@ -16,8 +16,9 @@ export { TwRevisionSidebar } from "./review/tw-revision-sidebar";
16
16
  export { TwHealthPanel } from "./review/tw-health-panel";
17
17
 
18
18
  // Toolbar
19
- export { TwToolbar, type TwToolbarProps, type ViewMode } from "./toolbar/tw-toolbar";
19
+ export { TwToolbar, type TwToolbarProps } from "./toolbar/tw-toolbar";
20
20
  export { TwToolbarIconButton } from "./toolbar/tw-toolbar-icon-button";
21
+ export type { WorkspaceMode, ZoomLevel } from "../api/public-types";
21
22
 
22
23
  // Status
23
24
  export { TwStatusBar } from "./status/tw-status-bar";
@@ -0,0 +1,27 @@
1
+ import type {
2
+ DocumentNavigationSnapshot,
3
+ PageLayoutSnapshot,
4
+ SurfaceBlockSnapshot,
5
+ } from "../api/public-types.ts";
6
+
7
+ export interface LineMarker {
8
+ id: string;
9
+ label: string;
10
+ topPx: number;
11
+ }
12
+
13
+ export function computeLineMarkersIfEnabled(input: {
14
+ pageLayout: PageLayoutSnapshot | undefined;
15
+ surfaceBlocks: readonly SurfaceBlockSnapshot[];
16
+ pages: ReadonlyArray<DocumentNavigationSnapshot["pages"][number]>;
17
+ buildLineNumberMarkers: (
18
+ blocks: readonly SurfaceBlockSnapshot[],
19
+ pages: ReadonlyArray<DocumentNavigationSnapshot["pages"][number]>,
20
+ ) => LineMarker[];
21
+ }): LineMarker[] {
22
+ if (!input.pageLayout?.lineNumbering) {
23
+ return [];
24
+ }
25
+
26
+ return input.buildLineNumberMarkers(input.surfaceBlocks, input.pages);
27
+ }
@@ -1,5 +1,5 @@
1
- import React, { useRef, useState } from "react";
2
- import { Check, MessageSquarePlus } from "lucide-react";
1
+ import React, { useCallback, useEffect, useRef, useState } from "react";
2
+ import { Check, CornerDownRight, RotateCcw } from "lucide-react";
3
3
 
4
4
  import type { CommentSidebarSnapshot, CommentSidebarThreadSnapshot } from "../../api/public-types";
5
5
 
@@ -22,130 +22,218 @@ export function TwCommentSidebar(props: TwCommentSidebarProps) {
22
22
 
23
23
  return (
24
24
  <div className="outline-none">
25
- <p className="text-xs text-tertiary mb-3">
26
- {comments.openCommentIds.length} open · {comments.resolvedCommentIds.length} resolved · {comments.detachedCommentIds.length} detached
27
- </p>
25
+ <div className="mb-2 flex items-center gap-2 text-[10px] text-tertiary">
26
+ <span>{comments.openCommentIds.length} open</span>
27
+ <span className="text-border">·</span>
28
+ <span>{comments.resolvedCommentIds.length} resolved</span>
29
+ {comments.detachedCommentIds.length > 0 && (
30
+ <>
31
+ <span className="text-border">·</span>
32
+ <span>{comments.detachedCommentIds.length} detached</span>
33
+ </>
34
+ )}
35
+ </div>
28
36
  {comments.threads.length > 0 ? (
29
- <div className="space-y-1">
30
- {comments.threads.map((thread) => {
31
- const isActive = activeCommentId === thread.commentId;
32
- const leadEntry = thread.entries[0];
33
- const isOwnComment = currentUserId != null && leadEntry?.authorId === currentUserId;
34
- const canEdit = isOwnComment && thread.status === "open" && props.onEditBody != null;
35
-
36
- return (
37
- <div
38
- key={thread.commentId}
39
- role="button"
40
- tabIndex={0}
41
- className={`rounded-lg p-2.5 transition-colors cursor-pointer ${focusRingClass} ${isActive ? "bg-accent-soft" : "hover:bg-surface"}`}
42
- onClick={() => props.onOpenComment?.(thread)}
43
- onKeyDown={(event) => {
44
- if (event.key === "Enter" || event.key === " ") {
45
- event.preventDefault();
46
- props.onOpenComment?.(thread);
47
- }
48
- }}
49
- >
50
- <div className="flex items-start justify-between gap-2 mb-1">
51
- <span className="text-sm font-medium text-primary">{thread.createdBy}</span>
52
- <StatusBadge status={thread.status} />
53
- </div>
54
- <p className="text-xs text-tertiary mb-1">{thread.createdAt}</p>
55
- <p className="text-xs font-medium text-comment bg-comment-soft rounded px-1 py-0.5 inline-block mb-1.5">
56
- {thread.excerpt}
57
- </p>
37
+ <div className="space-y-1.5">
38
+ {comments.threads.map((thread) => (
39
+ <CommentThreadCard
40
+ key={thread.commentId}
41
+ thread={thread}
42
+ isActive={activeCommentId === thread.commentId}
43
+ currentUserId={currentUserId}
44
+ onOpenComment={props.onOpenComment}
45
+ onResolveComment={props.onResolveComment}
46
+ onReopenComment={props.onReopenComment}
47
+ onAddReply={props.onAddReply}
48
+ onEditBody={props.onEditBody}
49
+ />
50
+ ))}
51
+ </div>
52
+ ) : (
53
+ <div className="rounded-lg border border-dashed border-border bg-surface/60 px-2.5 py-3 text-[10px] leading-4 text-tertiary">
54
+ No comment threads yet. Select text and add one from the toolbar.
55
+ </div>
56
+ )}
57
+ </div>
58
+ );
59
+ }
58
60
 
59
- {/* Comment body — inline editable for own comments on open threads */}
60
- {leadEntry?.body ? (
61
- canEdit ? (
62
- <InlineEditableBody
63
- commentId={thread.commentId}
64
- body={leadEntry.body}
65
- onSave={(newBody) => props.onEditBody?.(thread.commentId, newBody)}
66
- />
67
- ) : (
68
- <p className="text-sm text-secondary leading-relaxed">{leadEntry.body}</p>
69
- )
70
- ) : null}
61
+ function CommentThreadCard(props: {
62
+ thread: CommentSidebarThreadSnapshot;
63
+ isActive: boolean;
64
+ currentUserId?: string;
65
+ onOpenComment?: (thread: CommentSidebarThreadSnapshot) => void;
66
+ onResolveComment?: (commentId: string) => void;
67
+ onReopenComment?: (commentId: string) => void;
68
+ onAddReply?: (commentId: string, body: string) => void;
69
+ onEditBody?: (commentId: string, body: string) => void;
70
+ }) {
71
+ const { thread, isActive } = props;
72
+ const leadEntry = thread.entries[0];
73
+ const isDraftThread = thread.status === "open" && thread.entryCount === 1 && isEmptyCommentBody(leadEntry?.body);
74
+ const isOwnComment = props.currentUserId != null && leadEntry?.authorId === props.currentUserId;
75
+ const canEdit = isOwnComment && thread.status === "open" && props.onEditBody != null;
76
+ const hasNoBody = isEmptyCommentBody(leadEntry?.body);
77
+ const showExcerpt = Boolean(thread.excerpt) && !isDraftThread && thread.excerpt !== "Empty thread";
71
78
 
72
- {/* Show reply entries */}
73
- {thread.entries.slice(1).map((entry) => (
74
- <div key={entry.entryId} className="mt-2 pl-2 border-l border-border">
75
- <p className="text-xs text-tertiary">{entry.authorId} · {entry.createdAt}</p>
76
- <p className="text-sm text-secondary leading-relaxed">{entry.body}</p>
77
- </div>
78
- ))}
79
+ const scrollRef = useCallback(
80
+ (node: HTMLDivElement | null) => {
81
+ if (node && isActive && typeof node.scrollIntoView === "function") {
82
+ node.scrollIntoView({ behavior: "smooth", block: "nearest" });
83
+ }
84
+ },
85
+ [isActive],
86
+ );
79
87
 
80
- {thread.entryCount > thread.entries.length ? (
81
- <p className="text-xs text-tertiary mt-1.5">
82
- +{thread.entryCount - thread.entries.length} more repl{thread.entryCount - thread.entries.length === 1 ? "y" : "ies"}
83
- </p>
84
- ) : null}
88
+ return (
89
+ <div
90
+ ref={scrollRef}
91
+ data-comment-thread-id={thread.commentId}
92
+ data-comment-thread-status={thread.status}
93
+ role="button"
94
+ tabIndex={0}
95
+ className={[
96
+ "cursor-pointer rounded-lg border px-2 py-1.5 transition-colors",
97
+ focusRingClass,
98
+ isActive
99
+ ? "border-accent/25 bg-accent-soft/35"
100
+ : "border-border bg-canvas hover:border-border-strong hover:bg-surface/70",
101
+ thread.status === "detached" ? "opacity-70" : "",
102
+ ].join(" ")}
103
+ onClick={() => props.onOpenComment?.(thread)}
104
+ onKeyDown={(event) => {
105
+ if (event.key === "Enter" || event.key === " ") {
106
+ event.preventDefault();
107
+ props.onOpenComment?.(thread);
108
+ }
109
+ }}
110
+ >
111
+ {/* Header row: avatar + author + date + status */}
112
+ <div className="mb-1 flex items-center gap-1.5">
113
+ <span className="inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-accent/10 text-[8px] font-semibold text-accent">
114
+ {thread.createdBy.charAt(0).toUpperCase()}
115
+ </span>
116
+ <span className="truncate text-[10px] font-medium text-primary">{thread.createdBy}</span>
117
+ <span data-comment-thread-created-at="true" className="text-[9px] text-tertiary">
118
+ {formatCommentDate(thread.createdAt)}
119
+ </span>
120
+ <span className="flex-1" />
121
+ {isDraftThread ? <StatusBadge label="draft" tone="draft" /> : null}
122
+ {thread.status === "resolved" ? <StatusBadge label="resolved" tone="resolved" /> : null}
123
+ {thread.status === "detached" ? <StatusBadge label="detached" tone="detached" /> : null}
124
+ </div>
85
125
 
86
- {thread.resolvedAt && thread.resolvedBy ? (
87
- <p className="text-xs text-tertiary mt-1">
88
- Resolved by {thread.resolvedBy} at {thread.resolvedAt}
89
- </p>
90
- ) : null}
126
+ {/* Excerpt anchored text from document */}
127
+ {showExcerpt ? (
128
+ <p className="mb-1 rounded-md border-l-2 border-comment/25 bg-comment-soft/30 px-2 py-1 text-[9px] leading-4 text-comment/80 italic whitespace-pre-wrap break-words line-clamp-2">
129
+ {thread.excerpt}
130
+ </p>
131
+ ) : null}
91
132
 
92
- <div className="flex gap-1.5 mt-2">
93
- {thread.status === "open" ? (
94
- <button
95
- type="button"
96
- className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs text-insert hover:bg-insert-soft transition-colors"
97
- onClick={(e) => {
98
- e.stopPropagation();
99
- props.onResolveComment?.(thread.commentId);
100
- }}
101
- >
102
- <Check className="h-3 w-3" /> Resolve
103
- </button>
104
- ) : thread.status === "resolved" ? (
105
- <button
106
- type="button"
107
- className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs text-secondary hover:bg-surface transition-colors"
108
- onClick={(e) => {
109
- e.stopPropagation();
110
- props.onReopenComment?.(thread.commentId);
111
- }}
112
- >
113
- Reopen
114
- </button>
115
- ) : (
116
- <span className="text-xs text-tertiary px-2 py-1">Detached</span>
117
- )}
118
- </div>
133
+ {/* Comment body */}
134
+ {canEdit && (isActive || hasNoBody) ? (
135
+ <InlineEditableBody
136
+ body={leadEntry?.body ?? ""}
137
+ autoFocus={isActive && hasNoBody}
138
+ onSave={(newBody) => props.onEditBody?.(thread.commentId, newBody)}
139
+ label={isDraftThread ? "New comment" : undefined}
140
+ />
141
+ ) : leadEntry?.body ? (
142
+ <p
143
+ className="text-[10px] leading-[1.1rem] text-secondary whitespace-pre-wrap break-words line-clamp-4"
144
+ data-comment-thread-body="true"
145
+ >
146
+ {leadEntry.body}
147
+ </p>
148
+ ) : canEdit ? (
149
+ <p
150
+ className="cursor-text text-[10px] italic text-tertiary"
151
+ onClick={(e) => {
152
+ e.stopPropagation();
153
+ props.onOpenComment?.(thread);
154
+ }}
155
+ >
156
+ New comment
157
+ </p>
158
+ ) : null}
119
159
 
120
- {/* Reply input only for open threads */}
121
- {thread.status === "open" && props.onAddReply ? (
122
- <ReplyInput commentId={thread.commentId} onAddReply={props.onAddReply} />
123
- ) : null}
124
- </div>
125
- );
126
- })}
160
+ {/* Reply entries (compact) */}
161
+ {thread.entries.slice(1).map((entry) => (
162
+ <div key={entry.entryId} className="mt-1.5 ml-4 border-l border-border/50 pl-2.5">
163
+ <div className="mb-0.5 flex items-center gap-1">
164
+ <span className="text-[9px] font-medium text-secondary">{entry.authorId}</span>
165
+ <span className="text-[9px] text-tertiary">{formatCommentDate(entry.createdAt)}</span>
166
+ </div>
167
+ <p
168
+ className="text-[10px] leading-4 text-secondary whitespace-pre-wrap break-words line-clamp-3"
169
+ data-comment-reply-body="true"
170
+ >
171
+ {entry.body}
172
+ </p>
127
173
  </div>
128
- ) : (
129
- <p className="text-xs text-tertiary py-4">
130
- Comment threads will appear here when the runtime loads them.
174
+ ))}
175
+
176
+ {thread.entryCount > thread.entries.length ? (
177
+ <p className="mt-1 text-[9px] text-tertiary">
178
+ +{thread.entryCount - thread.entries.length} more
131
179
  </p>
132
- )}
180
+ ) : null}
181
+
182
+ {/* Inline actions — compact, horizontal */}
183
+ <div className="mt-1 flex items-center gap-0.5">
184
+ {thread.status === "open" && (
185
+ <>
186
+ <button
187
+ type="button"
188
+ className="inline-flex items-center gap-0.5 rounded px-1 py-0.5 text-[9px] font-medium text-insert hover:bg-insert-soft transition-colors"
189
+ onClick={(e) => { e.stopPropagation(); props.onResolveComment?.(thread.commentId); }}
190
+ >
191
+ <Check className="h-2 w-2" /> Resolve
192
+ </button>
193
+ {props.onAddReply && (
194
+ <ReplyInput commentId={thread.commentId} onAddReply={props.onAddReply} />
195
+ )}
196
+ </>
197
+ )}
198
+ {thread.status === "resolved" && (
199
+ <button
200
+ type="button"
201
+ className="inline-flex items-center gap-0.5 rounded px-1 py-0.5 text-[9px] font-medium text-secondary hover:bg-surface transition-colors"
202
+ data-comment-thread-action="reopen"
203
+ onClick={(e) => { e.stopPropagation(); props.onReopenComment?.(thread.commentId); }}
204
+ >
205
+ <RotateCcw className="h-2 w-2" /> Reopen
206
+ </button>
207
+ )}
208
+ {thread.status === "detached" && (
209
+ <span className="text-[9px] text-comment">Detached</span>
210
+ )}
211
+ </div>
133
212
  </div>
134
213
  );
135
214
  }
136
215
 
137
216
  function InlineEditableBody(props: {
138
- commentId: string;
139
217
  body: string;
218
+ autoFocus?: boolean;
219
+ label?: string;
140
220
  onSave: (newBody: string) => void;
141
221
  }) {
142
- const [isEditing, setIsEditing] = useState(false);
222
+ const [isEditing, setIsEditing] = useState(props.autoFocus || props.body === "");
143
223
  const [draft, setDraft] = useState(props.body);
224
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
225
+
226
+ useEffect(() => {
227
+ if (isEditing && textareaRef.current) {
228
+ textareaRef.current.focus();
229
+ textareaRef.current.setSelectionRange(draft.length, draft.length);
230
+ }
231
+ }, [isEditing]);
144
232
 
145
233
  if (!isEditing) {
146
234
  return (
147
235
  <p
148
- className="text-sm text-secondary leading-relaxed cursor-text rounded px-1 -mx-1 hover:bg-surface transition-colors"
236
+ className={`cursor-text rounded px-1 text-[10px] leading-[1.15rem] -mx-1 transition-colors hover:bg-surface ${props.body ? "text-secondary" : "text-tertiary italic"}`}
149
237
  onClick={(e) => {
150
238
  e.stopPropagation();
151
239
  setDraft(props.body);
@@ -153,68 +241,84 @@ function InlineEditableBody(props: {
153
241
  }}
154
242
  title="Click to edit"
155
243
  >
156
- {props.body}
244
+ {props.body || "Click to add comment\u2026"}
157
245
  </p>
158
246
  );
159
247
  }
160
248
 
161
249
  return (
162
- <textarea
163
- className="w-full text-sm text-primary leading-relaxed bg-surface rounded-md border border-border px-2 py-1.5 resize-none focus:outline-none focus:ring-1 focus:ring-accent"
164
- rows={Math.max(2, props.body.split("\n").length)}
165
- value={draft}
166
- autoFocus
167
- onClick={(e) => e.stopPropagation()}
168
- onChange={(e) => setDraft(e.target.value)}
169
- onBlur={() => {
170
- if (draft.trim() && draft.trim() !== props.body) {
171
- props.onSave(draft.trim());
172
- }
173
- setIsEditing(false);
174
- }}
175
- onKeyDown={(e) => {
176
- if (e.key === "Enter" && !e.shiftKey) {
177
- e.preventDefault();
250
+ <div className="space-y-1">
251
+ {props.label ? (
252
+ <span className="block text-[10px] font-medium uppercase tracking-[0.08em] text-tertiary">
253
+ {props.label}
254
+ </span>
255
+ ) : null}
256
+ <textarea
257
+ ref={textareaRef}
258
+ className="w-full resize-none rounded-md border border-border bg-surface px-2 py-1.5 text-[10px] leading-4 text-primary placeholder:text-tertiary focus:outline-none focus:ring-1 focus:ring-accent"
259
+ rows={2}
260
+ value={draft}
261
+ placeholder="Type your comment..."
262
+ onClick={(e) => e.stopPropagation()}
263
+ onChange={(e) => setDraft(e.target.value)}
264
+ onBlur={() => {
178
265
  if (draft.trim() && draft.trim() !== props.body) {
179
266
  props.onSave(draft.trim());
180
267
  }
181
268
  setIsEditing(false);
182
- }
183
- if (e.key === "Escape") {
184
- setDraft(props.body);
185
- setIsEditing(false);
186
- }
187
- e.stopPropagation();
188
- }}
189
- />
269
+ }}
270
+ onKeyDown={(e) => {
271
+ if (e.key === "Enter" && !e.shiftKey) {
272
+ e.preventDefault();
273
+ if (draft.trim() && draft.trim() !== props.body) {
274
+ props.onSave(draft.trim());
275
+ }
276
+ setIsEditing(false);
277
+ }
278
+ if (e.key === "Escape") {
279
+ setDraft(props.body);
280
+ setIsEditing(false);
281
+ }
282
+ e.stopPropagation();
283
+ }}
284
+ />
285
+ </div>
190
286
  );
191
287
  }
192
288
 
193
289
  function ReplyInput(props: { commentId: string; onAddReply: (commentId: string, body: string) => void }) {
194
290
  const [body, setBody] = useState("");
195
291
  const [isOpen, setIsOpen] = useState(false);
292
+ const inputRef = useRef<HTMLTextAreaElement>(null);
293
+
294
+ useEffect(() => {
295
+ if (isOpen && inputRef.current) {
296
+ inputRef.current.focus();
297
+ }
298
+ }, [isOpen]);
196
299
 
197
300
  if (!isOpen) {
198
301
  return (
199
302
  <button
200
303
  type="button"
201
- className="inline-flex items-center gap-1 text-xs text-tertiary hover:text-secondary transition-colors mt-1"
304
+ className="inline-flex items-center gap-0.5 rounded px-1.5 py-0.5 text-[10px] font-medium text-tertiary hover:text-secondary hover:bg-surface transition-colors"
202
305
  onClick={(e) => {
203
306
  e.stopPropagation();
204
307
  setIsOpen(true);
205
308
  }}
206
309
  >
207
- <MessageSquarePlus className="h-3 w-3" /> Reply
310
+ <CornerDownRight className="h-2.5 w-2.5" /> Reply
208
311
  </button>
209
312
  );
210
313
  }
211
314
 
212
315
  return (
213
- <div className="mt-2" onClick={(e) => e.stopPropagation()}>
316
+ <div className="w-full mt-1.5" onClick={(e) => e.stopPropagation()}>
214
317
  <textarea
215
- className="w-full rounded-md border border-border bg-surface px-2 py-1.5 text-xs text-primary placeholder:text-tertiary resize-none focus:outline-none focus:ring-1 focus:ring-accent"
318
+ ref={inputRef}
319
+ className="w-full rounded border border-border bg-surface px-2 py-1 text-[11px] text-primary placeholder:text-tertiary resize-none focus:outline-none focus:ring-1 focus:ring-accent"
216
320
  rows={2}
217
- placeholder="Write a reply..."
321
+ placeholder="Reply..."
218
322
  value={body}
219
323
  onChange={(e) => setBody(e.target.value)}
220
324
  onKeyDown={(e) => {
@@ -230,13 +334,12 @@ function ReplyInput(props: { commentId: string; onAddReply: (commentId: string,
230
334
  }
231
335
  e.stopPropagation();
232
336
  }}
233
- autoFocus
234
337
  />
235
- <div className="flex gap-1.5 mt-1">
338
+ <div className="flex gap-1 mt-0.5">
236
339
  <button
237
340
  type="button"
238
341
  disabled={!body.trim()}
239
- className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs text-accent hover:bg-accent-soft transition-colors disabled:opacity-40"
342
+ className="rounded px-1.5 py-0.5 text-[10px] font-medium text-accent hover:bg-accent-soft transition-colors disabled:opacity-40"
240
343
  onClick={() => {
241
344
  if (body.trim()) {
242
345
  props.onAddReply(props.commentId, body.trim());
@@ -245,15 +348,12 @@ function ReplyInput(props: { commentId: string; onAddReply: (commentId: string,
245
348
  }
246
349
  }}
247
350
  >
248
- Reply
351
+ Send
249
352
  </button>
250
353
  <button
251
354
  type="button"
252
- className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs text-tertiary hover:bg-surface transition-colors"
253
- onClick={() => {
254
- setBody("");
255
- setIsOpen(false);
256
- }}
355
+ className="rounded px-1.5 py-0.5 text-[10px] text-tertiary hover:bg-surface transition-colors"
356
+ onClick={() => { setBody(""); setIsOpen(false); }}
257
357
  >
258
358
  Cancel
259
359
  </button>
@@ -262,15 +362,45 @@ function ReplyInput(props: { commentId: string; onAddReply: (commentId: string,
262
362
  );
263
363
  }
264
364
 
265
- function StatusBadge(props: { status: string }) {
365
+ function formatCommentDate(raw: string): string {
366
+ try {
367
+ const date = new Date(raw);
368
+ if (Number.isNaN(date.getTime())) return raw;
369
+ const now = new Date();
370
+ const diffMs = now.getTime() - date.getTime();
371
+ const diffMin = Math.floor(diffMs / 60000);
372
+ if (diffMin < 1) return "just now";
373
+ if (diffMin < 60) return `${diffMin}m ago`;
374
+ const diffHours = Math.floor(diffMin / 60);
375
+ if (diffHours < 24) return `${diffHours}h ago`;
376
+ const diffDays = Math.floor(diffHours / 24);
377
+ if (diffDays < 7) return `${diffDays}d ago`;
378
+ return new Intl.DateTimeFormat("en-US", {
379
+ month: "short",
380
+ day: "numeric",
381
+ year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
382
+ }).format(date);
383
+ } catch {
384
+ return raw;
385
+ }
386
+ }
387
+
388
+ function StatusBadge(props: { label: string; tone: "resolved" | "detached" | "draft" }) {
266
389
  const styles: Record<string, string> = {
267
- open: "text-accent bg-accent-soft",
268
390
  resolved: "text-insert bg-insert-soft",
269
391
  detached: "text-comment bg-warning-soft",
392
+ draft: "text-secondary bg-subtle",
270
393
  };
271
394
  return (
272
- <span className={`inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium ${styles[props.status] ?? "text-secondary bg-subtle"}`}>
273
- {props.status}
395
+ <span
396
+ className={`shrink-0 rounded px-1 py-px text-[8px] font-medium uppercase tracking-[0.08em] ${styles[props.tone] ?? "text-secondary bg-subtle"}`}
397
+ data-comment-thread-badge={props.tone}
398
+ >
399
+ {props.label}
274
400
  </span>
275
401
  );
276
402
  }
403
+
404
+ function isEmptyCommentBody(body: string | undefined): boolean {
405
+ return !body || body.trim() === "";
406
+ }
@@ -5,15 +5,17 @@ import type {
5
5
  CompatibilityFeatureEntry,
6
6
  CompatibilityPanelSnapshot,
7
7
  EditorWarning,
8
+ WorkflowBlockedCommandReason,
8
9
  } from "../../api/public-types";
9
10
 
10
11
  export interface TwHealthPanelProps {
11
12
  compatibility: CompatibilityPanelSnapshot;
12
13
  warnings: EditorWarning[];
14
+ blockedReasons?: WorkflowBlockedCommandReason[];
13
15
  }
14
16
 
15
17
  export function TwHealthPanel(props: TwHealthPanelProps) {
16
- const { compatibility, warnings } = props;
18
+ const { compatibility, warnings, blockedReasons = [] } = props;
17
19
  const supportedCount = compatibility.featureEntries.filter(
18
20
  (e) => e.featureClass === "supported-roundtrip",
19
21
  ).length;
@@ -80,7 +82,34 @@ export function TwHealthPanel(props: TwHealthPanelProps) {
80
82
  </div>
81
83
  ))}
82
84
 
83
- {compatibility.featureEntries.length === 0 && warnings.length === 0 ? (
85
+ {blockedReasons.length > 0 ? (
86
+ <>
87
+ <div className="border-t border-border mt-2 pt-2">
88
+ <p className="text-xs font-medium text-tertiary mb-1">Workflow blocked reasons</p>
89
+ </div>
90
+ {blockedReasons.map((reason, index) => (
91
+ <div key={`blocked-${index}`} className="flex rounded-lg transition-colors hover:bg-surface">
92
+ <div className="w-0.5 shrink-0 rounded-l-lg bg-amber-400" />
93
+ <div className="flex items-start gap-2 p-2.5 flex-1">
94
+ <ShieldAlert className="h-4 w-4 text-amber-500 shrink-0 mt-0.5" />
95
+ <div className="flex-1 min-w-0">
96
+ <div className="flex items-start justify-between gap-2">
97
+ <span className="text-sm font-medium text-primary">{reason.message}</span>
98
+ <span className="inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium text-amber-700 bg-amber-100">
99
+ {reason.code.replace(/_/g, " ")}
100
+ </span>
101
+ </div>
102
+ {reason.scopeId ? (
103
+ <p className="text-xs text-tertiary mt-0.5">scope: {reason.scopeId}</p>
104
+ ) : null}
105
+ </div>
106
+ </div>
107
+ </div>
108
+ ))}
109
+ </>
110
+ ) : null}
111
+
112
+ {compatibility.featureEntries.length === 0 && warnings.length === 0 && blockedReasons.length === 0 ? (
84
113
  <p className="text-xs text-tertiary py-4">
85
114
  No compatibility entries or warnings to display.
86
115
  </p>