diff --git a/web/src/components/ActivityFeed.tsx b/web/src/components/ActivityFeed.tsx index b35cdc4e..0f4d04f8 100644 --- a/web/src/components/ActivityFeed.tsx +++ b/web/src/components/ActivityFeed.tsx @@ -255,6 +255,7 @@ export function ActivityFeed({ diff --git a/web/src/components/ProposalList.test.tsx b/web/src/components/ProposalList.test.tsx index 3d257cdb..eb6b139a 100644 --- a/web/src/components/ProposalList.test.tsx +++ b/web/src/components/ProposalList.test.tsx @@ -326,4 +326,277 @@ describe('ProposalList', () => { 'dark:focus-visible:ring-offset-neutral-800' ); }); + + it('renders proposal comments in the discussion section when selected', () => { + const proposals: Proposal[] = [ + { + number: 1, + title: 'Proposal with comments', + phase: 'discussion', + author: 'worker', + createdAt: '2026-02-05T09:00:00Z', + commentCount: 1, + repo: 'hivemoot/colony', + }, + ]; + const comments = [ + { + id: 101, + issueOrPrNumber: 1, + type: 'issue' as const, + repo: 'hivemoot/colony', + author: 'scout', + body: 'I support this proposal!', + createdAt: '2026-02-05T10:00:00Z', + url: 'https://github.com/hivemoot/colony/issues/1#issuecomment-101', + }, + { + id: 102, + issueOrPrNumber: 2, // Different proposal + type: 'proposal' as const, + repo: 'hivemoot/colony', + author: 'builder', + body: 'Unrelated comment', + createdAt: '2026-02-05T11:00:00Z', + url: 'https://github.com/hivemoot/colony/issues/2#issuecomment-102', + }, + { + id: 103, + issueOrPrNumber: 1, // Same proposal number in different repo + type: 'issue' as const, + repo: 'hivemoot/hivemoot', + author: 'builder', + body: 'Cross-repo comment', + createdAt: '2026-02-05T11:30:00Z', + url: 'https://github.com/hivemoot/hivemoot/issues/1#issuecomment-103', + }, + { + id: 104, + issueOrPrNumber: 1, // Same number but phase-transition synthetic type + type: 'proposal' as const, + repo: 'hivemoot/colony', + author: 'builder', + body: 'Moved to voting phase', + createdAt: '2026-02-05T11:45:00Z', + url: 'https://github.com/hivemoot/colony/issues/1#issuecomment-104', + }, + ]; + + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: /#1/i })); + + expect(screen.getByText('Discussion')).toBeInTheDocument(); + expect(screen.getByText(/@scout/i)).toBeInTheDocument(); + expect(screen.getByText(/I support this proposal!/i)).toBeInTheDocument(); + expect( + screen.getByRole('link', { name: /view on github/i }) + ).toHaveAttribute( + 'href', + 'https://github.com/hivemoot/colony/issues/1#issuecomment-101' + ); + expect(screen.queryByText(/Unrelated comment/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/Cross-repo comment/i)).not.toBeInTheDocument(); + expect( + screen.queryByText(/Moved to voting phase/i) + ).not.toBeInTheDocument(); + }); + + it('marks metadata/system comments in proposal discussion', () => { + const proposals: Proposal[] = [ + { + number: 7, + title: 'System message visibility', + phase: 'discussion', + author: 'worker', + createdAt: '2026-02-05T09:00:00Z', + commentCount: 1, + repo: 'hivemoot/colony', + }, + ]; + const comments = [ + { + id: 301, + issueOrPrNumber: 7, + type: 'issue' as const, + repo: 'hivemoot/colony', + author: 'hivemoot', + body: '\n# Discussion Phase', + createdAt: '2026-02-05T09:30:00Z', + url: 'https://github.com/hivemoot/colony/issues/7#issuecomment-301', + }, + ]; + + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: /#7/i })); + + expect(screen.getByText('System')).toBeInTheDocument(); + expect(screen.getByText(/@hivemoot/i)).toBeInTheDocument(); + expect( + screen.getByRole('link', { name: /view on github/i }) + ).toHaveAttribute( + 'href', + 'https://github.com/hivemoot/colony/issues/7#issuecomment-301' + ); + }); + + it('clamps long discussion comments and supports expand/collapse', () => { + const proposals: Proposal[] = [ + { + number: 8, + title: 'Clamp long comments', + phase: 'discussion', + author: 'worker', + createdAt: '2026-02-05T09:00:00Z', + commentCount: 1, + repo: 'hivemoot/colony', + }, + ]; + const longBody = `Long comment: ${'lorem ipsum '.repeat(40)}`; + const comments = [ + { + id: 401, + issueOrPrNumber: 8, + type: 'issue' as const, + repo: 'hivemoot/colony', + author: 'builder', + body: longBody, + createdAt: '2026-02-05T09:30:00Z', + url: 'https://github.com/hivemoot/colony/issues/8#issuecomment-401', + }, + ]; + + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: /#8/i })); + + const showMoreButton = screen.getByRole('button', { name: /show more/i }); + expect(showMoreButton).toHaveAttribute('aria-expanded', 'false'); + const clampedComment = screen.getByText((content) => + content.startsWith('Long comment:') + ); + expect(clampedComment.textContent?.endsWith('...')).toBe(true); + + fireEvent.click(showMoreButton); + const expandedComment = screen.getByText((content) => + content.startsWith('Long comment:') + ); + expect(expandedComment.textContent).toBe(longBody); + + const showLessButton = screen.getByRole('button', { name: /show less/i }); + expect(showLessButton).toHaveAttribute('aria-expanded', 'true'); + + fireEvent.click(showLessButton); + const reclampedComment = screen.getByText((content) => + content.startsWith('Long comment:') + ); + expect(reclampedComment.textContent?.endsWith('...')).toBe(true); + expect( + screen.getByRole('button', { name: /show more/i }) + ).toBeInTheDocument(); + }); + + it('keeps proposal selection and panel ids unique across repos', () => { + const proposals: Proposal[] = [ + { + number: 1, + title: 'Colony proposal', + phase: 'discussion', + author: 'worker', + createdAt: '2026-02-05T09:00:00Z', + commentCount: 1, + repo: 'hivemoot/colony', + }, + { + number: 1, + title: 'Hivemoot proposal', + phase: 'discussion', + author: 'scout', + createdAt: '2026-02-05T09:30:00Z', + commentCount: 1, + repo: 'hivemoot/hivemoot', + }, + ]; + const comments = [ + { + id: 201, + issueOrPrNumber: 1, + type: 'issue' as const, + repo: 'hivemoot/colony', + author: 'worker', + body: 'Colony-only comment', + createdAt: '2026-02-05T10:00:00Z', + url: 'https://github.com/hivemoot/colony/issues/1#issuecomment-201', + }, + { + id: 202, + issueOrPrNumber: 1, + type: 'issue' as const, + repo: 'hivemoot/hivemoot', + author: 'scout', + body: 'Hivemoot-only comment', + createdAt: '2026-02-05T10:05:00Z', + url: 'https://github.com/hivemoot/hivemoot/issues/1#issuecomment-202', + }, + ]; + + render( + + ); + + const proposalButtons = screen.getAllByRole('button', { name: /#1/i }); + const controlsIds = proposalButtons.map((button) => + button.getAttribute('aria-controls') + ); + expect(controlsIds[0]).not.toEqual(controlsIds[1]); + + const issueLinks = screen.getAllByRole('link', { name: /view issue/i }); + expect(issueLinks[0]).toHaveAttribute( + 'href', + 'https://github.com/hivemoot/colony/issues/1' + ); + expect(issueLinks[1]).toHaveAttribute( + 'href', + 'https://github.com/hivemoot/hivemoot/issues/1' + ); + + fireEvent.click(screen.getByRole('button', { name: /colony proposal/i })); + expect(screen.getByText(/Colony-only comment/i)).toBeInTheDocument(); + expect( + screen.queryByText(/Hivemoot-only comment/i) + ).not.toBeInTheDocument(); + expect( + screen.getByRole('link', { name: /view proposal thread/i }) + ).toHaveAttribute('href', 'https://github.com/hivemoot/colony/issues/1'); + + fireEvent.click(screen.getByRole('button', { name: /hivemoot proposal/i })); + expect(screen.getByText(/Hivemoot-only comment/i)).toBeInTheDocument(); + expect(screen.queryByText(/Colony-only comment/i)).not.toBeInTheDocument(); + expect( + screen.getByRole('link', { name: /view proposal thread/i }) + ).toHaveAttribute('href', 'https://github.com/hivemoot/hivemoot/issues/1'); + }); }); diff --git a/web/src/components/ProposalList.tsx b/web/src/components/ProposalList.tsx index f017cb27..d198f195 100644 --- a/web/src/components/ProposalList.tsx +++ b/web/src/components/ProposalList.tsx @@ -1,5 +1,5 @@ import { useMemo, useState } from 'react'; -import type { Proposal, PullRequest } from '../types/activity'; +import type { Proposal, PullRequest, Comment } from '../types/activity'; import { handleAvatarError, getGitHubAvatarUrl } from '../utils/avatar'; import { formatDuration, formatTimeAgo } from '../utils/time'; import { buildDecisionSnapshot } from '../utils/decision-explorer'; @@ -7,6 +7,7 @@ import { buildDecisionSnapshot } from '../utils/decision-explorer'; interface ProposalListProps { proposals: Proposal[]; pullRequests?: PullRequest[]; + comments?: Comment[]; repoUrl: string; filteredAgent?: string | null; } @@ -14,16 +15,22 @@ interface ProposalListProps { export function ProposalList({ proposals, pullRequests = [], + comments = [], repoUrl, filteredAgent, }: ProposalListProps): React.ReactElement { - const [selectedProposalNumber, setSelectedProposalNumber] = useState< - number | null - >(null); + const [expandedCommentIds, setExpandedCommentIds] = useState>( + () => new Set() + ); + const [selectedProposalId, setSelectedProposalId] = useState( + null + ); const selectedProposal = useMemo( - () => proposals.find((p) => p.number === selectedProposalNumber) ?? null, - [proposals, selectedProposalNumber] + () => + proposals.find((p) => getProposalIdentity(p) === selectedProposalId) ?? + null, + [proposals, selectedProposalId] ); const snapshot = useMemo( @@ -34,6 +41,28 @@ export function ProposalList({ [selectedProposal, pullRequests] ); + const proposalComments = useMemo( + () => + selectedProposal + ? comments + .filter((c) => { + const sameRepo = + (c.repo ?? null) === (selectedProposal.repo ?? null); + return ( + c.issueOrPrNumber === selectedProposal.number && + c.type === 'issue' && + sameRepo + ); + }) + .sort( + (a, b) => + new Date(a.createdAt).getTime() - + new Date(b.createdAt).getTime() + ) + : [], + [selectedProposal, comments] + ); + if (proposals.length === 0) { return (

@@ -48,11 +77,14 @@ export function ProposalList({

{proposals.map((proposal) => { - const isSelected = proposal.number === selectedProposalNumber; + const proposalId = getProposalIdentity(proposal); + const explorerId = getDecisionExplorerId(proposal); + const isSelected = proposalId === selectedProposalId; + const proposalRepoUrl = getRepositoryUrl(proposal.repo, repoUrl); return (
- setSelectedProposalNumber(isSelected ? null : proposal.number) + setSelectedProposalId(isSelected ? null : proposalId) } aria-expanded={isSelected} - aria-controls={`decision-explorer-${proposal.number}`} + aria-controls={explorerId} className="w-full text-left p-4 hover:bg-white/60 dark:hover:bg-neutral-800/60 rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500 focus-visible:ring-offset-1 dark:focus-visible:ring-offset-neutral-800" >
@@ -136,7 +168,7 @@ export function ProposalList({
-
-

- Timeline -

-
    - {snapshot.timeline.map((item, index) => ( -
  1. -
    - - {item.phase.replace(/-/g, ' ')} - - - {item.durationToNext && ( - - ({item.durationToNext}) +
    +
    +

    + Timeline +

    +
      + {snapshot.timeline.map((item, index) => ( +
    1. +
      + + {item.phase.replace(/-/g, ' ')} - )} -
      -
    2. - ))} -
    + + {item.durationToNext && ( + + ({item.durationToNext}) + + )} +
    +
  2. + ))} +
+
+ +
+ + {proposalComments.length > 0 ? ( +
    + {proposalComments.map((comment) => { + const systemComment = isSystemComment(comment); + const commentKey = `${selectedProposalId ?? 'none'}:${comment.id}`; + const isExpanded = expandedCommentIds.has(commentKey); + const commentPreview = truncateCommentBody( + comment.body, + isExpanded + ); + return ( +
  • + {systemComment ? ( +
    + System +
    + ) : null} +
    +
    + + + @{comment.author} + + + + + View on GitHub + +
    +

    + {commentPreview} +

    + {isCommentClampEligible(comment.body) ? ( + + ) : null} +
    +
  • + ); + })} +
+ ) : ( +

+ No discussion recorded for this proposal yet. +

+ )} +
@@ -205,14 +340,33 @@ export function ProposalList({ Vote Breakdown {snapshot.votes.total > 0 ? ( -
-

- ๐Ÿ‘ {snapshot.votes.thumbsUp} -

-

- ๐Ÿ‘Ž {snapshot.votes.thumbsDown} -

-

+

+
+ + ๐Ÿ‘ {snapshot.votes.thumbsUp} + + + ๐Ÿ‘Ž {snapshot.votes.thumbsDown} + +
+