Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 74 additions & 1 deletion web/src/components/ProposalList.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { render, screen, fireEvent, within } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { ProposalList } from './ProposalList';
import type { Proposal, PullRequest } from '../types/activity';
Expand Down Expand Up @@ -407,4 +407,77 @@ describe('ProposalList', () => {
screen.queryByText(/Same number issue comment/i)
).not.toBeInTheDocument();
});

it('keeps selection scoped by repo when proposal numbers collide', () => {
const proposals: Proposal[] = [
{
number: 42,
title: 'Colony proposal',
phase: 'discussion',
author: 'worker',
createdAt: '2026-02-05T09:00:00Z',
commentCount: 1,
repo: 'hivemoot/colony',
},
{
number: 42,
title: 'Governance proposal',
phase: 'discussion',
author: 'scout',
createdAt: '2026-02-05T10:00:00Z',
commentCount: 1,
repo: 'hivemoot/hivemoot',
},
];
const comments = [
{
id: 2001,
issueOrPrNumber: 42,
type: 'proposal' as const,
repo: 'hivemoot/colony',
author: 'worker',
body: 'Colony-specific thread',
createdAt: '2026-02-05T11:00:00Z',
url: 'https://github.com/hivemoot/colony/issues/42#issuecomment-2001',
},
{
id: 2002,
issueOrPrNumber: 42,
type: 'proposal' as const,
repo: 'hivemoot/hivemoot',
author: 'queen',
body: 'Governance-specific thread',
createdAt: '2026-02-05T12:00:00Z',
url: 'https://github.com/hivemoot/hivemoot/issues/42#issuecomment-2002',
},
];

render(
<ProposalList
proposals={proposals}
comments={comments}
repoUrl={repoUrl}
/>
);

const colonyCard = screen.getByText('Colony proposal').closest('article');
expect(colonyCard).not.toBeNull();
fireEvent.click(within(colonyCard as HTMLElement).getByRole('button'));

expect(screen.getByText('Colony-specific thread')).toBeInTheDocument();
expect(
screen.queryByText('Governance-specific thread')
).not.toBeInTheDocument();

const governanceCard = screen
.getByText('Governance proposal')
.closest('article');
expect(governanceCard).not.toBeNull();
fireEvent.click(within(governanceCard as HTMLElement).getByRole('button'));

expect(screen.getByText('Governance-specific thread')).toBeInTheDocument();
expect(
screen.queryByText('Colony-specific thread')
).not.toBeInTheDocument();
});
});
28 changes: 18 additions & 10 deletions web/src/components/ProposalList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,20 @@ export function ProposalList({
repoUrl,
filteredAgent,
}: ProposalListProps): React.ReactElement {
const [selectedProposalNumber, setSelectedProposalNumber] = useState<
number | null
>(null);
const [selectedProposalKey, setSelectedProposalKey] = useState<string | null>(
null
);

const getProposalKey = (proposal: Proposal): string =>
`${proposal.repo ?? 'unknown'}:${proposal.number}`;

const getExplorerId = (proposal: Proposal): string =>
`decision-explorer-${getProposalKey(proposal).replace(/[^a-zA-Z0-9_-]/g, '-')}`;

const selectedProposal = useMemo(
() => proposals.find((p) => p.number === selectedProposalNumber) ?? null,
[proposals, selectedProposalNumber]
() =>
proposals.find((p) => getProposalKey(p) === selectedProposalKey) ?? null,
[proposals, selectedProposalKey]
);

const snapshot = useMemo(
Expand Down Expand Up @@ -72,11 +79,12 @@ export function ProposalList({
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
{proposals.map((proposal) => {
const isSelected = proposal.number === selectedProposalNumber;
const proposalKey = getProposalKey(proposal);
const isSelected = proposalKey === selectedProposalKey;

return (
<article
key={proposal.number}
key={proposalKey}
className={`bg-white/40 dark:bg-neutral-800/40 border rounded-lg motion-safe:transition-colors ${
isSelected
? 'border-amber-400 dark:border-amber-500'
Expand All @@ -86,10 +94,10 @@ export function ProposalList({
<button
type="button"
onClick={() =>
setSelectedProposalNumber(isSelected ? null : proposal.number)
setSelectedProposalKey(isSelected ? null : proposalKey)
}
aria-expanded={isSelected}
aria-controls={`decision-explorer-${proposal.number}`}
aria-controls={getExplorerId(proposal)}
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"
>
<div className="flex justify-between items-start mb-2">
Expand Down Expand Up @@ -180,7 +188,7 @@ export function ProposalList({

{selectedProposal && snapshot && (
<section
id={`decision-explorer-${selectedProposal.number}`}
id={getExplorerId(selectedProposal)}
aria-label={`Decision explorer for proposal #${selectedProposal.number}`}
className="bg-white/40 dark:bg-neutral-800/40 border border-amber-300 dark:border-neutral-600 rounded-lg p-4"
>
Expand Down