Split CommentThread.svelte (310 lines) — extract CommentMessage component
#198
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Context
CommentThread.svelteis 310 lines. The{#each flatMessages}loop body (lines 223-294) is ~70 lines of nested markup with conditional edit mode, quote extraction, avatar rendering, and delete button. That loop body is a distinct visual region: one comment message bubble.Proposed Split
CommentMessage.svelteExtract the per-message rendering:
message(FlatMessage),isOwn(boolean),onEdit,onDelete,editingId,editText,onEditTextChange,onEditKeydown,onCancelEditCommentThread.sveltebecomes simpler{#each}loop body becomes<CommentMessage ... />Acceptance Criteria
npm run checkpasses👨💻 Felix Brandt -- Senior Fullstack Developer
Good refactor target. The
{#each}loop body at lines 223-294 is exactly the kind of visual boundary that deserves its own component -- a single message bubble is a nameable UI region ("CommentMessage"), not a "Helper" or "Wrapper".Props interface concerns:
The proposed props list has 8 items:
message,isOwn,onEdit,onDelete,editingId,editText,onEditTextChange,onEditKeydown,onCancelEdit. That is above the 3-parameter guideline. Consider grouping the edit-related callbacks into aneditStateobject prop:This brings the prop count down to 5 (
message,isOwn,onEdit,onDelete,editState) and makes the edit concern cohesive.isOwncan be derived frommessageif the current user ID is available. Is it passed through context, or does the parent compute it? If the parent computes it, that is fine -- but document why it is not derived inside the child.Svelte 5 checklist for the extraction:
{#each}inCommentThreadmust keep its key expression(message.id)after the extraction -- verify this is preserved.$derivedvalues that depend on the loop variable need to move intoCommentMessageor become props. Which derived values exist in the current loop body?bind:value, that two-way binding needs to become a callback prop (onEditTextChange) as the issue suggests -- good.Testing:
CommentMessage.test.ts. Does the currentCommentThreadhave tests covering these? If so, they should migrate to the new component's test file.CommentThreadstill orchestrates the CRUD lifecycle correctly after the split.Question:
$lib/utils-- testable in isolation.🏗️ Markus Keller -- Senior Application Architect
Clean split target. The parent keeps state and CRUD orchestration, the child is a pure renderer. That is the correct boundary.
Module boundary clarity:
CommentThread"keeps comment CRUD operations, flat message derivation, compose area." Confirm thatCommentMessagehas zero awareness of the API layer -- it receives data and fires callbacks, nothing else. If anyfetchcall or API client import ends up inCommentMessage, the boundary is wrong.$derivedblock or an imperative function? If it is complex enough to have edge cases (empty threads, deleted messages, orphaned replies), it may deserve extraction into a pure utility module (flattenMessages.ts) that can be unit-tested independently of Svelte reactivity. This refactor is a natural time to do that.Data flow direction:
onEdit,onDelete,onEditTextChange,onEditKeydown,onCancelEdit) all flow upward. That is correct -- commands go up, data goes down. But 5 callbacks on one child component is a signal. Consider whether the edit state machine (editing ID, edit text, save, cancel) could be encapsulated in the parent and exposed as a singleeditStateprop with anonSaveandonCancelcallback. This simplifies the child's contract.Accidental complexity check:
CommentThread. That is still substantial. After the extraction, audit what remains: if the compose area (new message input + send button) is another 40-60 lines of template, it may be a second extraction candidate (CommentCompose.svelte). No need to do it in this issue, but worth noting for a follow-up.Question:
FlatMessagetype currently live insideCommentThread.svelte's<script>block, or is it in a shared types file? After the split, bothCommentThreadandCommentMessageneed it. If it is not already in$lib/typesor similar, move it there as part of this refactor.🧪 Sara Holt -- Senior QA Engineer
The acceptance criteria list four behaviors that must work identically after the split: edit-in-place, quote rendering, mention highlighting, and delete. That is a good checklist, but it needs test coverage to be enforceable.
Current test coverage question:
CommentThreadhave existing Vitest tests covering these four behaviors? If yes, the migration plan should be explicit: which tests move toCommentMessage.test.ts(rendering concerns) and which stay inCommentThread.test.ts(orchestration concerns). If no tests exist, this refactor is the right time to add them -- extracting a component without tests means "works identically" is verified by manual inspection only.Proposed test strategy for
CommentMessage:renders author name and avatar initials-- basic renderingrenders timestamp and "edited" label when message was edited-- conditional displayrenders quoted text block when message contains a quote-- quote extractionhighlights @mentions in message body-- mention highlightingenters edit mode when onEdit is triggered-- conditional edit textareashows delete button only for own messages (isOwn=true)-- conditional renderingdoes not show delete button for other users' messages-- negative caseEach of these is a focused unit test using
@testing-library/sveltethat rendersCommentMessagewith specific props and asserts on the DOM output.Integration-level concern:
CommentThreadorchestrates: load messages, send new message, edit existing, delete. A thin integration test should verify the parent-child wiring -- that clicking delete in the child triggers the parent's delete handler and the message disappears from the list. This catches prop-wiring regressions that unit tests on isolated components would miss.Edge cases to cover:
FlatMessage?)Acceptance criteria gap:
npm run checkpasses" but does not mentionnpm run test. Addnpm run testpassing as an explicit acceptance criterion.🔒 Nora "NullX" Steiner -- Application Security Engineer
Pure UI refactor, so the attack surface should not change -- but component splits can introduce subtle security regressions if not handled carefully.
XSS surface in the extracted component:
{@html}to render formatted content or highlight mentions, that{@html}usage moves intoCommentMessage.svelte. Verify that the sanitization step (if any) stays upstream of the render -- it must not be accidentally dropped during extraction. If there is no sanitization and the content is rendered via{@html}, flag that as a pre-existing issue to address.{@html}, the same concern applies. User-authored content passed through{@html}without DOMPurify or equivalent is a stored XSS vector.Authorization check for delete:
isOwnprop controls whether the delete button renders. But the actual delete must also be authorized server-side -- the frontend hiding a button is not a security control. Confirm that the backendDELETEendpoint verifies ownership independently. This is likely already in place, but the component split is a good moment to verify.Callback trust boundary:
CommentMessagereceivesonDeleteandonEditas callback props from the parent. The child should pass only the message ID back to the parent -- never construct API calls or include auth tokens. After the split, verify that the child component has zero imports from$lib/api.server.tsor any server-side module.Question:
🎨 Leonie Voss -- UI/UX Design Lead
The message bubble is a well-defined visual region -- avatar, name, timestamp, body, optional quote, optional edit controls. Extracting it into its own component makes the visual contract explicit, which is good for design consistency.
Accessibility concerns to preserve during extraction:
{#each}) parent hasrole="list"or uses a semantic<ul>/<ol>CommentMessagerenders as a<li>or hasrole="listitem"aria-labelthat includes the message context (e.g., "Delete message from [author]"), not just "Delete"aria-labelthat identifies what is being editedTouch target sizes:
CommentMessage, not left behind in the parent's layout.Visual consistency:
isOwnprop. After extraction, verify the conditional classes are self-contained inCommentMessageand do not depend on a parent CSS context that might break if the component is used elsewhere.Quote block styling:
Question:
CommentMessagewith the markup.⚙️ Tobias Wendt -- DevOps & Platform Engineer
Pure frontend refactor -- no infrastructure changes, no new dependencies, no build pipeline impact. From a DevOps perspective this is low-risk, but a few things to verify.
Build and bundle impact:
.sveltecomponent file does not change the build configuration. Vite handles it automatically. No changes needed tovite.config.ts,svelte.config.js, or any Docker build steps.CommentMessage.sveltefile is co-located withCommentThread.sveltein the same directory (likelysrc/lib/components/orsrc/routes/conversations/). This keeps the import path short and avoids barrel-file complications.CI pipeline check:
npm run checkpassing. The CI workflow should already run this. Confirm thatnpm run test(Vitest) is also part of the pipeline for this change -- the type check alone does not catch behavioral regressions.No-op from a deployment perspective:
Suggestion:
CommentThreaddecomposition (the issue hints at the compose area being another candidate), consider creating aconversations/subfolder undercomponents/to groupCommentThread,CommentMessage, and any future siblings. Keeps the flatcomponents/directory manageable as the project grows.Comprehensive Response to Review Comments
I read through all source files referenced in the issue. Here are concrete findings per persona.
Felix Brandt -- Props Interface & Quote Parsing
Prop count: The proposed 8 props are accurate. The
editStategrouping is a good call. Revised interface:isOwnderivation: The parent computes it viaisOwn()atCommentThread.svelte:85-87, comparingcurrentUserId(a prop on CommentThread) againstc.authorId. Keeping it as a computed prop passed down is correct --CommentMessageshould not needcurrentUserIdcontext. No change needed, but we will document the reason in a code comment.{#each}key expression: Currently(msg.id)at line 223. Will be preserved on the<CommentMessage>element after extraction.$derivedin loop body: There are no$derivedvalues inside the loop. The only per-message computation is the{@const parsed = extractQuote(msg.content)}at line 224, which is a template-local const. This will move intoCommentMessageunchanged.Quote parsing:
extractQuote()at lines 97-101 is already a pure function in the<script>block. During extraction it will move to$lib/utils/comment.ts(or stay inCommentThreadand be imported byCommentMessage). Given it is a 4-line pure function, extracting to a utility file makes it independently testable -- will do that.The regex
^>\s*"(.+?)"\s*\n\n?([\s\S]*)$handles:> "quoted text"\n\nbody-- standard case> "quoted text"\nbody-- single newline variant{ quote: null, body: content }Edge case gap: a message that is only a quote with no body (
> "text"\n\n) produces{ quote: "text", body: "" }. This is handled correctly by the template since the body renders as empty.Markus Keller -- Module Boundary & FlatMessage Type
Zero API awareness in child: Confirmed. The extracted
CommentMessagewill have zerofetchcalls and zero imports from API modules. All CRUD stays inCommentThread(lines 103-189). The child receives data + callbacks only.FlatMessagetype location: Currently defined inline inCommentThread.svelteat lines 35-43. After the split, both parent and child need it. Plan: moveFlatMessageto$lib/types.tsalongside the existingCommentandCommentReplytypes. Note thatFlatMessageis structurally identical toCommentReply(same fields). We could either reuseCommentReplydirectly or keepFlatMessageas a named alias for clarity. I lean toward keeping the distinct name since it represents a flattened view, not a reply per se.Flat message derivation complexity: The derivation at line 60-62 is a one-liner
flatMap. No edge cases worth extracting -- deleted messages and orphaned replies are not represented in the data model (the backend filters them). No separate utility needed.Compose area as second extraction candidate: After extraction,
CommentThreadwill be roughly: 100 lines of script (state + CRUD) + ~15 lines of message list template + ~12 lines of compose area. The compose area is already delegated toMentionEditor(lines 298-308), so it is only 10 lines of wrapper markup. Not worth a separate component.Callback consolidation: Agree with the
editStateprop approach (see Felix section above). This brings callbacks from 5 to effectively 2 top-level ones (onEdit,onDelete) plus theeditStateobject.Sara Holt -- Test Coverage
Current test coverage:
CommentThread.svelte.spec.tshas 3 tests covering:onCountChangewith initial commentsNot covered: edit-in-place, quote rendering, mention highlighting, delete button visibility. These are the four behaviors listed in acceptance criteria.
Test migration plan:
Tests that stay in
CommentThread.svelte.spec.ts(orchestration):onCountChangecallback tests (all 3 existing)Tests for new
CommentMessage.svelte.spec.ts(rendering):renders author name and avatar initials-- assertgetInitials()output in DOMrenders timestamp; shows "edited" label when updatedAt > createdAtrenders quoted text block when content starts with > "..."highlights @mentions via {@html renderBody()}-- assert.mentionspan in DOMshows delete button only when isOwn=truedoes not show delete button when isOwn=falseenters edit mode when editingId matches message id-- assert textarea presentEdge cases to add:
> "text"\n\nwith empty body -- renders quote block, no body paragraphmentionDTOs: []-- renders plain text, no.mentionspansauthorNameis always a string inFlatMessage, the backend populates it at query timeAcceptance criteria update: Agree -- add
npm run testpassing as explicit criterion.Nora Steiner -- Security
{@html}and XSS: The{@html renderBody(...)}at line 264 is the only{@html}usage. ReviewingrenderBody()in$lib/utils/mention.ts:53-72:&,<,>,"in the raw content -- this neutralizes HTML injection<span>tag<br>after escapingThis is safe. The escape-first approach means user content cannot inject tags. The only HTML injected is the controlled
<span class="mention">wrapper. No DOMPurify needed -- the manual escaping covers all injection vectors for plain-text content.Quote rendering: The quoted text at line 244 uses
{parsed.quote}with Svelte's default text interpolation (not{@html}), so it is auto-escaped. No XSS risk.Backend delete authorization: Confirmed in
CommentService.java:118-126. ThedeleteCommentmethod checksisAuthor || isAdminserver-side and throwsDomainException.forbidden()if neither. The frontendisOwnprop only controls UI visibility -- the actual security boundary is backend-enforced.Edit input: Plain text only. No markdown parsing, no HTML rendering of edit input. The edit textarea (
bind:value={editText}) captures plain text, which is sent as JSON{ content: text }to the PATCH endpoint. On re-render, it goes throughrenderBody()which escapes everything.Child component imports: After extraction,
CommentMessagewill import only from$lib/utils/mention(forrenderBody) and$lib/paraglide/messages(for i18n). Zero API/fetch imports.Leonie Voss -- Accessibility & Styling
Semantic structure: Currently the message list is a
<div class="space-y-2">(line 222) with<div>children. There are norole="list"or<ul>semantics. This is a pre-existing gap. During extraction:<div class="space-y-2">should getrole="log"(appropriate for a comment thread -- live region semantics)CommentMessageroot element should getrole="article"(WAI-ARIA comment pattern)Delete button aria-label: Currently uses
m.btn_delete()(line 270) which is a generic "Delete" label. Should be improved to include author context:m.comment_delete_aria({ author: msg.authorName }). This requires a new Paraglide key.Edit textarea label: No
aria-labelcurrently (line 249-254). Will addaria-label={m.comment_edit_aria({ author: msg.authorName })}.Focus management: Not currently implemented. When
editingIdchanges to matchmsg.id, the textarea appears but focus is not moved. Plan: use Svelte's{@attach}directive on the edit textarea to callnode.focus()on mount. WhencancelEdit()is called, focus should return to the message container -- this needs a ref to the trigger element.Touch targets: The delete button at line 268 is
p-0.5with ah-4 w-4SVG -- effective size is roughly 20x20px. This is below the 44x44px minimum. Will increase top-2or addmin-h-[44px] min-w-[44px].CSS scoping: All styles in the message bubble use Tailwind utility classes (inline), not scoped
<style>blocks. No parent-scoped CSS will break during extraction. The.mentionclass used by{@html}is defined in globallayout.css(lines 212-226), so it is unaffected by component boundaries. The quote block styling is also all inline Tailwind (border-l-2 border-line pl-2 font-serif text-base text-ink-3 italicat line 243).Hover/focus states: The own-message paragraph has
hover:bg-surface(line 261) for the edit-on-click affordance. This is inline Tailwind and will move cleanly with the markup.Tobias Wendt -- DevOps & File Location
Co-location:
CommentMessage.sveltewill live insrc/lib/components/alongsideCommentThread.svelte. Both are general-purpose components used from multiple routes (document detail, annotation panels, transcription blocks), so$lib/components/is the correct location -- not a route-specific folder.Subfolder: A
conversations/subfolder is premature. The compose area is already handled byMentionEditor.svelteand will not need further extraction (see Markus section). The component count in$lib/components/is manageable. Can revisit if more comment-related components emerge.CI pipeline: Confirmed
npm run checkandnpm run testare both in the CI workflow. No Playwright e2e tests directly exercise the comment thread (checkedfrontend/e2e/-- no conversation-specific spec file), so no e2e regression risk.No infrastructure impact: Correct. No new deps, no env vars, no Docker/Flyway changes.