feat(web): add collapsible comment side panel (#1607)

This commit is contained in:
soulme 2026-05-14 14:27:09 +08:00 committed by GitHub
parent a7bebd926f
commit 2a8ebff11a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 201 additions and 2 deletions

View file

@ -1993,9 +1993,11 @@ function commentAvatarInitial(comment: PreviewComment): string {
return seed.charAt(0).toUpperCase();
}
function CommentSidePanel({
export function CommentSidePanel({
comments,
selectedIds,
collapsed,
onCollapsedChange,
onToggleSelect,
onClearSelection,
onReply,
@ -2005,6 +2007,8 @@ function CommentSidePanel({
}: {
comments: PreviewComment[];
selectedIds: Set<string>;
collapsed: boolean;
onCollapsedChange: (collapsed: boolean) => void;
onToggleSelect: (commentId: string) => void;
onClearSelection: () => void;
onReply: (comment: PreviewComment) => void;
@ -2015,8 +2019,41 @@ function CommentSidePanel({
const sorted = [...comments].sort((a, b) => b.createdAt - a.createdAt);
const visibleSelectedIds = new Set(comments.filter((comment) => selectedIds.has(comment.id)).map((comment) => comment.id));
const selectedCount = visibleSelectedIds.size;
const commentsLabel = t('chat.tabComments');
if (collapsed) {
return (
<button
type="button"
className="comment-side-rail"
data-testid="comment-side-collapsed-rail"
aria-label={t('preview.showSidebar', { label: commentsLabel })}
title={t('preview.showSidebar', { label: commentsLabel })}
onClick={() => onCollapsedChange(false)}
>
<Icon name="comment" size={14} />
<span>{commentsLabel}</span>
{comments.length > 0 ? <strong>{comments.length}</strong> : null}
</button>
);
}
return (
<aside className="comment-side-panel" data-testid="comment-side-panel" aria-label={t('chat.tabComments')}>
<aside className="comment-side-panel" data-testid="comment-side-panel" aria-label={commentsLabel}>
<div className="comment-side-header">
<div className="comment-side-title">
<Icon name="comment" size={14} />
<span>{commentsLabel}</span>
</div>
<button
type="button"
className="comment-side-collapse"
aria-label={t('preview.hideSidebar', { label: commentsLabel })}
title={t('preview.hideSidebar', { label: commentsLabel })}
onClick={() => onCollapsedChange(true)}
>
<Icon name="chevron-right" size={14} />
</button>
</div>
<div className="comment-side-list">
{sorted.length === 0 ? (
<div className="comment-side-empty">
@ -3672,6 +3709,7 @@ function HtmlViewer({
const [sendingBoardBatch, setSendingBoardBatch] = useState(false);
const [commentSavedToast, setCommentSavedToast] = useState<string | null>(null);
const [selectedSideCommentIds, setSelectedSideCommentIds] = useState<Set<string>>(() => new Set());
const [commentSidePanelCollapsed, setCommentSidePanelCollapsed] = useState(false);
const [strokePoints, setStrokePoints] = useState<StrokePoint[]>([]);
const previewStateKey = `${projectId}:${file.name}`;
const previewScale = zoom / 100;
@ -5721,6 +5759,8 @@ function HtmlViewer({
<CommentSidePanel
comments={visibleSideComments}
selectedIds={selectedSideCommentIds}
collapsed={commentSidePanelCollapsed}
onCollapsedChange={setCommentSidePanelCollapsed}
onToggleSelect={(commentId) => {
setSelectedSideCommentIds((current) => {
const next = new Set(current);

View file

@ -9714,6 +9714,93 @@ button.connector-action.is-loading {
z-index: 30;
overflow: hidden;
}
.comment-side-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 10px 12px;
border-bottom: 1px solid var(--border);
background: var(--bg-panel, #fff);
}
.comment-side-title {
display: inline-flex;
align-items: center;
min-width: 0;
gap: 7px;
color: var(--text);
font-size: 12px;
font-weight: 700;
}
.comment-side-title span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.comment-side-collapse {
width: 26px;
height: 26px;
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
color: var(--text-muted);
cursor: pointer;
}
.comment-side-collapse:hover {
background: var(--bg-subtle);
border-color: var(--border);
color: var(--text);
}
.comment-side-rail {
position: absolute;
top: 8px;
right: 8px;
bottom: 8px;
z-index: 30;
width: 42px;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 12px 0;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--bg-panel, #fff);
color: var(--text-muted);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
cursor: pointer;
}
.comment-side-rail:hover {
color: var(--text);
border-color: var(--border-strong);
background: var(--bg-subtle);
}
.comment-side-rail span {
writing-mode: vertical-rl;
text-orientation: mixed;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
}
.comment-side-rail strong {
min-width: 18px;
height: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 4px;
border-radius: 999px;
background: #ff5a3c;
color: #fff;
font-size: 10px;
font-weight: 700;
line-height: 1;
}
.comment-side-list {
flex: 1;
overflow-y: auto;

View file

@ -2,6 +2,7 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { useState } from 'react';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { renderToStaticMarkup } from 'react-dom/server';
import { afterEach, describe, expect, it, vi } from 'vitest';
@ -21,6 +22,7 @@ vi.mock('../../src/state/projects', async () => {
});
import {
CommentSidePanel,
FileViewer,
LiveArtifactViewer,
LiveArtifactRefreshHistoryPanel,
@ -34,6 +36,7 @@ import {
import type { InspectOverrideMap } from '../../src/components/FileViewer';
import type { LiveArtifact, LiveArtifactWorkspaceEntry, ProjectFile } from '../../src/types';
import { I18nProvider } from '../../src/i18n';
import type { Dict } from '../../src/i18n/types';
afterEach(() => {
cleanup();
@ -970,6 +973,17 @@ describe('FileViewer SVG artifacts', () => {
});
describe('FileViewer tweaks toolbar', () => {
const t = (key: keyof Dict) => {
const labels: Partial<Record<keyof Dict, string>> = {
'chat.tabComments': 'Comments',
'chat.comments.emptySaved': 'No saved comments.',
'common.close': 'Close',
'preview.showSidebar': 'Show Comments',
'preview.hideSidebar': 'Hide Comments',
};
return labels[key] ?? key;
};
function htmlPreviewFile(): ProjectFile {
return baseFile({
name: 'preview.html',
@ -1066,6 +1080,64 @@ describe('FileViewer tweaks toolbar', () => {
expect(screen.queryByRole('button', { name: 'Send' })).toBeNull();
expect(screen.queryByText('Queues while working')).toBeNull();
});
it('collapses the comment side panel into a narrow reopen rail', () => {
const onCollapseChange = vi.fn();
function Harness() {
const [collapsed, setCollapsed] = useState(false);
return (
<CommentSidePanel
comments={[
{
id: 'comment-1',
projectId: 'project-1',
conversationId: 'conversation-1',
filePath: 'preview.html',
elementId: 'button.sso-btn',
selector: '[data-od-id="button.sso-btn"]',
label: 'button.sso-btn',
text: 'GitHub',
htmlHint: '<button>GitHub</button>',
position: { x: 16, y: 24, width: 160, height: 48 },
note: '不要github换成微信',
status: 'open',
createdAt: Date.now(),
updatedAt: Date.now(),
},
]}
selectedIds={new Set(['comment-1'])}
collapsed={collapsed}
onCollapsedChange={(next) => {
onCollapseChange(next);
setCollapsed(next);
}}
onToggleSelect={() => {}}
onClearSelection={() => {}}
onReply={() => {}}
onSendSelected={() => {}}
sending={false}
t={t}
/>
);
}
render(<Harness />);
expect(screen.getByTestId('comment-side-panel')).toBeTruthy();
expect(screen.getByText('不要github换成微信')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: /hide comments/i }));
expect(onCollapseChange).toHaveBeenLastCalledWith(true);
expect(screen.queryByText('不要github换成微信')).toBeNull();
expect(screen.queryByTestId('comment-side-selectbar')).toBeNull();
expect(screen.getByTestId('comment-side-collapsed-rail')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: /show comments/i }));
expect(onCollapseChange).toHaveBeenLastCalledWith(false);
});
});
describe('applyInspectOverridesToSource', () => {