Skip to content

fix: red dot next to governance tab - #1692

Closed
webmagic123 wants to merge 14 commits into
masterfrom
fix/red-dot-next-to-governance-tab
Closed

webmagic123 wants to merge 14 commits into
masterfrom
fix/red-dot-next-to-governance-tab

Conversation

@webmagic123

Copy link
Copy Markdown
Collaborator

No description provided.

@vercel

vercel Bot commented Apr 20, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
geogenesis Ready Ready Preview Apr 22, 2026 0:23am

Request Review

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a “pending votes” indicator to the Browse sidebar’s Governance link by extending the sidebar data payload with a hasPendingVotes boolean derived from governance proposal data.

Changes:

  • Add hasPendingVotes to BrowseSidebarData and propagate it through sidebar loading.
  • Fetch proposed governance proposals across editor spaces and compute whether any are unvoted by the current member.
  • Render a small red dot next to “Governance” when pending votes exist.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.

File Description
apps/web/partials/browse-sidebar/browse-sidebar.tsx Renders a red-dot indicator on the Governance nav item when hasPendingVotes is true.
apps/web/core/browse/fetch-browse-sidebar-data.ts Extends sidebar data fetch to compute hasPendingVotes via REST proposal pagination across editor spaces.
apps/web/app/explore/page.tsx Updates degraded/fallback BrowseSidebarData shape to include hasPendingVotes.
apps/web/app/api/explore/feed/route.ts Updates degraded/fallback BrowseSidebarData shape to include hasPendingVotes.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 288 to +291
);

const hasPendingVotes = await hasPendingVotesInEditorSpaces(memberSpaceId, editorIds);

Copilot AI Apr 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hasPendingVotesInEditorSpaces could be started earlier/in parallel: it only depends on memberSpaceId and editorIds, but currently runs after fetchSpaceRows and list construction. Kicking it off once editorIds are available (e.g., storing a promise and awaiting it near the return) would reduce sidebar data fetch latency without changing behavior.

Copilot uses AI. Check for mistakes.
<span>Governance</span>
<span className="min-w-0 flex-1 text-left">Governance</span>
{hasPendingVotes ? (
<span className="inline-block h-1.5 w-1.5 shrink-0 rounded-full bg-red-01" aria-hidden="true" />

Copilot AI Apr 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The red-dot notification is currently marked aria-hidden, so assistive tech users won’t be informed that there are pending votes. If this indicator represents actionable state, consider adding an accessible label (e.g., a visually-hidden "Pending votes" text node, or aria-label on a non-hidden element) so the status is conveyed beyond color alone.

Suggested change
<span className="inline-block h-1.5 w-1.5 shrink-0 rounded-full bg-red-01" aria-hidden="true" />
<>
<span className="sr-only">Pending votes</span>
<span className="inline-block h-1.5 w-1.5 shrink-0 rounded-full bg-red-01" aria-hidden="true" />
</>

Copilot uses AI. Check for mistakes.
Comment on lines +37 to +88

async function hasPendingVotesInEditorSpaces(memberSpaceId: string, editorSpaceIds: string[]): Promise<boolean> {
if (editorSpaceIds.length === 0) return false;

const config = Environment.getConfig();

for (const spaceId of editorSpaceIds) {
let cursor: string | null = null;

for (let page = 0; page < GOVERNANCE_PROPOSAL_MAX_PAGES; page++) {
const params = new URLSearchParams();
params.set('limit', String(GOVERNANCE_PROPOSAL_PAGE_SIZE));
params.set('status', 'PROPOSED');
params.set('orderBy', 'end_time');
params.set('orderDirection', 'desc');
params.set('voterId', memberSpaceId);
if (cursor) {
params.set('cursor', cursor);
}

const path = `/proposals/space/${encodePathSegment(spaceId)}/status?${params.toString()}`;

const result = await Effect.runPromise(
Effect.either(
restFetch<unknown>({
endpoint: config.api,
path,
})
)
);

if (Either.isLeft(result)) {
console.error(`Failed to fetch governance proposals for space ${spaceId}:`, result.left);
break;
}

const decoded = Schema.decodeUnknownEither(ApiProposalListResponseSchema)(result.right);
if (Either.isLeft(decoded)) {
console.error(`Failed to decode governance proposals for space ${spaceId}:`, decoded.left);
break;
}

if (decoded.right.proposals.some(proposal => proposal.userVote == null)) {
return true;
}

cursor = decoded.right.nextCursor;
if (!cursor) break;
}
}

return false;

Copilot AI Apr 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hasPendingVotesInEditorSpaces can issue up to editorSpaceIds.length * GOVERNANCE_PROPOSAL_MAX_PAGES sequential REST requests (100 items/page). Since this runs as part of fetchBrowseSidebarData (used by the sidebar on page load), it can add significant latency and backend load for accounts that are editor in many spaces. Consider limiting the work (e.g., smaller page cap/limit, early exit after first page, or parallelizing with a small concurrency limit + request timeout) and/or caching the result server-side.

Suggested change
async function hasPendingVotesInEditorSpaces(memberSpaceId: string, editorSpaceIds: string[]): Promise<boolean> {
if (editorSpaceIds.length === 0) return false;
const config = Environment.getConfig();
for (const spaceId of editorSpaceIds) {
let cursor: string | null = null;
for (let page = 0; page < GOVERNANCE_PROPOSAL_MAX_PAGES; page++) {
const params = new URLSearchParams();
params.set('limit', String(GOVERNANCE_PROPOSAL_PAGE_SIZE));
params.set('status', 'PROPOSED');
params.set('orderBy', 'end_time');
params.set('orderDirection', 'desc');
params.set('voterId', memberSpaceId);
if (cursor) {
params.set('cursor', cursor);
}
const path = `/proposals/space/${encodePathSegment(spaceId)}/status?${params.toString()}`;
const result = await Effect.runPromise(
Effect.either(
restFetch<unknown>({
endpoint: config.api,
path,
})
)
);
if (Either.isLeft(result)) {
console.error(`Failed to fetch governance proposals for space ${spaceId}:`, result.left);
break;
}
const decoded = Schema.decodeUnknownEither(ApiProposalListResponseSchema)(result.right);
if (Either.isLeft(decoded)) {
console.error(`Failed to decode governance proposals for space ${spaceId}:`, decoded.left);
break;
}
if (decoded.right.proposals.some(proposal => proposal.userVote == null)) {
return true;
}
cursor = decoded.right.nextCursor;
if (!cursor) break;
}
}
return false;
const GOVERNANCE_PROPOSAL_SPACE_CONCURRENCY = 4;
async function hasPendingVotesInEditorSpace(
memberSpaceId: string,
spaceId: string,
config: ReturnType<typeof Environment.getConfig>
): Promise<boolean> {
let cursor: string | null = null;
for (let page = 0; page < GOVERNANCE_PROPOSAL_MAX_PAGES; page++) {
const params = new URLSearchParams();
params.set('limit', String(GOVERNANCE_PROPOSAL_PAGE_SIZE));
params.set('status', 'PROPOSED');
params.set('orderBy', 'end_time');
params.set('orderDirection', 'desc');
params.set('voterId', memberSpaceId);
if (cursor) {
params.set('cursor', cursor);
}
const path = `/proposals/space/${encodePathSegment(spaceId)}/status?${params.toString()}`;
const result = await Effect.runPromise(
Effect.either(
restFetch<unknown>({
endpoint: config.api,
path,
})
)
);
if (Either.isLeft(result)) {
console.error(`Failed to fetch governance proposals for space ${spaceId}:`, result.left);
return false;
}
const decoded = Schema.decodeUnknownEither(ApiProposalListResponseSchema)(result.right);
if (Either.isLeft(decoded)) {
console.error(`Failed to decode governance proposals for space ${spaceId}:`, decoded.left);
return false;
}
if (decoded.right.proposals.some(proposal => proposal.userVote == null)) {
return true;
}
cursor = decoded.right.nextCursor;
if (!cursor) break;
}
return false;
}
async function hasPendingVotesInEditorSpaces(memberSpaceId: string, editorSpaceIds: string[]): Promise<boolean> {
if (editorSpaceIds.length === 0) return false;
const config = Environment.getConfig();
const concurrency = Math.min(GOVERNANCE_PROPOSAL_SPACE_CONCURRENCY, editorSpaceIds.length);
let nextIndex = 0;
let foundPendingVote = false;
const worker = async () => {
while (!foundPendingVote) {
const currentIndex = nextIndex;
nextIndex += 1;
if (currentIndex >= editorSpaceIds.length) {
return;
}
const hasPendingVotes = await hasPendingVotesInEditorSpace(memberSpaceId, editorSpaceIds[currentIndex], config);
if (hasPendingVotes) {
foundPendingVote = true;
return;
}
}
};
await Promise.all(Array.from({ length: concurrency }, () => worker()));
return foundPendingVote;

Copilot uses AI. Check for mistakes.
Comment on lines +35 to +84
const GOVERNANCE_PROPOSAL_PAGE_SIZE = 100;
const GOVERNANCE_PROPOSAL_MAX_PAGES = 40;

async function hasPendingVotesInEditorSpaces(memberSpaceId: string, editorSpaceIds: string[]): Promise<boolean> {
if (editorSpaceIds.length === 0) return false;

const config = Environment.getConfig();

for (const spaceId of editorSpaceIds) {
let cursor: string | null = null;

for (let page = 0; page < GOVERNANCE_PROPOSAL_MAX_PAGES; page++) {
const params = new URLSearchParams();
params.set('limit', String(GOVERNANCE_PROPOSAL_PAGE_SIZE));
params.set('status', 'PROPOSED');
params.set('orderBy', 'end_time');
params.set('orderDirection', 'desc');
params.set('voterId', memberSpaceId);
if (cursor) {
params.set('cursor', cursor);
}

const path = `/proposals/space/${encodePathSegment(spaceId)}/status?${params.toString()}`;

const result = await Effect.runPromise(
Effect.either(
restFetch<unknown>({
endpoint: config.api,
path,
})
)
);

if (Either.isLeft(result)) {
console.error(`Failed to fetch governance proposals for space ${spaceId}:`, result.left);
break;
}

const decoded = Schema.decodeUnknownEither(ApiProposalListResponseSchema)(result.right);
if (Either.isLeft(decoded)) {
console.error(`Failed to decode governance proposals for space ${spaceId}:`, decoded.left);
break;
}

if (decoded.right.proposals.some(proposal => proposal.userVote == null)) {
return true;
}

cursor = decoded.right.nextCursor;
if (!cursor) break;

Copilot AI Apr 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This introduces a second copy of the REST pagination constants/logic (GOVERNANCE_PROPOSAL_PAGE_SIZE/GOVERNANCE_PROPOSAL_MAX_PAGES + cursor loop). There is already similar paging code in core/io/fetch-sidebar-counts.ts (REST_PAGE_SIZE/MAX_PAGES_PER_SPACE + fetchProposalPagesForSpace). Consider extracting a shared helper or reusing the existing one to avoid divergence if the API contract or paging defaults change.

Suggested change
const GOVERNANCE_PROPOSAL_PAGE_SIZE = 100;
const GOVERNANCE_PROPOSAL_MAX_PAGES = 40;
async function hasPendingVotesInEditorSpaces(memberSpaceId: string, editorSpaceIds: string[]): Promise<boolean> {
if (editorSpaceIds.length === 0) return false;
const config = Environment.getConfig();
for (const spaceId of editorSpaceIds) {
let cursor: string | null = null;
for (let page = 0; page < GOVERNANCE_PROPOSAL_MAX_PAGES; page++) {
const params = new URLSearchParams();
params.set('limit', String(GOVERNANCE_PROPOSAL_PAGE_SIZE));
params.set('status', 'PROPOSED');
params.set('orderBy', 'end_time');
params.set('orderDirection', 'desc');
params.set('voterId', memberSpaceId);
if (cursor) {
params.set('cursor', cursor);
}
const path = `/proposals/space/${encodePathSegment(spaceId)}/status?${params.toString()}`;
const result = await Effect.runPromise(
Effect.either(
restFetch<unknown>({
endpoint: config.api,
path,
})
)
);
if (Either.isLeft(result)) {
console.error(`Failed to fetch governance proposals for space ${spaceId}:`, result.left);
break;
}
const decoded = Schema.decodeUnknownEither(ApiProposalListResponseSchema)(result.right);
if (Either.isLeft(decoded)) {
console.error(`Failed to decode governance proposals for space ${spaceId}:`, decoded.left);
break;
}
if (decoded.right.proposals.some(proposal => proposal.userVote == null)) {
return true;
}
cursor = decoded.right.nextCursor;
if (!cursor) break;
const GOVERNANCE_PROPOSAL_PAGINATION = {
pageSize: 100,
maxPages: 40,
} as const;
async function fetchGovernanceProposalPagesForSpace(memberSpaceId: string, spaceId: string) {
const config = Environment.getConfig();
const pages: Array<Schema.Schema.Type<typeof ApiProposalListResponseSchema>> = [];
let cursor: string | null = null;
for (let page = 0; page < GOVERNANCE_PROPOSAL_PAGINATION.maxPages; page++) {
const params = new URLSearchParams();
params.set('limit', String(GOVERNANCE_PROPOSAL_PAGINATION.pageSize));
params.set('status', 'PROPOSED');
params.set('orderBy', 'end_time');
params.set('orderDirection', 'desc');
params.set('voterId', memberSpaceId);
if (cursor) {
params.set('cursor', cursor);
}
const path = `/proposals/space/${encodePathSegment(spaceId)}/status?${params.toString()}`;
const result = await Effect.runPromise(
Effect.either(
restFetch<unknown>({
endpoint: config.api,
path,
})
)
);
if (Either.isLeft(result)) {
console.error(`Failed to fetch governance proposals for space ${spaceId}:`, result.left);
break;
}
const decoded = Schema.decodeUnknownEither(ApiProposalListResponseSchema)(result.right);
if (Either.isLeft(decoded)) {
console.error(`Failed to decode governance proposals for space ${spaceId}:`, decoded.left);
break;
}
pages.push(decoded.right);
cursor = decoded.right.nextCursor;
if (!cursor) break;
}
return pages;
}
async function hasPendingVotesInEditorSpaces(memberSpaceId: string, editorSpaceIds: string[]): Promise<boolean> {
if (editorSpaceIds.length === 0) return false;
for (const spaceId of editorSpaceIds) {
const proposalPages = await fetchGovernanceProposalPagesForSpace(memberSpaceId, spaceId);
if (proposalPages.some(page => page.proposals.some(proposal => proposal.userVote == null))) {
return true;

Copilot uses AI. Check for mistakes.
Address Copilot review feedback on the governance red-dot indicator:
- Fan out pending-votes lookups across editor spaces (concurrency=4)
  with cooperative early-exit, instead of serial per-space paging.
  Reduces sidebar data-fetch latency for editor-heavy accounts.
- Add a visually-hidden 'Pending votes' label alongside the aria-hidden
  dot so screen-reader users get the same signal as sighted users.
After a vote transaction submits successfully, the list used to wait for
router.refresh() plus backend indexing before the voted proposal sank
to the bottom of its bucket — a visible multi-second delay.

Track optimistically-voted proposal ids in a jotai atom, written from
the AcceptOrReject success callback. Each list item is wrapped in a
client component that applies a CSS 'order' bump (+5000) within its
bucket when optimistically voted. Bucket base offsets (0/10000/20000)
guarantee the bump cannot overshoot into the next bucket.

When router.refresh() eventually returns fresh data with userVote set,
the server's own sort already places the item in the same spot, so the
optimistic state and server truth agree with no visual jitter.
Previously the optimistic vote fired in the mutation's onSuccess, which
still waits for the wallet transaction to submit. Fire it at click time
instead so the card sinks to the bottom immediately, and roll back in
onError if the transaction fails.
The red dot's server-computed hasPendingVotes boolean waited on
backend indexing, so it stayed visible after the user voted on the
last pending proposal until a full page refresh.

Return the list of pending proposal ids from the sidebar data fetch
instead of a boolean, and compute the dot's visibility client-side by
filtering out ids already present in the optimistic-vote atom. Once
the user votes on every pending proposal the dot disappears instantly;
if a future server refresh brings new pending ids, the dot reappears.
The GovernanceFilterMenu trigger showed the URL-derived label, so
after clicking an item the trigger kept the old value for 3-4s while
the server refetched. Track a local pendingLabel, override the trigger
immediately on click, and clear it once the server-derived label
catches up.

Also switch secondary/ghost/transparent/primary button focus styles
from focus: to focus-visible: so the thick inset border only appears
on keyboard focus — a mouse click on a dropdown trigger no longer
paints a lingering bold outline.
Three synthesized BrowseSidebarData fallbacks in explore and activity
routes still referenced the old hasPendingVotes boolean; rename them
to pendingVoteProposalIds to match the updated type.
Dropdown changes on /home kick off a full server fetch for the
proposal list and sidebar counts, which can take a couple of seconds.
The previous UI kept the old content on screen during the fetch,
making selections feel unresponsive.

Wrap dropdown navigation in useTransition and swap content to
skeletons when isPending stays true beyond 150ms. Cached/fast
responses resolve before the delay fires, so there is no skeleton
flash when data is already warm.
…onents

Three follow-up fixes on the governance home loading behavior:

- Drop the Suspense key that forced a remount on every filter change.
  Without it, useTransition can keep the old proposal cards visible
  during navigation and only the useTransition-driven skeleton shows
  after the 150ms delay — no more double-skeleton flash between the
  Suspense fallback and the transition skeleton.
- Align the transition skeleton's layout with the real cards so
  it doesn't visually jump when content swaps in.
- Always render the right-rail Sidebar. Its counts don't change
  with filter selections, so it shouldn't flicker to a skeleton.
- Wire AcceptOrRejectEditor and AcceptOrRejectMember into the same
  optimistic-vote atom as the space-level AcceptOrReject. Voting from
  the governance home review list now clears the red dot as soon as
  the last pending proposal is approved/rejected, with error
  rollback on failed transactions.
Four related fixes for governance loading + vote feedback:

- Drop the custom transition-driven skeleton and its 150ms delay.
  The double-shade flicker was the custom skeleton (bg-grey-01) briefly
  replacing the real Suspense fallback (bg-grey-02). With useTransition
  doing the right thing, the Suspense fallback only fires on an actual
  suspend — instant/prefetched navigations show no skeleton at all.
- Split the optimistic-vote atom into two: optimistic (click-driven,
  drives list reorder) and confirmed (mutation-success-driven, drives
  sidebar dot). Clicking 'Reject' no longer clears the dot prematurely;
  the dot clears the moment 'You rejected' appears.
- Wire useConfirmVote into all three AcceptOrReject components on
  vote success.
- Poll the browse sidebar data every 30s and refetch on window focus
  so a newly published proposal starts showing the red dot without a
  hard refresh.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 16 out of 16 changed files in this pull request and generated 5 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +94 to +105
{address ? (
<Link href={NavUtils.toHome()} className={`${navLinkClass} w-full min-w-0`}>
<BrowseNavIcon src={BROWSE_NAV_ICON.governance} />
<span className="min-w-0 flex-1 text-left">Governance</span>
{hasPendingVotes ? (
<>
<span className="sr-only">Pending votes</span>
<span className="inline-block h-1.5 w-1.5 shrink-0 rounded-full bg-red-01" aria-hidden="true" />
</>
) : null}
</Link>
) : null}

Copilot AI Apr 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Governance nav link is now only rendered when address is present. This removes the Governance entry entirely for signed-out users, which is a behavior change unrelated to the red-dot and may make the home/governance area unreachable from the sidebar. If Governance should remain accessible while signed out, render the link unconditionally and only conditionally render the pending-vote indicator/dot.

Suggested change
{address ? (
<Link href={NavUtils.toHome()} className={`${navLinkClass} w-full min-w-0`}>
<BrowseNavIcon src={BROWSE_NAV_ICON.governance} />
<span className="min-w-0 flex-1 text-left">Governance</span>
{hasPendingVotes ? (
<>
<span className="sr-only">Pending votes</span>
<span className="inline-block h-1.5 w-1.5 shrink-0 rounded-full bg-red-01" aria-hidden="true" />
</>
) : null}
</Link>
) : null}
<Link href={NavUtils.toHome()} className={`${navLinkClass} w-full min-w-0`}>
<BrowseNavIcon src={BROWSE_NAV_ICON.governance} />
<span className="min-w-0 flex-1 text-left">Governance</span>
{address && hasPendingVotes ? (
<>
<span className="sr-only">Pending votes</span>
<span className="inline-block h-1.5 w-1.5 shrink-0 rounded-full bg-red-01" aria-hidden="true" />
</>
) : null}
</Link>

Copilot uses AI. Check for mistakes.
Comment on lines 197 to +207
React.useEffect(() => {
let cancelled = false;
void loadBrowseSidebarData(walletAddress).then(d => {
if (!cancelled) setData(d);
});
const load = () => {
void loadBrowseSidebarData(walletAddress).then(d => {
if (!cancelled) setData(d);
});
};
load();
const interval = setInterval(load, 30_000);
const onFocus = () => load();
window.addEventListener('focus', onFocus);

Copilot AI Apr 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This adds a 30s polling interval plus a focus listener that re-runs loadBrowseSidebarData. That server action now performs multiple network calls (GraphQL + REST pagination across editor spaces), so this can substantially increase backend/API load per client session. Consider reducing frequency, polling only when the sidebar is open/visible, or switching to a cheaper endpoint (e.g., a single boolean/count for pending votes) to avoid repeated heavy fetches.

Copilot uses AI. Check for mistakes.
Comment on lines +36 to +54
const GOVERNANCE_PROPOSAL_PAGE_SIZE = 100;
const GOVERNANCE_PROPOSAL_MAX_PAGES = 40;
const GOVERNANCE_PROPOSAL_SPACE_CONCURRENCY = 4;

async function collectPendingVoteIdsInSpace(
memberSpaceId: string,
spaceId: string,
apiEndpoint: string
): Promise<string[]> {
const ids: string[] = [];
let cursor: string | null = null;

for (let page = 0; page < GOVERNANCE_PROPOSAL_MAX_PAGES; page++) {
const params = new URLSearchParams();
params.set('limit', String(GOVERNANCE_PROPOSAL_PAGE_SIZE));
params.set('status', 'PROPOSED');
params.set('orderBy', 'end_time');
params.set('orderDirection', 'desc');
params.set('voterId', memberSpaceId);

Copilot AI Apr 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

collectPendingVoteIdsInSpace can fetch up to 40 pages of 100 proposals per space, and collectPendingVoteIdsInEditorSpaces does this across all editor spaces. Combined with client-side polling, this can create a large number of REST calls and potentially large response payloads. Since the sidebar currently only needs to know whether any pending vote exists (or a small bounded set), consider short-circuiting after finding the first unvoted proposal, lowering GOVERNANCE_PROPOSAL_MAX_PAGES, and/or deduping and capping the collected IDs to keep server and API load bounded.

Copilot uses AI. Check for mistakes.
Comment on lines +5 to +10
/** Proposal ids whose card should already appear as "voted" (sunk to bottom of list). Written on click. */
const optimisticVotedIdsAtom = atom<Set<string>>(new Set<string>());

/** Proposal ids whose vote mutation resolved successfully. Written when useVote reports success. */
const confirmedVotedIdsAtom = atom<Set<string>>(new Set<string>());

Copilot AI Apr 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

optimisticVotedIdsAtom / confirmedVotedIdsAtom are global for the whole app session and aren’t scoped to the currently connected wallet / personal space. If a user disconnects and connects a different account without a full reload, these sets can leak state across accounts and incorrectly suppress the pending-vote dot or reorder proposal lists. Consider keying these sets by account (e.g., atom family keyed by address/spaceId) or clearing them on connection change/logout.

Copilot uses AI. Check for mistakes.
Comment on lines +14 to +19
const OPTIMISTIC_VOTE_ORDER_BUMP = 5000;

export function ProposalListItem({ proposalId, baseOrder, canSink, children }: Props) {
const isOptimistic = useIsOptimisticallyVoted(proposalId);
const order = isOptimistic && canSink ? baseOrder + OPTIMISTIC_VOTE_ORDER_BUMP : baseOrder;
return <div style={{ order }}>{children}</div>;

Copilot AI Apr 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This uses CSS flex order to change the visual ordering of proposals without changing their DOM order. That can create an accessibility mismatch (screen readers and keyboard users may encounter items in a different order than what’s displayed). If the order is meaningful (unvoted first), consider re-sorting the rendered children (or the proposals array) in React based on optimistic state rather than relying on CSS order.

Copilot uses AI. Check for mistakes.
Replace the per-tab full sidebar rescan with a Redis-backed cache kept
in sync by gaia notification-service webhooks:

- POST /api/geo-notifications: receives per-editor fan-out webhooks,
  verifies HMAC-SHA256 on raw body bytes, dedupes by idempotency_key,
  and mutates a per-user pending-vote set in Upstash:
    proposal_created          -> SADD
    proposal_voted (self)     -> SREM
    proposal_executed|rejected -> SREM
  Bounty/unhandled events are 2xxed so the delivery worker stops
  retrying. Redis failures return 503 so the worker retries.

- GET /api/pending-votes: resolves the caller's user_space_id from
  the WALLET_ADDRESS cookie (cached in Redis for 24h) and returns
  {proposalIds}. Redis miss falls back to the authoritative REST
  scan and seeds the cache.

- Sidebar: initial full load unchanged. Pending-vote polling split
  into its own effect, bumped to 60s, and routed to /api/pending-votes
  when NEXT_PUBLIC_USE_NOTIFICATION_SERVICE=true. Poll is skipped when
  the user is not an editor of any space or the tab is hidden.

Per-poll cost drops from ~5+ GraphQL + N-space REST scan to one Redis
SMEMBERS. Self-heals on the 24h TTL if a webhook is ever missed.

Requires gaia to register the webhook URL + shared secret in its
app_webhooks table before the flag is flipped on.
@ohohoreilly

Copy link
Copy Markdown
Contributor

Closing as part of a sweep of the open-PR queue. Not a judgement on the work — reopen if you still want it and I will help get it current.

Opened 2026-04-20 and now conflicting with master. At this distance a rebase is usually more work than redoing the change against current code, and the surrounding code has moved a long way underneath "fix: red dot next to governance tab".

@webmagic123 — if the idea still stands but the branch does not, a fresh PR or a ticket is probably a better route than reviving this one.

Nothing is discarded: the branch and its history remain, and reopening costs a click.

Context: 74 PRs were open, 25 older than two months, the oldest from February. The point is to make the queue mean something so genuinely ready work is visible rather than buried — #2449 sat ready for three days this week partly because of the noise. Only non-draft, conflicting PRs are in scope; drafts and anything still mergeable are being left alone.

This branch was successfully deployed

1 active deployment
Preview — 5d54f095 Deployed Apr 22, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants