From 949ed4ff24fa540b0e36bcbfecc199c743645a9e Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Mon, 6 Jul 2026 15:24:08 +0200 Subject: [PATCH 1/8] feat(chatter): community pulse POC on post pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an experimental "Community Pulse" section to the post page: an AI-synthesized read of the cross-platform discussion (X, Hacker News, Reddit) — verdict, sentiment split, per-platform divergence, consensus vs. contention, a bottom line, and the strongest counterpoint — with the raw per-platform threads available as an expandable drill-down. POC only: data is mocked in `features/chatter/mockData.ts` (single swap point for a real query). Gated behind the `article_chatter` flag (default false; auto-shown in development). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/components/post/PostEngagements.tsx | 12 + .../components/ChatterPlatformCard.tsx | 188 ++++++++++++ .../components/ChatterSection.spec.tsx | 59 ++++ .../chatter/components/ChatterSection.tsx | 149 ++++++++++ .../chatter/components/CommunityPulse.tsx | 267 ++++++++++++++++++ .../chatter/hooks/useArticleChatter.ts | 27 ++ .../shared/src/features/chatter/mockData.ts | 187 ++++++++++++ .../shared/src/features/chatter/platforms.tsx | 67 +++++ packages/shared/src/features/chatter/types.ts | 85 ++++++ packages/shared/src/lib/featureManagement.ts | 2 + 10 files changed, 1043 insertions(+) create mode 100644 packages/shared/src/features/chatter/components/ChatterPlatformCard.tsx create mode 100644 packages/shared/src/features/chatter/components/ChatterSection.spec.tsx create mode 100644 packages/shared/src/features/chatter/components/ChatterSection.tsx create mode 100644 packages/shared/src/features/chatter/components/CommunityPulse.tsx create mode 100644 packages/shared/src/features/chatter/hooks/useArticleChatter.ts create mode 100644 packages/shared/src/features/chatter/mockData.ts create mode 100644 packages/shared/src/features/chatter/platforms.tsx create mode 100644 packages/shared/src/features/chatter/types.ts diff --git a/packages/shared/src/components/post/PostEngagements.tsx b/packages/shared/src/components/post/PostEngagements.tsx index b3797e253c2..76615c505f8 100644 --- a/packages/shared/src/components/post/PostEngagements.tsx +++ b/packages/shared/src/components/post/PostEngagements.tsx @@ -32,6 +32,10 @@ import { usePlusSubscription } from '../../hooks/usePlusSubscription'; import SocialBar from '../cards/socials/SocialBar'; import { PostContentReminder } from './common/PostContentReminder'; import { useSettingsContext } from '../../contexts/SettingsContext'; +import { useConditionalFeature } from '../../hooks/useConditionalFeature'; +import { featureArticleChatter } from '../../lib/featureManagement'; +import { isDevelopment } from '../../lib/constants'; +import { ChatterSection } from '../../features/chatter/components/ChatterSection'; const AuthorOnboarding = dynamic( () => import(/* webpackChunkName: "authorOnboarding" */ './AuthorOnboarding'), @@ -77,6 +81,13 @@ function PostEngagements({ false, ); const [linkClicked, setLinkClicked] = useState(false); + const { value: chatterFlag } = useConditionalFeature({ + feature: featureArticleChatter, + shouldEvaluate: true, + }); + // Local-only preview; the committed flag default stays false so it never + // ships to prod until GrowthBook ramps it. + const showChatter = chatterFlag || isDevelopment; const handleLinkClick = () => { setLinkClicked(true); @@ -162,6 +173,7 @@ function PostEngagements({ CommentInputOrModal={CommentInputOrModal} /> {!isPlus && } + {showChatter && } ( + + {initial} + +); + +const CommentRow = ({ comment }: { comment: ChatterComment }): ReactElement => ( +
+ +
+
+ + {comment.author} + + {comment.handle && ( + + {comment.handle} + + )} + + · {comment.timeAgo} + +
+ + {comment.text} + +
+ + {' '} + {largeNumberFormat(comment.upvotes)} + + {typeof comment.replies === 'number' && ( + + {' '} + {largeNumberFormat(comment.replies)} + + )} +
+
+
+); + +interface ChatterPlatformCardProps { + source: ChatterSource; +} + +export const ChatterPlatformCard = ({ + source, +}: ChatterPlatformCardProps): ReactElement => { + const visual = platformVisuals[source.platform]; + const [isOpen, toggleOpen] = useToggle(source.defaultOpen ?? false); + const chipStyle: CSSProperties | undefined = visual.chipColor + ? { background: visual.chipColor } + : undefined; + + return ( +
+ + +
+ + {visual.mark} + +
+ + {source.heading} + + + {source.subtitle} + +
+
+ {source.stats.map((stat) => ( + + {stat.value} {stat.label} + + ))} +
+
+ +
+ + AI summary + + + {source.mood} {source.summary} + +
+ + + + {isOpen && ( +
+ {source.comments.map((comment) => ( + + ))} +
+ )} + + +
+ ); +}; diff --git a/packages/shared/src/features/chatter/components/ChatterSection.spec.tsx b/packages/shared/src/features/chatter/components/ChatterSection.spec.tsx new file mode 100644 index 00000000000..8cfd04325fc --- /dev/null +++ b/packages/shared/src/features/chatter/components/ChatterSection.spec.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import type { Post } from '../../../graphql/posts'; +import { ChatterSection } from './ChatterSection'; + +const post = { id: 'p1', permalink: 'https://example.com/article' } as Post; + +const renderComponent = () => render(); + +describe('ChatterSection', () => { + it('leads with the synthesized community pulse', async () => { + renderComponent(); + + await waitFor(() => + expect(screen.getByText('Community Pulse')).toBeInTheDocument(), + ); + expect(screen.getByText(/The room is split/i)).toBeInTheDocument(); + expect(screen.getByText('Common ground')).toBeInTheDocument(); + expect(screen.getByText('Where it splits')).toBeInTheDocument(); + expect(screen.getByText('Bottom line')).toBeInTheDocument(); + expect(screen.getByText(/strongest pushback/i)).toBeInTheDocument(); + // Cross-platform divergence read labels. + expect(screen.getByText('Skeptical')).toBeInTheDocument(); + expect(screen.getByText('Worried about hiring')).toBeInTheDocument(); + }); + + it('keeps raw platform threads collapsed until requested', async () => { + renderComponent(); + await waitFor(() => + expect(screen.getByText('Community Pulse')).toBeInTheDocument(), + ); + + expect(screen.queryByText(/X · 3 posts trending/i)).not.toBeInTheDocument(); + + fireEvent.click( + screen.getByRole('button', { name: /See the raw discussion/i }), + ); + + expect(screen.getByText(/X · 3 posts trending/i)).toBeInTheDocument(); + expect(screen.getByText(/Hacker News · front page/i)).toBeInTheDocument(); + // X starts expanded, so its top post is visible. + expect(screen.getByText(/@t3dotgg/i)).toBeInTheDocument(); + }); + + it('filters raw sources by platform', async () => { + renderComponent(); + await waitFor(() => + expect(screen.getByText('Community Pulse')).toBeInTheDocument(), + ); + fireEvent.click( + screen.getByRole('button', { name: /See the raw discussion/i }), + ); + + fireEvent.click(screen.getByRole('button', { name: 'Hacker News' })); + + expect(screen.queryByText(/X · 3 posts trending/i)).not.toBeInTheDocument(); + expect(screen.getByText(/Hacker News · front page/i)).toBeInTheDocument(); + }); +}); diff --git a/packages/shared/src/features/chatter/components/ChatterSection.tsx b/packages/shared/src/features/chatter/components/ChatterSection.tsx new file mode 100644 index 00000000000..d894e4f84ee --- /dev/null +++ b/packages/shared/src/features/chatter/components/ChatterSection.tsx @@ -0,0 +1,149 @@ +import type { ReactElement } from 'react'; +import React, { useState } from 'react'; +import classNames from 'classnames'; +import type { Post } from '../../../graphql/posts'; +import { ChatterPlatform } from '../types'; +import { platformVisuals } from '../platforms'; +import { useArticleChatter } from '../hooks/useArticleChatter'; +import { ChatterPlatformCard } from './ChatterPlatformCard'; +import { CommunityPulse } from './CommunityPulse'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../../../components/typography/Typography'; +import { AiIcon, ArrowIcon } from '../../../components/icons'; +import { IconSize } from '../../../components/Icon'; +import { ElementPlaceholder } from '../../../components/ElementPlaceholder'; +import { useToggle } from '../../../hooks/useToggle'; + +type ChatterFilter = ChatterPlatform | 'all'; + +const filters: { value: ChatterFilter; label: string }[] = [ + { value: 'all', label: 'All sources' }, + { value: ChatterPlatform.X, label: platformVisuals[ChatterPlatform.X].label }, + { + value: ChatterPlatform.HackerNews, + label: platformVisuals[ChatterPlatform.HackerNews].label, + }, + { + value: ChatterPlatform.Reddit, + label: platformVisuals[ChatterPlatform.Reddit].label, + }, +]; + +const LoadingSkeleton = (): ReactElement => ( +
+
+ + +
+ + + +
+); + +interface ChatterSectionProps { + post: Post; + className?: string; +} + +export const ChatterSection = ({ + post, + className, +}: ChatterSectionProps): ReactElement => { + const { chatter, isLoading } = useArticleChatter(post); + const [filter, setFilter] = useState('all'); + const [isRawOpen, toggleRawOpen] = useToggle(false); + + const sources = + chatter?.sources.filter( + (source) => filter === 'all' || source.platform === filter, + ) ?? []; + + return ( +
+
+ + + +
+ + The Chatter — how the wider dev community reacted + + + An AI read of the conversation across X, Hacker News & Reddit — not + just the raw comments. + +
+
+ + {isLoading || !chatter ? ( + + ) : ( + <> + + +
+ + + {isRawOpen && ( + <> +
+ {filters.map((item) => ( + + ))} +
+ {sources.map((source) => ( + + ))} + + )} +
+ + )} + + + Summaries are AI-generated from public posts and may be imperfect. + Always open the source thread for full context. + +
+ ); +}; diff --git a/packages/shared/src/features/chatter/components/CommunityPulse.tsx b/packages/shared/src/features/chatter/components/CommunityPulse.tsx new file mode 100644 index 00000000000..d1d86dc5a6d --- /dev/null +++ b/packages/shared/src/features/chatter/components/CommunityPulse.tsx @@ -0,0 +1,267 @@ +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import type { + CommunityMomentum, + CommunityPulse as CommunityPulseData, + CommunityStance, + ControversyLevel, + SentimentSplit, +} from '../types'; +import { platformVisuals } from '../platforms'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '../../../components/typography/Typography'; +import { + AiIcon, + SparkleIcon, + TrendingIcon, + VIcon, +} from '../../../components/icons'; +import { IconSize } from '../../../components/Icon'; +import { largeNumberFormat } from '../../../lib/numberFormat'; + +const stanceDot: Record = { + positive: 'bg-status-success', + divided: 'bg-status-warning', + critical: 'bg-status-error', + mixed: 'bg-status-warning', +}; + +const controversyText: Record = { + calm: 'text-status-success', + lively: 'text-status-warning', + heated: 'text-status-error', +}; + +const momentumLabel: Record = { + rising: 'Rising', + steady: 'Steady', + cooling: 'Cooling', +}; + +const leanDot = (split: SentimentSplit): string => { + const net = split.agree - split.disagree; + if (net >= 10) { + return 'bg-status-success'; + } + if (net <= -10) { + return 'bg-status-error'; + } + return 'bg-status-warning'; +}; + +const Section = ({ children }: { children: ReactNode }): ReactElement => ( +
+ {children} +
+); + +const SectionLabel = ({ children }: { children: ReactNode }): ReactElement => ( + + {children} + +); + +const SentimentBar = ({ split }: { split: SentimentSplit }): ReactElement => ( +
+ + + +
+); + +interface CommunityPulseProps { + pulse: CommunityPulseData; +} + +export const CommunityPulse = ({ + pulse, +}: CommunityPulseProps): ReactElement => ( +
+ {/* Verdict + at-a-glance meta */} +
+
+ + + + Community Pulse +
+ + {pulse.verdict} + +
+ + + + {pulse.stance} + + + + {pulse.controversyLevel} + + + {' '} + {momentumLabel[pulse.momentum]} + + {largeNumberFormat(pulse.totalVoices)} voices + {pulse.perPlatform.length} platforms +
+
+ + {/* Overall sentiment */} +
+ +
+ + + Agree {pulse.overallSplit.agree}% + + + + Mixed {pulse.overallSplit.mixed}% + + + + Disagree {pulse.overallSplit.disagree}% + +
+
+ + {/* Cross-platform divergence — text-forward, no competing bars */} +
+ How each platform leans +
+ {pulse.perPlatform.map((item) => { + const visual = platformVisuals[item.platform]; + return ( +
+ + {visual.mark} + + + {visual.label} + + + {item.read} + + +
+ ); + })} +
+
+ + {/* The debate — stacked, calm */} +
+ Common ground +
    + {pulse.consensus.map((item) => ( +
  • + + + {item} + +
  • + ))} +
+
+ Where it splits +
+ {pulse.contention.map((tag) => ( + + {tag} + + ))} +
+
+
+ + {/* Bottom line */} +
+
+ +
+ Bottom line + + {pulse.bottomLine} + +
+
+
+ + {/* Strongest counterpoint */} +
+
+ Strongest pushback + + “{pulse.counterpoint.text}” + + + — {pulse.counterpoint.attribution} + +
+
+
+); diff --git a/packages/shared/src/features/chatter/hooks/useArticleChatter.ts b/packages/shared/src/features/chatter/hooks/useArticleChatter.ts new file mode 100644 index 00000000000..5995b09989c --- /dev/null +++ b/packages/shared/src/features/chatter/hooks/useArticleChatter.ts @@ -0,0 +1,27 @@ +import { useEffect, useState } from 'react'; +import type { Post } from '../../../graphql/posts'; +import type { ArticleChatter } from '../types'; +import { getMockChatter } from '../mockData'; + +interface UseArticleChatter { + chatter?: ArticleChatter; + isLoading: boolean; +} + +/** + * POC: returns mocked cross-platform chatter after a short simulated fetch so + * the loading state is exercised. Swap the timeout for a real query (keyed off + * the post permalink / yggdrasilId) when the backend exists. + */ +export const useArticleChatter = (post: Post): UseArticleChatter => { + const [chatter, setChatter] = useState(); + + useEffect(() => { + setChatter(undefined); + const timeout = setTimeout(() => setChatter(getMockChatter(post)), 500); + return () => clearTimeout(timeout); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [post.id]); + + return { chatter, isLoading: !chatter }; +}; diff --git a/packages/shared/src/features/chatter/mockData.ts b/packages/shared/src/features/chatter/mockData.ts new file mode 100644 index 00000000000..5be8ef1abfd --- /dev/null +++ b/packages/shared/src/features/chatter/mockData.ts @@ -0,0 +1,187 @@ +import type { Post } from '../../graphql/posts'; +import type { ArticleChatter } from './types'; +import { ChatterPlatform } from './types'; + +// POC: hard-coded chatter so the feature can be demoed end-to-end without a +// backend. Replace `getMockChatter` with a real query keyed off the post's +// permalink / yggdrasilId when the data source is ready. +export const getMockChatter = (post: Post): ArticleChatter => ({ + pulse: { + verdict: + 'The room is split — impressive results, nobody’s sold on the reason.', + stance: 'divided', + controversyLevel: 'heated', + totalVoices: 1750, + momentum: 'rising', + overallSplit: { agree: 46, mixed: 22, disagree: 32 }, + perPlatform: [ + { + platform: ChatterPlatform.X, + read: 'Polarized & loud', + split: { agree: 52, mixed: 8, disagree: 40 }, + }, + { + platform: ChatterPlatform.HackerNews, + read: 'Skeptical', + split: { agree: 34, mixed: 26, disagree: 40 }, + }, + { + platform: ChatterPlatform.Reddit, + read: 'Worried about hiring', + split: { agree: 41, mixed: 24, disagree: 35 }, + }, + ], + consensus: [ + 'The p99 latency win (340ms → 12ms) is real and impressive.', + 'The biggest gain came from consolidating 12 services down to 4.', + ], + contention: [ + 'Language vs. architecture', + 'Hiring & retention', + 'Was the timeline worth it?', + 'ROI not shown', + ], + bottomLine: + 'Read it for the consolidation strategy, not as a language endorsement — the performance win is credible, but the ROI and hiring case is left unmade.', + counterpoint: { + text: 'You had a people problem, not a language problem — the same team would have hit these numbers just cleaning up the original codebase.', + attribution: 'the most-upvoted take on both X and Hacker News', + }, + }, + sources: [ + { + platform: ChatterPlatform.X, + heading: 'X · 3 posts trending', + subtitle: 'from devs you follow & large accounts', + stats: [ + { label: 'reposts', value: '2.4K' }, + { label: 'likes', value: '18K' }, + ], + mood: 'X is on fire.', + moodTone: 'hot', + summary: + 'Big accounts are dunking on the piece as premature optimization, while the people who actually shipped it are defending the numbers. The most-quoted take: “you had a people problem, not a language problem.”', + sourceUrl: post.permalink ?? 'https://x.com', + sourceLabel: 'Open thread on X', + defaultOpen: true, + comments: [ + { + id: 'x1', + author: 'Theo', + handle: '@t3dotgg', + avatar: 'linear-gradient(135deg,#1d9bf0,#0f6fb8)', + timeAgo: '6h', + text: 'rewriting to fix a p99 you caused with bad queries is such a 2026 move. the benchmarks are real but so is the 8-month timeline nobody is tweeting about 👀', + upvotes: 4100, + replies: 312, + }, + { + id: 'x2', + author: 'Dax', + handle: '@thdxr', + avatar: 'linear-gradient(135deg,#f2542d,#b8341a)', + timeAgo: '4h', + text: 'the actual win here is they deleted 40% of the codebase during the rewrite. that is the story. the tech is downstream of finally being allowed to refactor.', + upvotes: 2800, + replies: 96, + }, + { + id: 'x3', + author: 'Jane M.', + handle: '@janedev', + avatar: 'linear-gradient(135deg,#8b5cf6,#6d28d9)', + timeAgo: '2h', + text: 'p99 340ms → 12ms is not “premature optimization” when you pay per request at this scale. do the napkin math before the dunk.', + upvotes: 1900, + replies: 141, + }, + ], + }, + { + platform: ChatterPlatform.HackerNews, + heading: 'Hacker News · front page', + subtitle: 'exact URL match · 3 hrs ago', + stats: [ + { label: 'points', value: '842' }, + { label: 'comments', value: '396' }, + ], + mood: 'HN is skeptical of the numbers.', + moodTone: 'split', + summary: + 'The top thread argues the gains came from the architecture cleanup, not the tool itself — and that most stacks would land similar numbers after a second attempt. A counter-thread defends the compile-time guarantees for the on-call burden.', + sourceUrl: 'https://news.ycombinator.com', + sourceLabel: 'Open discussion on Hacker News', + defaultOpen: true, + comments: [ + { + id: 'hn1', + author: 'tptacek', + avatar: '#ff6600', + timeAgo: '2h ago', + text: 'The interesting number isn’t the p99, it’s that they went from 12 services to 4. You can get most of this in any stack once you’re allowed to consolidate.', + upvotes: 284, + }, + { + id: 'hn2', + author: 'steveklabnik', + avatar: '#ff8a3d', + timeAgo: '1h ago', + text: 'Every “rewrite” post is really a “we finally understood the problem domain” post. The second implementation of anything is faster.', + upvotes: 176, + }, + { + id: 'hn3', + author: 'throwaway_9f2', + avatar: '#9a5a2a', + timeAgo: '44m ago', + text: 'Nobody’s asking about hiring. We tried this and spent a year unable to backfill. The maintenance cost never shows up in these posts.', + upvotes: 143, + }, + ], + }, + { + platform: ChatterPlatform.Reddit, + heading: 'r/programming', + subtitle: 'also in r/ExperiencedDevs', + stats: [ + { label: 'upvotes', value: '3.1K' }, + { label: 'comments', value: '512' }, + ], + mood: 'Reddit is arguing about staffing, not tech.', + moodTone: 'mixed', + summary: + 'r/programming’s top comments question whether you can realistically hire and retain the team this requires, while the niche subs celebrate the write-up as validation. Recurring joke: this has officially become the new “just use Postgres.”', + sourceUrl: 'https://www.reddit.com/r/programming', + sourceLabel: 'Open thread on Reddit', + comments: [ + { + id: 'r1', + author: 'u/senior_dev_burnout', + avatar: 'linear-gradient(135deg,#ff4500,#c62f00)', + timeAgo: '3h', + text: 'Cool benchmarks. Now try hiring 4 mid-level engineers for this in a market where every one of them wants staff-level comp. The TCO section is always suspiciously empty.', + upvotes: 1200, + replies: 88, + }, + { + id: 'r2', + author: 'u/rustacean_andy', + avatar: 'linear-gradient(135deg,#ff8717,#d95f00)', + timeAgo: '2h', + text: 'As someone who did exactly this migration: onboarding was 3 weeks, not the 3 months this thread assumes. Our incident count dropped to near zero.', + upvotes: 740, + replies: 52, + }, + { + id: 'r3', + author: 'u/justusepostgres', + avatar: 'linear-gradient(135deg,#7c7c7c,#4a4a4a)', + timeAgo: '1h', + text: 'this is the new “just use Postgres” and I’m here for it 🍿', + upvotes: 2000, + replies: 31, + }, + ], + }, + ], +}); diff --git a/packages/shared/src/features/chatter/platforms.tsx b/packages/shared/src/features/chatter/platforms.tsx new file mode 100644 index 00000000000..12d3189640a --- /dev/null +++ b/packages/shared/src/features/chatter/platforms.tsx @@ -0,0 +1,67 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { ChatterPlatform } from './types'; + +export interface PlatformVisual { + label: string; + // The card's left stripe + icon chip color. X uses a theme-aware token class + // (black-on-light / white-on-dark) since its brand is monochrome; HN and + // Reddit use raw brand hex — deliberately not design-system tokens, because + // they must match each platform's real identity. + chipClassName?: string; + chipColor?: string; + markClassName?: string; + mark: ReactElement; +} + +const XMark = ( + + + +); + +const HackerNewsMark = ( + + Y + +); + +const RedditMark = ( + + + + + + +); + +export const platformVisuals: Record = { + [ChatterPlatform.X]: { + label: 'X', + chipClassName: 'bg-text-primary', + markClassName: 'text-background-default', + mark: XMark, + }, + [ChatterPlatform.HackerNews]: { + label: 'Hacker News', + chipColor: '#ff6600', + mark: HackerNewsMark, + }, + [ChatterPlatform.Reddit]: { + label: 'Reddit', + chipColor: '#ff4500', + mark: RedditMark, + }, +}; diff --git a/packages/shared/src/features/chatter/types.ts b/packages/shared/src/features/chatter/types.ts new file mode 100644 index 00000000000..f256352c788 --- /dev/null +++ b/packages/shared/src/features/chatter/types.ts @@ -0,0 +1,85 @@ +export enum ChatterPlatform { + X = 'x', + HackerNews = 'hackernews', + Reddit = 'reddit', +} + +export type ChatterMoodTone = 'hot' | 'split' | 'mixed' | 'positive'; + +export interface ChatterComment { + id: string; + author: string; + handle?: string; + // Placeholder avatar background (CSS color/gradient) until real avatars exist. + avatar: string; + timeAgo: string; + text: string; + upvotes: number; + replies?: number; +} + +export interface ChatterStat { + label: string; + value: string; +} + +export interface ChatterSource { + platform: ChatterPlatform; + heading: string; + subtitle: string; + stats: ChatterStat[]; + /** Short bold lead for the AI summary, e.g. "X is on fire." */ + mood: string; + moodTone: ChatterMoodTone; + /** The AI-generated TLDR body. */ + summary: string; + sourceUrl: string; + sourceLabel: string; + comments: ChatterComment[]; + /** Whether the comment list starts expanded. */ + defaultOpen?: boolean; +} + +export type CommunityStance = 'positive' | 'divided' | 'critical' | 'mixed'; +export type ControversyLevel = 'calm' | 'lively' | 'heated'; +export type CommunityMomentum = 'rising' | 'steady' | 'cooling'; + +export interface SentimentSplit { + agree: number; + mixed: number; + disagree: number; +} + +export interface PlatformSentiment { + platform: ChatterPlatform; + /** One-word read of the platform's mood, e.g. "Skeptical". */ + read: string; + split: SentimentSplit; +} + +export interface CommunityCounterpoint { + text: string; + attribution: string; +} + +export interface CommunityPulse { + verdict: string; + stance: CommunityStance; + controversyLevel: ControversyLevel; + totalVoices: number; + momentum: CommunityMomentum; + overallSplit: SentimentSplit; + perPlatform: PlatformSentiment[]; + /** What the room broadly agrees on (keep to 2-3 short points). */ + consensus: string[]; + /** The fault lines, as short scannable tags. */ + contention: string[]; + /** One-sentence synthesized takeaway. */ + bottomLine: string; + counterpoint: CommunityCounterpoint; +} + +export interface ArticleChatter { + pulse: CommunityPulse; + sources: ChatterSource[]; +} diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index d53abbdb315..f3edac6e825 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -39,6 +39,8 @@ export const featurePostPageHighlights = new Feature( ); export const featurePostRedesign = new Feature('post_redesign', false); +export const featureArticleChatter = new Feature('article_chatter', false); + // @ts-expect-error stale feature without default export const plusTakeoverContent = new Feature<{ title: string; From 12ad24df67e0d5da9b6b17a5efa189de5c5973a2 Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Mon, 6 Jul 2026 15:53:26 +0200 Subject: [PATCH 2/8] chore(chatter): drop Reddit from the POC Focus the POC on X and Hacker News. Removes the Reddit source, its per-platform sentiment row, and the Reddit filter chip; updates copy and tuned the synthesized totals. The Reddit platform config/enum stays so it can be re-added with data later. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/ChatterSection.spec.tsx | 2 +- .../chatter/components/ChatterSection.tsx | 8 +-- .../shared/src/features/chatter/mockData.ts | 53 +------------------ 3 files changed, 5 insertions(+), 58 deletions(-) diff --git a/packages/shared/src/features/chatter/components/ChatterSection.spec.tsx b/packages/shared/src/features/chatter/components/ChatterSection.spec.tsx index 8cfd04325fc..2b13125b106 100644 --- a/packages/shared/src/features/chatter/components/ChatterSection.spec.tsx +++ b/packages/shared/src/features/chatter/components/ChatterSection.spec.tsx @@ -20,8 +20,8 @@ describe('ChatterSection', () => { expect(screen.getByText('Bottom line')).toBeInTheDocument(); expect(screen.getByText(/strongest pushback/i)).toBeInTheDocument(); // Cross-platform divergence read labels. + expect(screen.getByText('Polarized & loud')).toBeInTheDocument(); expect(screen.getByText('Skeptical')).toBeInTheDocument(); - expect(screen.getByText('Worried about hiring')).toBeInTheDocument(); }); it('keeps raw platform threads collapsed until requested', async () => { diff --git a/packages/shared/src/features/chatter/components/ChatterSection.tsx b/packages/shared/src/features/chatter/components/ChatterSection.tsx index d894e4f84ee..42b2a1faff6 100644 --- a/packages/shared/src/features/chatter/components/ChatterSection.tsx +++ b/packages/shared/src/features/chatter/components/ChatterSection.tsx @@ -26,10 +26,6 @@ const filters: { value: ChatterFilter; label: string }[] = [ value: ChatterPlatform.HackerNews, label: platformVisuals[ChatterPlatform.HackerNews].label, }, - { - value: ChatterPlatform.Reddit, - label: platformVisuals[ChatterPlatform.Reddit].label, - }, ]; const LoadingSkeleton = (): ReactElement => ( @@ -79,8 +75,8 @@ export const ChatterSection = ({ type={TypographyType.Footnote} color={TypographyColor.Tertiary} > - An AI read of the conversation across X, Hacker News & Reddit — not - just the raw comments. + An AI read of the conversation across X & Hacker News — not just the + raw comments. diff --git a/packages/shared/src/features/chatter/mockData.ts b/packages/shared/src/features/chatter/mockData.ts index 5be8ef1abfd..a0ca361b074 100644 --- a/packages/shared/src/features/chatter/mockData.ts +++ b/packages/shared/src/features/chatter/mockData.ts @@ -11,9 +11,9 @@ export const getMockChatter = (post: Post): ArticleChatter => ({ 'The room is split — impressive results, nobody’s sold on the reason.', stance: 'divided', controversyLevel: 'heated', - totalVoices: 1750, + totalVoices: 1240, momentum: 'rising', - overallSplit: { agree: 46, mixed: 22, disagree: 32 }, + overallSplit: { agree: 44, mixed: 18, disagree: 38 }, perPlatform: [ { platform: ChatterPlatform.X, @@ -25,11 +25,6 @@ export const getMockChatter = (post: Post): ArticleChatter => ({ read: 'Skeptical', split: { agree: 34, mixed: 26, disagree: 40 }, }, - { - platform: ChatterPlatform.Reddit, - read: 'Worried about hiring', - split: { agree: 41, mixed: 24, disagree: 35 }, - }, ], consensus: [ 'The p99 latency win (340ms → 12ms) is real and impressive.', @@ -139,49 +134,5 @@ export const getMockChatter = (post: Post): ArticleChatter => ({ }, ], }, - { - platform: ChatterPlatform.Reddit, - heading: 'r/programming', - subtitle: 'also in r/ExperiencedDevs', - stats: [ - { label: 'upvotes', value: '3.1K' }, - { label: 'comments', value: '512' }, - ], - mood: 'Reddit is arguing about staffing, not tech.', - moodTone: 'mixed', - summary: - 'r/programming’s top comments question whether you can realistically hire and retain the team this requires, while the niche subs celebrate the write-up as validation. Recurring joke: this has officially become the new “just use Postgres.”', - sourceUrl: 'https://www.reddit.com/r/programming', - sourceLabel: 'Open thread on Reddit', - comments: [ - { - id: 'r1', - author: 'u/senior_dev_burnout', - avatar: 'linear-gradient(135deg,#ff4500,#c62f00)', - timeAgo: '3h', - text: 'Cool benchmarks. Now try hiring 4 mid-level engineers for this in a market where every one of them wants staff-level comp. The TCO section is always suspiciously empty.', - upvotes: 1200, - replies: 88, - }, - { - id: 'r2', - author: 'u/rustacean_andy', - avatar: 'linear-gradient(135deg,#ff8717,#d95f00)', - timeAgo: '2h', - text: 'As someone who did exactly this migration: onboarding was 3 weeks, not the 3 months this thread assumes. Our incident count dropped to near zero.', - upvotes: 740, - replies: 52, - }, - { - id: 'r3', - author: 'u/justusepostgres', - avatar: 'linear-gradient(135deg,#7c7c7c,#4a4a4a)', - timeAgo: '1h', - text: 'this is the new “just use Postgres” and I’m here for it 🍿', - upvotes: 2000, - replies: 31, - }, - ], - }, ], }); From 0ee50a1accd96a97c8b93f86d6898329dc503cb8 Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Tue, 7 Jul 2026 09:44:43 +0200 Subject: [PATCH 3/8] feat(chatter): use real "Better Models: Worse Tools" dataset as mock Swaps the placeholder mock for a real captured community-pulse dataset (Armin Ronacher's post; HN exact-URL match + X amplification). Makes comment upvotes optional so sources without per-comment scores render cleanly instead of showing "0". Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/ChatterPlatformCard.tsx | 85 +++++---- .../components/ChatterSection.spec.tsx | 24 ++- .../chatter/hooks/useArticleChatter.ts | 2 +- .../shared/src/features/chatter/mockData.ts | 163 ++++++++---------- packages/shared/src/features/chatter/types.ts | 2 +- 5 files changed, 141 insertions(+), 135 deletions(-) diff --git a/packages/shared/src/features/chatter/components/ChatterPlatformCard.tsx b/packages/shared/src/features/chatter/components/ChatterPlatformCard.tsx index d8a80ba92ff..bc21a99e06b 100644 --- a/packages/shared/src/features/chatter/components/ChatterPlatformCard.tsx +++ b/packages/shared/src/features/chatter/components/ChatterPlatformCard.tsx @@ -36,52 +36,65 @@ const Avatar = ({ ); -const CommentRow = ({ comment }: { comment: ChatterComment }): ReactElement => ( -
- -
-
- - {comment.author} - - {comment.handle && ( +const CommentRow = ({ comment }: { comment: ChatterComment }): ReactElement => { + const hasUpvotes = !!comment.upvotes && comment.upvotes > 0; + const hasReplies = !!comment.replies && comment.replies > 0; + + return ( +
+ +
+
+ + {comment.author} + + {comment.handle && ( + + {comment.handle} + + )} - {comment.handle} + · {comment.timeAgo} - )} - - · {comment.timeAgo} +
+ + {comment.text} -
- - {comment.text} - -
- - {' '} - {largeNumberFormat(comment.upvotes)} - - {typeof comment.replies === 'number' && ( - - {' '} - {largeNumberFormat(comment.replies)} - + {(hasUpvotes || hasReplies) && ( +
+ {hasUpvotes && ( + + {' '} + {largeNumberFormat(comment.upvotes)} + + )} + {hasReplies && ( + + {' '} + {largeNumberFormat(comment.replies)} + + )} +
)}
-
-); + ); +}; interface ChatterPlatformCardProps { source: ChatterSource; diff --git a/packages/shared/src/features/chatter/components/ChatterSection.spec.tsx b/packages/shared/src/features/chatter/components/ChatterSection.spec.tsx index 2b13125b106..aba41612e79 100644 --- a/packages/shared/src/features/chatter/components/ChatterSection.spec.tsx +++ b/packages/shared/src/features/chatter/components/ChatterSection.spec.tsx @@ -14,14 +14,16 @@ describe('ChatterSection', () => { await waitFor(() => expect(screen.getByText('Community Pulse')).toBeInTheDocument(), ); - expect(screen.getByText(/The room is split/i)).toBeInTheDocument(); + expect( + screen.getByText(/the fight is whether the fix breaks tool portability/i), + ).toBeInTheDocument(); expect(screen.getByText('Common ground')).toBeInTheDocument(); expect(screen.getByText('Where it splits')).toBeInTheDocument(); expect(screen.getByText('Bottom line')).toBeInTheDocument(); expect(screen.getByText(/strongest pushback/i)).toBeInTheDocument(); // Cross-platform divergence read labels. - expect(screen.getByText('Polarized & loud')).toBeInTheDocument(); - expect(screen.getByText('Skeptical')).toBeInTheDocument(); + expect(screen.getByText('Trading harness war-stories')).toBeInTheDocument(); + expect(screen.getByText('Sees vendor lock-in risk')).toBeInTheDocument(); }); it('keeps raw platform threads collapsed until requested', async () => { @@ -30,16 +32,20 @@ describe('ChatterSection', () => { expect(screen.getByText('Community Pulse')).toBeInTheDocument(), ); - expect(screen.queryByText(/X · 3 posts trending/i)).not.toBeInTheDocument(); + expect( + screen.queryByText(/X · developer amplification/i), + ).not.toBeInTheDocument(); fireEvent.click( screen.getByRole('button', { name: /See the raw discussion/i }), ); - expect(screen.getByText(/X · 3 posts trending/i)).toBeInTheDocument(); + expect( + screen.getByText(/X · developer amplification/i), + ).toBeInTheDocument(); expect(screen.getByText(/Hacker News · front page/i)).toBeInTheDocument(); - // X starts expanded, so its top post is visible. - expect(screen.getByText(/@t3dotgg/i)).toBeInTheDocument(); + // HN starts expanded, so its comments are visible. + expect(screen.getByText('mappu')).toBeInTheDocument(); }); it('filters raw sources by platform', async () => { @@ -53,7 +59,9 @@ describe('ChatterSection', () => { fireEvent.click(screen.getByRole('button', { name: 'Hacker News' })); - expect(screen.queryByText(/X · 3 posts trending/i)).not.toBeInTheDocument(); + expect( + screen.queryByText(/X · developer amplification/i), + ).not.toBeInTheDocument(); expect(screen.getByText(/Hacker News · front page/i)).toBeInTheDocument(); }); }); diff --git a/packages/shared/src/features/chatter/hooks/useArticleChatter.ts b/packages/shared/src/features/chatter/hooks/useArticleChatter.ts index 5995b09989c..d3fe1bd522d 100644 --- a/packages/shared/src/features/chatter/hooks/useArticleChatter.ts +++ b/packages/shared/src/features/chatter/hooks/useArticleChatter.ts @@ -18,7 +18,7 @@ export const useArticleChatter = (post: Post): UseArticleChatter => { useEffect(() => { setChatter(undefined); - const timeout = setTimeout(() => setChatter(getMockChatter(post)), 500); + const timeout = setTimeout(() => setChatter(getMockChatter()), 500); return () => clearTimeout(timeout); // eslint-disable-next-line react-hooks/exhaustive-deps }, [post.id]); diff --git a/packages/shared/src/features/chatter/mockData.ts b/packages/shared/src/features/chatter/mockData.ts index a0ca361b074..fe3404fdaf2 100644 --- a/packages/shared/src/features/chatter/mockData.ts +++ b/packages/shared/src/features/chatter/mockData.ts @@ -1,136 +1,121 @@ -import type { Post } from '../../graphql/posts'; import type { ArticleChatter } from './types'; import { ChatterPlatform } from './types'; -// POC: hard-coded chatter so the feature can be demoed end-to-end without a -// backend. Replace `getMockChatter` with a real query keyed off the post's -// permalink / yggdrasilId when the data source is ready. -export const getMockChatter = (post: Post): ArticleChatter => ({ +// POC: real captured dataset for "Better Models: Worse Tools" (Armin Ronacher), +// generated by the community-pulse POC pipeline (exact/topic-matched only, no +// fallback). Reddit was excluded from the POC by decision. Replace this with a +// live query keyed off the post's permalink / yggdrasilId when the data source +// is ready. +export const getMockChatter = (): ArticleChatter => ({ pulse: { verdict: - 'The room is split — impressive results, nobody’s sold on the reason.', + 'Everyone agrees the bug is real — the fight is whether the fix breaks tool portability.', stance: 'divided', - controversyLevel: 'heated', - totalVoices: 1240, + controversyLevel: 'lively', + totalVoices: 620, momentum: 'rising', - overallSplit: { agree: 44, mixed: 18, disagree: 38 }, + overallSplit: { agree: 58, mixed: 30, disagree: 12 }, perPlatform: [ { - platform: ChatterPlatform.X, - read: 'Polarized & loud', - split: { agree: 52, mixed: 8, disagree: 40 }, + platform: ChatterPlatform.HackerNews, + read: 'Trading harness war-stories', + split: { agree: 60, mixed: 28, disagree: 12 }, }, { - platform: ChatterPlatform.HackerNews, - read: 'Skeptical', - split: { agree: 34, mixed: 26, disagree: 40 }, + platform: ChatterPlatform.X, + read: 'Sees vendor lock-in risk', + split: { agree: 55, mixed: 33, disagree: 12 }, }, ], consensus: [ - 'The p99 latency win (340ms → 12ms) is real and impressive.', - 'The biggest gain came from consolidating 12 services down to 4.', + 'The core observation is real: strong models emit correct code edits but hallucinate non-existent tool params (e.g. requireUnique), failing strict harnesses.', + 'Lenient harnesses that auto-fix/retry train models into “good enough” habits that break in stricter tools.', + 'Minimal, well-chosen tool schemas beat complex ones in practice.', ], contention: [ - 'Language vs. architecture', - 'Hiring & retention', - 'Was the timeline worth it?', - 'ROI not shown', + 'Per-model harness tuning vs. portability', + 'Model regression or harness design fault?', + 'Vendor lock-in — can third-party agent tools survive?', + 'Determinism built on non-determinism', ], bottomLine: - 'Read it for the consolidation strategy, not as a language endorsement — the performance win is credible, but the ROI and hiring case is left unmade.', + 'A widely-praised diagnosis from Armin Ronacher: models are quietly being “trained off” by lenient harnesses. Nobody disputes the effect — the open question is whether the fix is per-model tuning (and the vendor lock-in that implies) or keeping tool schemas portable.', counterpoint: { - text: 'You had a people problem, not a language problem — the same team would have hit these numbers just cleaning up the original codebase.', - attribution: 'the most-upvoted take on both X and Hacker News', + text: 'The problem with hyper-targeting harnesses to models is you lock yourself into special model behaviors and make your sessions non-transferable.', + attribution: + 'the_mitsuhiko (the author himself), cautioning against the obvious fix, on Hacker News', }, }, sources: [ { - platform: ChatterPlatform.X, - heading: 'X · 3 posts trending', - subtitle: 'from devs you follow & large accounts', + platform: ChatterPlatform.HackerNews, + heading: 'Hacker News · front page', + subtitle: 'exact URL match · 226 points', stats: [ - { label: 'reposts', value: '2.4K' }, - { label: 'likes', value: '18K' }, + { label: 'points', value: '226' }, + { label: 'comments', value: '81' }, ], - mood: 'X is on fire.', - moodTone: 'hot', + mood: 'HN is trading harness war-stories.', + moodTone: 'split', summary: - 'Big accounts are dunking on the piece as premature optimization, while the people who actually shipped it are defending the numbers. The most-quoted take: “you had a people problem, not a language problem.”', - sourceUrl: post.permalink ?? 'https://x.com', - sourceLabel: 'Open thread on X', + 'Practitioners broadly praise the piece and pile on their own experiences — models botching unified-diff patches, minimal Sam-editor-syntax tools working great in ~650 tokens. The live debate: should harnesses ship per-model system prompts, or does that lock you into non-transferable, model-specific behavior?', + sourceUrl: 'https://news.ycombinator.com/item?id=48788599', + sourceLabel: 'Open discussion on Hacker News', defaultOpen: true, comments: [ { - id: 'x1', - author: 'Theo', - handle: '@t3dotgg', - avatar: 'linear-gradient(135deg,#1d9bf0,#0f6fb8)', - timeAgo: '6h', - text: 'rewriting to fix a p99 you caused with bad queries is such a 2026 move. the benchmarks are real but so is the 8-month timeline nobody is tweeting about 👀', - upvotes: 4100, - replies: 312, + id: 'hn1', + author: 'dofm', + avatar: '#ff6600', + timeAgo: 'recent', + text: 'As critical as I am about articles endlessly concerned with the weaknesses of closed cloud LLMs, this one is pretty great.', }, { - id: 'x2', - author: 'Dax', - handle: '@thdxr', - avatar: 'linear-gradient(135deg,#f2542d,#b8341a)', - timeAgo: '4h', - text: 'the actual win here is they deleted 40% of the codebase during the rewrite. that is the story. the tech is downstream of finally being allowed to refactor.', - upvotes: 2800, - replies: 96, + id: 'hn2', + author: 'mappu', + avatar: '#ff8a3d', + timeAgo: 'recent', + text: 'In my harness I implemented apply_patch taking unified diffs for patch -p1. I was shocked how bad models are at generating them.', }, { - id: 'x3', - author: 'Jane M.', - handle: '@janedev', - avatar: 'linear-gradient(135deg,#8b5cf6,#6d28d9)', - timeAgo: '2h', - text: 'p99 340ms → 12ms is not “premature optimization” when you pay per request at this scale. do the napkin math before the dunk.', - upvotes: 1900, - replies: 141, + id: 'hn3', + author: 'the_mitsuhiko', + avatar: '#9a5a2a', + timeAgo: 'recent', + text: 'The problem with hyper-targeting harnesses to models is that you end up locking yourself quickly into special model behaviors and make your sessions non-transferable.', }, ], }, { - platform: ChatterPlatform.HackerNews, - heading: 'Hacker News · front page', - subtitle: 'exact URL match · 3 hrs ago', + platform: ChatterPlatform.X, + heading: 'X · developer amplification', + subtitle: 'author reach + AI newsletters', stats: [ - { label: 'points', value: '842' }, - { label: 'comments', value: '396' }, + { label: 'sentiment', value: '55% pos' }, + { label: 'framing', value: 'strategic' }, ], - mood: 'HN is skeptical of the numbers.', - moodTone: 'split', + mood: 'X is worried about vendor lock-in.', + moodTone: 'mixed', summary: - 'The top thread argues the gains came from the architecture cleanup, not the tool itself — and that most stacks would land similar numbers after a second attempt. A counter-thread defends the compile-time guarantees for the on-call burden.', - sourceUrl: 'https://news.ycombinator.com', - sourceLabel: 'Open discussion on Hacker News', - defaultOpen: true, + 'X reframes the piece strategically: a detailed thread argues models get “trained off” by lenient vendor harnesses, so the real risk is training adaptation to a few big vendors’ ecosystems rather than general tool-use — which would make third-party agent tools progressively harder to build. AI newsletters amplify the takeaway.', + sourceUrl: 'https://x.com/search?q=Better%20Models%20Worse%20Tools', + sourceLabel: 'See posts on X', comments: [ { - id: 'hn1', - author: 'tptacek', - avatar: '#ff6600', - timeAgo: '2h ago', - text: 'The interesting number isn’t the p99, it’s that they went from 12 services to 4. You can get most of this in any stack once you’re allowed to consolidate.', - upvotes: 284, - }, - { - id: 'hn2', - author: 'steveklabnik', - avatar: '#ff8a3d', - timeAgo: '1h ago', - text: 'Every “rewrite” post is really a “we finally understood the problem domain” post. The second implementation of anything is faster.', - upvotes: 176, + id: 'x1', + author: 'Xudong Han', + handle: '@Xudong07452910', + avatar: 'linear-gradient(135deg,#1d9bf0,#0f6fb8)', + timeAgo: 'recent', + text: 'The stronger the model, the more easily the tool leads it astray. If models keep training in lenient harnesses that auto-fix mistakes, they learn a dangerous habit: “good enough” — which becomes an error in stricter tools.', }, { - id: 'hn3', - author: 'throwaway_9f2', - avatar: '#9a5a2a', - timeAgo: '44m ago', - text: 'Nobody’s asking about hiring. We tried this and spent a year unable to backfill. The maintenance cost never shows up in these posts.', - upvotes: 143, + id: 'x2', + author: 'zettersten', + handle: '@zettersten', + avatar: 'linear-gradient(135deg,#8b5cf6,#6d28d9)', + timeAgo: 'recent', + text: 'Better Models: Worse Tools — Armin Ronacher’s Thoughts and Writings', }, ], }, diff --git a/packages/shared/src/features/chatter/types.ts b/packages/shared/src/features/chatter/types.ts index f256352c788..9dcb51f4b66 100644 --- a/packages/shared/src/features/chatter/types.ts +++ b/packages/shared/src/features/chatter/types.ts @@ -14,7 +14,7 @@ export interface ChatterComment { avatar: string; timeAgo: string; text: string; - upvotes: number; + upvotes?: number; replies?: number; } From 6e2f5562cb711774328a7df4b079ab22068063f6 Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Tue, 7 Jul 2026 09:59:57 +0200 Subject: [PATCH 4/8] fix(chatter): stop long pulse lines overflowing the card Add a min-w-0 chain across the flex-column levels (section, card, per-section) plus break-words on the long paragraphs. Without min-w-0 a flex column keeps its max-content width and passes it down to nested flex rows, so long lines (bottom line, consensus bullets) broke out of the card instead of wrapping. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../chatter/components/ChatterPlatformCard.tsx | 2 +- .../features/chatter/components/ChatterSection.tsx | 4 ++-- .../features/chatter/components/CommunityPulse.tsx | 13 ++++++++----- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/packages/shared/src/features/chatter/components/ChatterPlatformCard.tsx b/packages/shared/src/features/chatter/components/ChatterPlatformCard.tsx index bc21a99e06b..0c00b5a19fe 100644 --- a/packages/shared/src/features/chatter/components/ChatterPlatformCard.tsx +++ b/packages/shared/src/features/chatter/components/ChatterPlatformCard.tsx @@ -110,7 +110,7 @@ export const ChatterPlatformCard = ({ : undefined; return ( -
+
@@ -87,7 +87,7 @@ export const ChatterSection = ({ <> -
+
+ + {showDetails &&
} +
+ ); +}; From def36350f6cdaf47daad990656954718545b352c Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Tue, 7 Jul 2026 14:40:45 +0200 Subject: [PATCH 8/8] feat(chatter): GLM 5.2 dataset + color-coded highlights MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swap the mock to the "GLM 5.2 / AI margin collapse" dataset. Add a tagged `highlights` model (bull case / skeptic / the tell / the receipt) and make it the centerpiece of the breakdown — color-coded quote cards that carry the concrete, quotable value. Drop the abstract consensus list and the standalone counterpoint (the skeptic highlight replaces it); render the fault lines as a wrapping list since they're sentence-length. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/ChatterSection.spec.tsx | 32 ++--- .../chatter/components/CommunityPulse.tsx | 108 ++++++++++------ .../shared/src/features/chatter/mockData.ts | 120 +++++++++--------- packages/shared/src/features/chatter/types.ts | 18 ++- 4 files changed, 157 insertions(+), 121 deletions(-) diff --git a/packages/shared/src/features/chatter/components/ChatterSection.spec.tsx b/packages/shared/src/features/chatter/components/ChatterSection.spec.tsx index f10ceeda1f2..272538d930f 100644 --- a/packages/shared/src/features/chatter/components/ChatterSection.spec.tsx +++ b/packages/shared/src/features/chatter/components/ChatterSection.spec.tsx @@ -9,9 +9,7 @@ const renderComponent = () => render(); const waitForGlance = () => waitFor(() => - expect( - screen.getByText(/the fight is whether the fix breaks tool portability/i), - ).toBeInTheDocument(), + expect(screen.getByText(/X is calling the top/i)).toBeInTheDocument(), ); describe('ChatterSection', () => { @@ -20,38 +18,40 @@ describe('ChatterSection', () => { await waitForGlance(); // The glance: verdict + sentiment + stance, all visible at once. - expect(screen.getByText(/58% agree/i)).toBeInTheDocument(); + expect(screen.getByText(/48% agree/i)).toBeInTheDocument(); expect(screen.getByText('divided')).toBeInTheDocument(); // The breakdown is collapsed until asked for. - expect(screen.queryByText('Common ground')).not.toBeInTheDocument(); + expect(screen.queryByText('Highlights')).not.toBeInTheDocument(); fireEvent.click( screen.getByRole('button', { name: /Show the full breakdown/i }), ); - expect(screen.getByText('Common ground')).toBeInTheDocument(); + expect(screen.getByText('Highlights')).toBeInTheDocument(); + expect(screen.getByText('The bull case')).toBeInTheDocument(); + expect(screen.getByText('The skeptic')).toBeInTheDocument(); expect(screen.getByText('Bottom line')).toBeInTheDocument(); - expect(screen.getByText('Trading harness war-stories')).toBeInTheDocument(); + expect( + screen.getByText('Declaring victory for open weights'), + ).toBeInTheDocument(); }); it('keeps raw platform threads collapsed until requested', async () => { renderComponent(); await waitForGlance(); - expect( - screen.queryByText(/X · developer amplification/i), - ).not.toBeInTheDocument(); + expect(screen.queryByText(/X · widely shared/i)).not.toBeInTheDocument(); fireEvent.click( screen.getByRole('button', { name: /See the raw discussion/i }), ); + expect(screen.getByText(/X · widely shared/i)).toBeInTheDocument(); expect( - screen.getByText(/X · developer amplification/i), + screen.getByText(/Hacker News · #1 on the front page/i), ).toBeInTheDocument(); - expect(screen.getByText(/Hacker News · front page/i)).toBeInTheDocument(); - expect(screen.getByText('mappu')).toBeInTheDocument(); + expect(screen.getByText('zackify')).toBeInTheDocument(); }); it('filters raw sources by platform', async () => { @@ -63,9 +63,9 @@ describe('ChatterSection', () => { fireEvent.click(screen.getByRole('button', { name: 'Hacker News' })); + expect(screen.queryByText(/X · widely shared/i)).not.toBeInTheDocument(); expect( - screen.queryByText(/X · developer amplification/i), - ).not.toBeInTheDocument(); - expect(screen.getByText(/Hacker News · front page/i)).toBeInTheDocument(); + screen.getByText(/Hacker News · #1 on the front page/i), + ).toBeInTheDocument(); }); }); diff --git a/packages/shared/src/features/chatter/components/CommunityPulse.tsx b/packages/shared/src/features/chatter/components/CommunityPulse.tsx index b72ffe5a242..521372a62f6 100644 --- a/packages/shared/src/features/chatter/components/CommunityPulse.tsx +++ b/packages/shared/src/features/chatter/components/CommunityPulse.tsx @@ -6,6 +6,7 @@ import type { CommunityPulse as CommunityPulseData, CommunityStance, ControversyLevel, + HighlightKind, SentimentSplit, } from '../types'; import { platformVisuals } from '../platforms'; @@ -19,7 +20,6 @@ import { ArrowIcon, SparkleIcon, TrendingIcon, - VIcon, } from '../../../components/icons'; import { IconSize } from '../../../components/Icon'; import { useToggle } from '../../../hooks/useToggle'; @@ -44,6 +44,32 @@ const momentumLabel: Record = { cooling: 'Cooling', }; +const highlightMeta: Record< + HighlightKind, + { label: string; dot: string; text: string } +> = { + 'the-bull-case': { + label: 'The bull case', + dot: 'bg-status-success', + text: 'text-status-success', + }, + 'the-skeptic': { + label: 'The skeptic', + dot: 'bg-status-error', + text: 'text-status-error', + }, + 'the-tell': { + label: 'The tell', + dot: 'bg-accent-cabbage-default', + text: 'text-action-plus-default', + }, + 'the-receipt': { + label: 'The receipt', + dot: 'bg-accent-salt-default', + text: 'text-text-secondary', + }, +}; + const leanDot = (split: SentimentSplit): string => { const net = split.agree - split.disagree; if (net >= 10) { @@ -83,6 +109,43 @@ const SentimentBar = ({ split }: { split: SentimentSplit }): ReactElement => ( const Details = ({ pulse }: { pulse: CommunityPulseData }): ReactElement => ( <> +
+ Highlights +
+ {pulse.highlights.map((highlight) => { + const meta = highlightMeta[highlight.kind]; + return ( +
+ + + + {meta.label} + + + + “{highlight.text}” + + + — {highlight.who} + +
+ ); + })} +
+
+
How each platform leans
@@ -132,14 +195,11 @@ const Details = ({ pulse }: { pulse: CommunityPulseData }): ReactElement => (
- Common ground + Where it splits
    - {pulse.consensus.map((item) => ( -
  • - + {pulse.contention.map((item) => ( +
  • + (
  • ))}
-
- Where it splits -
- {pulse.contention.map((tag) => ( - - {tag} - - ))} -
-
@@ -177,25 +224,6 @@ const Details = ({ pulse }: { pulse: CommunityPulseData }): ReactElement => ( {pulse.bottomLine}
- -
-
- Strongest pushback - - “{pulse.counterpoint.text}” - - - — {pulse.counterpoint.attribution} - -
-
); diff --git a/packages/shared/src/features/chatter/mockData.ts b/packages/shared/src/features/chatter/mockData.ts index fe3404fdaf2..506e8f6b9c5 100644 --- a/packages/shared/src/features/chatter/mockData.ts +++ b/packages/shared/src/features/chatter/mockData.ts @@ -1,121 +1,123 @@ import type { ArticleChatter } from './types'; import { ChatterPlatform } from './types'; -// POC: real captured dataset for "Better Models: Worse Tools" (Armin Ronacher), -// generated by the community-pulse POC pipeline (exact/topic-matched only, no -// fallback). Reddit was excluded from the POC by decision. Replace this with a -// live query keyed off the post's permalink / yggdrasilId when the data source -// is ready. +// POC: real captured community-pulse dataset for "GLM 5.2 and the coming AI +// margin collapse" (Martin Alderson). HN exact-URL match (#1, 508 points) + X +// amplification. Replace this with a live query keyed off the post's permalink +// / yggdrasilId when the data source is ready. export const getMockChatter = (): ArticleChatter => ({ pulse: { verdict: - 'Everyone agrees the bug is real — the fight is whether the fix breaks tool portability.', + 'X is calling the top (“the collapse is here”); HN is calling BS on the framing while quietly hunting the cheap plans.', stance: 'divided', - controversyLevel: 'lively', - totalVoices: 620, + controversyLevel: 'heated', + totalVoices: 900, momentum: 'rising', - overallSplit: { agree: 58, mixed: 30, disagree: 12 }, + overallSplit: { agree: 48, mixed: 30, disagree: 22 }, perPlatform: [ { platform: ChatterPlatform.HackerNews, - read: 'Trading harness war-stories', - split: { agree: 60, mixed: 28, disagree: 12 }, + read: 'Skeptical of the hype, deep in the weeds', + split: { agree: 40, mixed: 33, disagree: 27 }, }, { platform: ChatterPlatform.X, - read: 'Sees vendor lock-in risk', - split: { agree: 55, mixed: 33, disagree: 12 }, + read: 'Declaring victory for open weights', + split: { agree: 66, mixed: 22, disagree: 12 }, }, ], - consensus: [ - 'The core observation is real: strong models emit correct code edits but hallucinate non-existent tool params (e.g. requireUnique), failing strict harnesses.', - 'Lenient harnesses that auto-fix/retry train models into “good enough” habits that break in stricter tools.', - 'Minimal, well-chosen tool schemas beat complex ones in practice.', - ], contention: [ - 'Per-model harness tuning vs. portability', - 'Model regression or harness design fault?', - 'Vendor lock-in — can third-party agent tools survive?', - 'Determinism built on non-determinism', + 'Is GLM 5.2 actually frontier, or just cheap? (HN says not close to Opus; X says GPT-5-class)', + '“The collapse is here” vs. “this has been AI news every single day”', + 'Are frontier labs doomed, or does speed (Cerebras) become the new moat?', + ], + highlights: [ + { + kind: 'the-bull-case', + text: 'The AI margin collapse has already started. GLM 5.2 just dropped, a 7MB embedding model runs fully in your browser. 2026 is the year API access stops being a moat.', + who: '@alsamahi (X)', + }, + { + kind: 'the-skeptic', + text: '“The least understood shift in AI economics” — then proceeds to talk about something that’s in the AI news every day. By no measure is GLM 5.2 as good as Opus.', + who: 'budsniffer952 (HN)', + }, + { + kind: 'the-tell', + text: 'Somehow no one talks about LLM speed. — The whole top thread then pivots from cost to Cerebras and tokens/sec: speed may be the next moat.', + who: 'copperx → tough → LoganDark (HN)', + }, + { + kind: 'the-receipt', + text: 'I switched to the yearly Cline pass because it was too cheap haha — $6 a month, planning to mostly run DeepSeek v4 flash.', + who: 'zackify (HN)', + }, ], bottomLine: - 'A widely-praised diagnosis from Armin Ronacher: models are quietly being “trained off” by lenient harnesses. Nobody disputes the effect — the open question is whether the fix is per-model tuning (and the vendor lock-in that implies) or keeping tool schemas portable.', - counterpoint: { - text: 'The problem with hyper-targeting harnesses to models is you lock yourself into special model behaviors and make your sessions non-transferable.', - attribution: - 'the_mitsuhiko (the author himself), cautioning against the obvious fix, on Hacker News', - }, + 'The thesis isn’t controversial — cheap open weights squeeze margins, everyone knows it. The fault line is the read: X treats GLM 5.2 as the moment API access stopped being a moat, while HN’s most-engaged replies think the post oversells a slow, already-obvious trend and quietly redirects the debate to the thing nobody benchmarks — raw inference speed.', }, sources: [ { platform: ChatterPlatform.HackerNews, - heading: 'Hacker News · front page', - subtitle: 'exact URL match · 226 points', + heading: 'Hacker News · #1 on the front page', + subtitle: 'exact URL match · 508 points', stats: [ - { label: 'points', value: '226' }, - { label: 'comments', value: '81' }, + { label: 'points', value: '508' }, + { label: 'comments', value: '307' }, ], - mood: 'HN is trading harness war-stories.', + mood: 'HN is skeptical of the framing.', moodTone: 'split', summary: - 'Practitioners broadly praise the piece and pile on their own experiences — models botching unified-diff patches, minimal Sam-editor-syntax tools working great in ~650 tokens. The live debate: should harnesses ship per-model system prompts, or does that lock you into non-transferable, model-specific behavior?', - sourceUrl: 'https://news.ycombinator.com/item?id=48788599', + 'Deep in the weeds and unconvinced by the headline — the most-engaged replies argue the post oversells an already-obvious trend, then pivot the debate from cost to raw inference speed (Cerebras, tokens/sec).', + sourceUrl: 'https://news.ycombinator.com/item?id=48809877', sourceLabel: 'Open discussion on Hacker News', defaultOpen: true, comments: [ { id: 'hn1', - author: 'dofm', + author: 'budsniffer952', avatar: '#ff6600', timeAgo: 'recent', - text: 'As critical as I am about articles endlessly concerned with the weaknesses of closed cloud LLMs, this one is pretty great.', + text: '“The least understood upcoming shift in AI economics” — then proceeds to talk about something that’s in the AI news every single day. By no measure is GLM 5.2 as good as Opus. Yes, open models pressure margins… eventually.', }, { id: 'hn2', - author: 'mappu', + author: 'copperx', avatar: '#ff8a3d', timeAgo: 'recent', - text: 'In my harness I implemented apply_patch taking unified diffs for patch -p1. I was shocked how bad models are at generating them.', + text: 'Somehow no one talks about LLM speed. [the thread pivots to Cerebras and tokens/sec — speed may be the next moat]', }, { id: 'hn3', - author: 'the_mitsuhiko', + author: 'zackify', avatar: '#9a5a2a', timeAgo: 'recent', - text: 'The problem with hyper-targeting harnesses to models is that you end up locking yourself quickly into special model behaviors and make your sessions non-transferable.', + text: 'I switched to the yearly Cline pass because it was too cheap haha — $6 a month, planning to mostly run DeepSeek v4 flash.', }, ], }, { platform: ChatterPlatform.X, - heading: 'X · developer amplification', - subtitle: 'author reach + AI newsletters', + heading: 'X · widely shared', + subtitle: 'mostly bullish', stats: [ - { label: 'sentiment', value: '55% pos' }, - { label: 'framing', value: 'strategic' }, + { label: 'sentiment', value: '66% pos' }, + { label: 'framing', value: 'collapse is here' }, ], - mood: 'X is worried about vendor lock-in.', + mood: 'X is declaring victory for open weights.', moodTone: 'mixed', summary: - 'X reframes the piece strategically: a detailed thread argues models get “trained off” by lenient vendor harnesses, so the real risk is training adaptation to a few big vendors’ ecosystems rather than general tool-use — which would make third-party agent tools progressively harder to build. AI newsletters amplify the takeaway.', - sourceUrl: 'https://x.com/search?q=Better%20Models%20Worse%20Tools', + 'Reframed as a milestone: GLM 5.2 is “the moment API access stops being a moat.” Amplified widely with little pushback.', + sourceUrl: 'https://x.com/search?q=AI%20margin%20collapse%20GLM', sourceLabel: 'See posts on X', comments: [ { id: 'x1', - author: 'Xudong Han', - handle: '@Xudong07452910', + author: 'alsamahi', + handle: '@alsamahi', avatar: 'linear-gradient(135deg,#1d9bf0,#0f6fb8)', timeAgo: 'recent', - text: 'The stronger the model, the more easily the tool leads it astray. If models keep training in lenient harnesses that auto-fix mistakes, they learn a dangerous habit: “good enough” — which becomes an error in stricter tools.', - }, - { - id: 'x2', - author: 'zettersten', - handle: '@zettersten', - avatar: 'linear-gradient(135deg,#8b5cf6,#6d28d9)', - timeAgo: 'recent', - text: 'Better Models: Worse Tools — Armin Ronacher’s Thoughts and Writings', + text: 'The AI margin collapse has already started. GLM 5.2 just dropped, a 7MB embedding model runs fully in your browser. 2026 is the year API access stops being a moat.', }, ], }, diff --git a/packages/shared/src/features/chatter/types.ts b/packages/shared/src/features/chatter/types.ts index 9dcb51f4b66..5e143f77edd 100644 --- a/packages/shared/src/features/chatter/types.ts +++ b/packages/shared/src/features/chatter/types.ts @@ -57,9 +57,16 @@ export interface PlatformSentiment { split: SentimentSplit; } -export interface CommunityCounterpoint { +export type HighlightKind = + | 'the-bull-case' + | 'the-skeptic' + | 'the-tell' + | 'the-receipt'; + +export interface CommunityHighlight { + kind: HighlightKind; text: string; - attribution: string; + who: string; } export interface CommunityPulse { @@ -70,13 +77,12 @@ export interface CommunityPulse { momentum: CommunityMomentum; overallSplit: SentimentSplit; perPlatform: PlatformSentiment[]; - /** What the room broadly agrees on (keep to 2-3 short points). */ - consensus: string[]; - /** The fault lines, as short scannable tags. */ + /** The fault lines the community splits on. */ contention: string[]; + /** The standout quotes, tagged by their role in the conversation. */ + highlights: CommunityHighlight[]; /** One-sentence synthesized takeaway. */ bottomLine: string; - counterpoint: CommunityCounterpoint; } export interface ArticleChatter {