feat(#81): improve discussion discoverability

- Add comment count badge on the Discussion tab (seeded from SSR, updated live)
- Add 'Diskussion starten' nudge above collapsed panel when no comments exist
- Add empty state hint with speech-bubble icon inside the discussion panel
- Fix CommentThread to fire onCountChange with SSR-seeded count on mount
- Add tests for all three behaviours in CommentThread and DocumentBottomPanel

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-03-26 18:19:38 +01:00
parent c6984e49ee
commit bf82ebfe1d
8 changed files with 202 additions and 5 deletions

View File

@@ -291,5 +291,7 @@
"enrich_skip": "Überspringen",
"enrich_done_heading": "Alles erledigt!",
"enrich_done_body": "Alle Dokumente wurden bearbeitet.",
"enrich_back_to_list": "Zurück zur Liste"
"enrich_back_to_list": "Zurück zur Liste",
"comment_empty_hint": "Noch keine Kommentare starte die Diskussion!",
"comment_start_discussion": "Diskussion starten →"
}

View File

@@ -291,5 +291,7 @@
"enrich_skip": "Skip",
"enrich_done_heading": "All done!",
"enrich_done_body": "All documents have been processed.",
"enrich_back_to_list": "Back to list"
"enrich_back_to_list": "Back to list",
"comment_empty_hint": "No comments yet start the discussion!",
"comment_start_discussion": "Start discussion →"
}

View File

@@ -291,5 +291,7 @@
"enrich_skip": "Omitir",
"enrich_done_heading": "¡Todo listo!",
"enrich_done_body": "Todos los documentos han sido procesados.",
"enrich_back_to_list": "Volver a la lista"
"enrich_back_to_list": "Volver a la lista",
"comment_empty_hint": "Aún no hay comentarios ¡inicia la discusión!",
"comment_start_discussion": "Iniciar discusión →"
}

View File

@@ -167,6 +167,9 @@ function cancelReply() {
onMount(() => {
if (loadOnMount) {
reload();
} else {
const total = initialComments.reduce((s, c) => s + 1 + c.replies.length, 0);
onCountChange?.(total);
}
});
</script>
@@ -245,6 +248,24 @@ onMount(() => {
{/snippet}
<div class="space-y-4">
{#if comments.length === 0}
<div class="flex flex-col items-center gap-3 py-8 text-center">
<svg
class="h-10 w-10 text-ink-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M2.25 12.76c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.076-4.076a1.526 1.526 0 0 1 1.037-.443 48.282 48.282 0 0 0 5.68-.494c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z"
/>
</svg>
<p class="font-sans text-sm text-ink-3">{m.comment_empty_hint()}</p>
</div>
{/if}
{#each comments as thread, ti (thread.id)}
<div class={ti > 0 ? 'border-t border-line pt-4' : ''}>
<!-- Root comment -->

View File

@@ -0,0 +1,70 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import CommentThread from './CommentThread.svelte';
import type { Comment } from '$lib/types';
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
function makeComment(id: string, content = 'Hello'): Comment {
return {
id,
authorId: 'user-1',
authorName: 'Alice',
content,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
replies: []
};
}
const baseProps = {
documentId: 'doc-1',
canComment: true,
currentUserId: 'user-1',
canAdmin: false
};
describe('CommentThread empty state', () => {
it('shows empty state hint when there are no comments', async () => {
render(CommentThread, { ...baseProps, initialComments: [] });
await expect
.element(page.getByText('Noch keine Kommentare starte die Diskussion!'))
.toBeInTheDocument();
});
it('does not show empty state hint when comments exist', async () => {
render(CommentThread, { ...baseProps, initialComments: [makeComment('c-1')] });
await expect
.element(page.getByText('Noch keine Kommentare starte die Diskussion!'))
.not.toBeInTheDocument();
});
});
describe('CommentThread onCountChange', () => {
it('calls onCountChange with initial SSR count on mount', async () => {
const onCountChange = vi.fn();
render(CommentThread, {
...baseProps,
initialComments: [makeComment('c-1'), makeComment('c-2')],
onCountChange
});
expect(onCountChange).toHaveBeenCalledWith(2);
});
it('calls onCountChange with 0 when no initial comments', async () => {
const onCountChange = vi.fn();
render(CommentThread, { ...baseProps, initialComments: [], onCountChange });
expect(onCountChange).toHaveBeenCalledWith(0);
});
it('counts replies in the total', async () => {
const onCountChange = vi.fn();
const comment = { ...makeComment('c-1'), replies: [makeComment('r-1') as never] };
render(CommentThread, { ...baseProps, initialComments: [comment], onCountChange });
expect(onCountChange).toHaveBeenCalledWith(2);
});
});

View File

@@ -98,10 +98,16 @@ const tabs: { id: DocumentPanelTab; label: () => string }[] = [
];
const panelHeight = $derived(open ? height : MIN_HEIGHT);
let discussionCount = $state((() => comments.reduce((s, c) => s + 1 + c.replies.length, 0))());
function handleCountChange(count: number) {
discussionCount = count;
}
</script>
<div
class="fixed right-0 bottom-0 left-0 z-30 flex flex-col border-t border-line bg-surface shadow-[0_-4px_16px_rgba(0,0,0,0.08)]"
class="fixed relative right-0 bottom-0 left-0 z-30 flex flex-col border-t border-line bg-surface shadow-[0_-4px_16px_rgba(0,0,0,0.08)]"
style="height: {panelHeight}px"
data-testid="bottom-panel"
>
@@ -120,6 +126,15 @@ const panelHeight = $derived(open ? height : MIN_HEIGHT);
<div class="h-1 w-12 rounded-full bg-line"></div>
</div>
{#if !open && discussionCount === 0}
<button
data-testid="discussion-nudge"
onclick={() => openTab('discussion')}
class="absolute -top-7 left-1/2 -translate-x-1/2 rounded-full border border-line bg-surface px-3 py-1 font-sans text-xs whitespace-nowrap text-ink-3 shadow-sm transition-colors hover:text-ink"
>{m.comment_start_discussion()}</button
>
{/if}
<!-- Tab bar -->
<div class="flex shrink-0 items-center border-b border-line bg-surface px-4">
{#each tabs as tab (tab.id)}
@@ -131,6 +146,13 @@ const panelHeight = $derived(open ? height : MIN_HEIGHT);
aria-pressed={activeTab === tab.id && open}
>
{tab.label()}
{#if tab.id === 'discussion' && discussionCount > 0}
<span
data-testid="discussion-count-badge"
class="ml-1.5 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 font-sans text-[10px] font-bold text-white"
>{discussionCount}</span
>
{/if}
</button>
{/each}
@@ -165,6 +187,7 @@ const panelHeight = $derived(open ? height : MIN_HEIGHT);
canComment={canComment}
currentUserId={currentUserId}
canAdmin={canAdmin}
onCountChange={handleCountChange}
/>
{:else if activeTab === 'history'}
<PanelHistory documentId={doc.id} />

View File

@@ -0,0 +1,74 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
import DocumentBottomPanel from './DocumentBottomPanel.svelte';
import type { Comment } from '$lib/types';
afterEach(cleanup);
function makeComment(id: string): Comment {
return {
id,
authorId: 'user-1',
authorName: 'Alice',
content: 'Hello',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
replies: []
};
}
const doc = { id: 'doc-1', title: 'Test' };
const baseProps = {
doc,
canComment: true,
currentUserId: 'user-1',
canAdmin: false,
height: 300,
activeTab: 'discussion' as const
};
describe('DocumentBottomPanel discussion badge', () => {
it('shows a badge with comment count on the Discussion tab when comments exist', async () => {
render(DocumentBottomPanel, {
...baseProps,
comments: [makeComment('c-1'), makeComment('c-2')],
open: true
});
await expect.element(page.getByTestId('discussion-count-badge')).toBeInTheDocument();
await expect.element(page.getByTestId('discussion-count-badge')).toHaveTextContent('2');
});
it('does not show badge when there are no comments', async () => {
render(DocumentBottomPanel, { ...baseProps, comments: [], open: true });
await expect.element(page.getByTestId('discussion-count-badge')).not.toBeInTheDocument();
});
});
describe('DocumentBottomPanel start discussion nudge', () => {
it('shows nudge above drag handle when panel is closed and no comments', async () => {
render(DocumentBottomPanel, { ...baseProps, comments: [], open: false });
await expect.element(page.getByTestId('discussion-nudge')).toBeInTheDocument();
});
it('does not show nudge when panel is closed but comments exist', async () => {
render(DocumentBottomPanel, {
...baseProps,
comments: [makeComment('c-1')],
open: false
});
await expect.element(page.getByTestId('discussion-nudge')).not.toBeInTheDocument();
});
it('does not show nudge when panel is open', async () => {
render(DocumentBottomPanel, { ...baseProps, comments: [], open: true });
await expect.element(page.getByTestId('discussion-nudge')).not.toBeInTheDocument();
});
it('opens the discussion tab when the nudge is clicked', async () => {
render(DocumentBottomPanel, { ...baseProps, comments: [], open: false });
await userEvent.click(page.getByTestId('discussion-nudge'));
await expect.element(page.getByTestId('bottom-panel-content')).toBeInTheDocument();
});
});

View File

@@ -8,9 +8,11 @@ type Props = {
canComment: boolean;
currentUserId: string | null;
canAdmin: boolean;
onCountChange?: (count: number) => void;
};
let { documentId, initialComments, canComment, currentUserId, canAdmin }: Props = $props();
let { documentId, initialComments, canComment, currentUserId, canAdmin, onCountChange }: Props =
$props();
</script>
<div class="flex-1 overflow-y-auto p-6">
@@ -20,5 +22,6 @@ let { documentId, initialComments, canComment, currentUserId, canAdmin }: Props
canComment={canComment}
currentUserId={currentUserId}
canAdmin={canAdmin}
onCountChange={onCountChange}
/>
</div>