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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion KNOWLEDGE_BASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,9 @@ Commits, replies, quotes, scheduling, terminal submissions, and app (signed API)

Approved top-level commits about technology, current affairs or substantive real-world events can receive brief, shared subject headlines. Technical and developer topics carry the most weight. /dev/null community activity also qualifies, including livestreams, Tom, app betas and feature updates. Quote commits include the directly quoted post's text when it is approved and still available, so a brief reaction can share the subject of the post it quotes. Replies, casual small talk and evergreen lifestyle or hobby advice are skipped. OpenAI's Decisions API assesses this text for suitability and checks the closest existing headline names, shortlisted by semantic similarity; when none match, DeepSeek through OpenCode generates a headline from the same text. Posts can be processed together, and similar newly generated headlines share the first headline rather than creating duplicates. No account metadata is sent. A headline expires after fourteen days without a new commit link. This categorization does not change the original post and does not yet add visible headline labels to the interface.

Hot topics can also receive a short AI-written recap based on approved, available top-level commits from the past eight hours. An AI usefulness check skips topics without enough substance to summarize. Recaps aim to explain the underlying situation or event in one or two short sentences, rather than cataloguing individual posts. They are not independently verified reporting and can be reused for up to two hours. Only post text and the headline are sent to the AI providers, without account metadata. Removing a source commit, rejecting it, or deleting its author's account clears the associated recap. This runs in the background; hot-topic recaps are not yet displayed in the interface.
The desktop sidebar shows signed-in members up to five **Hot topics** above **Who to follow**. These aggregate approved top-level commits posted in the past eight hours, with extra weight for trending discussions. Only each author's strongest post contributes to a topic's ranking, so repeated posts from one profile do not lift it. The list respects mutes and blocks. The sidebar no longer repeats your own profile summary; your profile is still available from the main navigation.

Hot topics can also receive a short AI-written recap based on approved, available top-level commits from the past eight hours. An AI usefulness check skips topics without enough substance to summarize. Recaps aim to explain the underlying situation or event in one or two short sentences, rather than cataloguing individual posts. They are not independently verified reporting and can be reused for up to two hours. Only post text and the headline are sent to the AI providers, without account metadata. Removing a source commit, rejecting it, or deleting its author's account clears the associated recap. Signed-in members can open a headline to read its available recap above semantic search results from the past week. Expired recaps are omitted, and a recap is hidden if linked public posts include an author the viewer has muted or blocked. The results respect visibility, mutes and blocks too.

Sources: [composer](resources/js/components/commit-composer.tsx), [commit model](app/Models/Commit.php), [commit validation](app/Http/Requests/CreateCommitRequest.php), [text normalization](app/Services/CommitBodies.php), [submission throttle](app/Http/Middleware/ThrottleCommits.php).

Expand Down
21 changes: 21 additions & 0 deletions app/Http/Controllers/HeadlineController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use App\Models\Headline;
use App\Models\User;
use App\Queries\HeadlinePageQuery;
use Illuminate\Container\Attributes\CurrentUser;
use Inertia\Inertia;
use Inertia\Response;

final readonly class HeadlineController
{
public function show(Headline $headline, #[CurrentUser] User $user, HeadlinePageQuery $query): Response
{
return Inertia::render('headline/show', $query->get($headline, $user))
->withViewData(['robots' => 'noindex, follow']);
}
}
8 changes: 8 additions & 0 deletions app/Http/Middleware/HandleInertiaRequests.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use App\Queries\BioProfilesQuery;
use App\Queries\ConversationsQuery;
use App\Queries\DevelopersCountQuery;
use App\Queries\HotHeadlinesQuery;
use App\Queries\LiveFollowingQuery;
use App\Queries\NotificationGroupsQuery;
use App\Queries\SuggestedUsersQuery;
Expand Down Expand Up @@ -127,6 +128,13 @@ public function share(Request $request): array
'showsWhoToFollow' => $showsWhoToFollow,
...$user instanceof User && $hasAside ? [
'liveFollowing' => Inertia::once(fn (): Collection => resolve(LiveFollowingQuery::class)->get($user))->until(60),
'hotTopics' => Inertia::once(fn (): array => array_map(
fn (array $headline): array => [
'id' => $headline['id'],
'name' => $headline['name'],
],
resolve(HotHeadlinesQuery::class)->get($user),
))->until(60),
] : [],
'dimsLanes' => $request->cookie('lane_dimming') !== 'false',
'repliesPerPage' => RepliesPerPage::of($request)->value,
Expand Down
26 changes: 19 additions & 7 deletions app/Providers/AppServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -234,13 +234,25 @@ private function bootRateLimitingDefaults(): void
]);

// Every commit search pays for an embedding and a relevance verdict, so it gets a budget of its own...
RateLimiter::for('search', fn (Request $request): Limit => Limit::perMinute(15)
->by($this->userId($request))
->response(fn (Request $request, array $headers): JsonResponse => response()->json(
['message' => 'Slow down: too many searches. Give it a few seconds.'],
429,
$headers,
)));
RateLimiter::for('search', function (Request $request): Limit {
if ($request->routeIs('headline.show') && $request->header('X-Inertia-Partial-Component') === 'headline/show') {
$only = collect(explode(',', $request->header('X-Inertia-Partial-Data', '')))->filter();
$except = explode(',', $request->header('X-Inertia-Partial-Except', ''));

if (($only->isNotEmpty() && $only->doesntContain(fn (string $prop): bool => $prop === 'commits' || str_starts_with($prop, 'commits.')))
|| in_array('commits', $except, true)) {
return Limit::none();
}
}

return Limit::perMinute(15)
->by($this->userId($request))
->response(fn (Request $request, array $headers): JsonResponse => response()->json(
['message' => 'Slow down: too many searches. Give it a few seconds.'],
429,
$headers,
));
});

// Every commit page asks for its related commits, using the stored vector and a relevance verdict...
RateLimiter::for('related-commits', fn (Request $request): Limit => Limit::perMinute(60)->by(
Expand Down
12 changes: 7 additions & 5 deletions app/Queries/CommitSearchQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use App\Contracts\Services\RelevanceProvider;
use App\Models\Commit;
use App\Models\User;
use Carbon\CarbonInterface;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
Expand Down Expand Up @@ -39,7 +40,7 @@ public function __construct(private EmbeddingProvider $embeddings, private Relev
*
* @return Collection<int, Commit>
*/
public function get(string|Commit $term, ?User $viewer, int $limit = 20): Collection
public function get(string|Commit $term, ?User $viewer, int $limit = 20, ?CarbonInterface $since = null): Collection
{
$source = $term instanceof Commit
? Commit::query()->whereKey($term->id)->first(['id', 'body', 'embedding', 'parent_id', 'root_id'])
Expand All @@ -58,13 +59,13 @@ public function get(string|Commit $term, ?User $viewer, int $limit = 20): Collec
/** @var list<float> $vector */
$vector = $source->embedding ?? Cache::remember('search-embedding:'.hash('sha256', $term), self::CACHE_SECONDS, fn (): array => $this->embeddings->embed($term));

$candidates = DB::transaction(function () use ($source, $vector, $viewer, $limit): Collection {
if ($source instanceof Commit) {
$candidates = DB::transaction(function () use ($source, $vector, $viewer, $limit, $since): Collection {
if ($source instanceof Commit || $since instanceof CarbonInterface) {
DB::statement('set local hnsw.iterative_scan = strict_order');
}

return Commit::query()
->whereIn('commits.id', $this->nearest($vector, $viewer, $limit * 2, $source))
->whereIn('commits.id', $this->nearest($vector, $viewer, $limit * 2, $source, $since))
->visibleTo($viewer)
->withoutMutedAuthorsFor($viewer)
->withVisibleReplyCount($viewer)
Expand All @@ -87,10 +88,11 @@ public function get(string|Commit $term, ?User $viewer, int $limit = 20): Collec
*
* @param list<float> $vector
*/
private function nearest(array $vector, ?User $viewer, int $count, ?Commit $source): CommitBuilder
private function nearest(array $vector, ?User $viewer, int $count, ?Commit $source, ?CarbonInterface $since): CommitBuilder
{
return Commit::query()
->select('id')
->when($since, fn (CommitBuilder $query, CarbonInterface $since): CommitBuilder => $query->where('created_at', '>=', $since))
->when($source, fn (CommitBuilder $query, Commit $source): CommitBuilder => $query
->topLevel()
->whereKeyNot($source->threadRootId())
Expand Down
42 changes: 42 additions & 0 deletions app/Queries/HeadlinePageQuery.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

declare(strict_types=1);

namespace App\Queries;

use App\Models\Commit;
use App\Models\Headline;
use App\Models\User;
use Closure;
use Illuminate\Support\Collection;

final readonly class HeadlinePageQuery
{
public function __construct(private CommitSearchQuery $search)
{
//
}

/**
* @return array{name: string, summary: Closure(): (string|null), commits: Closure(): Collection<int, Commit>}
*/
public function get(Headline $headline, User $viewer): array
{
return [
'name' => $headline->name,
'summary' => function () use ($headline, $viewer): ?string {
if ($headline->summary === null || ! $headline->summary_created_at?->gt(now()->subHours(Headline::SUMMARY_HOURS))) {
return null;
}

// Without stored provenance, check all linked posts rather than risk repeating a hidden author's words.
$hasHiddenSources = $headline->commits()->topLevel()->approved()
->whereNotIn('commits.id', $headline->commits()->select('commits.id')->visibleTo($viewer)->withoutMutedAuthorsFor($viewer))
->exists();

return $hasHiddenSources ? null : $headline->summary;
},
'commits' => fn (): Collection => $this->search->get($headline->name, $viewer, since: now()->subWeek()),
];
}
}
7 changes: 4 additions & 3 deletions app/Queries/HotHeadlinesQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ public function __construct(private TheAlgorithm $theAlgorithm)
* Ties use author breadth and name, never post count or recency, so repeated weaker posts cannot lift a headline.
* Rankings are deterministic, without the Trending log's random draw or network adjustments.
*
* @return list<array{name: string, commits_count: int, score: float}>
* @return list<array{id: string, name: string, commits_count: int, score: float}>
*/
public function get(?User $viewer = null): array
{
Expand All @@ -44,11 +44,11 @@ public function get(?User $viewer = null): array
->selectRaw('max(commits.hot_score) as hot_score, count(*) as commits_count')
->groupBy('commit_headline.headline_id', 'commits.author_id');

/** @var list<object{name: string, commits_count: int, score: float}> $rows */
/** @var list<object{id: string, name: string, commits_count: int, score: float}> $rows */
$rows = DB::query()
->fromSub($contributions, 'contributions')
->join('headlines', 'headlines.id', '=', 'contributions.headline_id')
->select('headlines.name')
->select('headlines.id', 'headlines.name')
->selectRaw('sum(contributions.commits_count)::bigint as commits_count, count(*) as authors_count, sum(1 + greatest(0::float8, contributions.hot_score - ?::float8)) as score', [$baseline])
->groupBy('headlines.id', 'headlines.name')
->orderByDesc('score')
Expand All @@ -58,6 +58,7 @@ public function get(?User $viewer = null): array
->get()->all();

return array_map(fn (object $row): array => [
'id' => $row->id,
'name' => $row->name,
'commits_count' => $row->commits_count,
'score' => $row->score,
Expand Down
101 changes: 5 additions & 96 deletions resources/js/components/app-aside.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,15 @@ import ChangelogController from '@/actions/App/Http/Controllers/ChangelogControl
import PartnerController from '@/actions/App/Http/Controllers/PartnerController';
import PrivacyController from '@/actions/App/Http/Controllers/PrivacyController';
import TermsController from '@/actions/App/Http/Controllers/TermsController';
import UserFollowersController from '@/actions/App/Http/Controllers/UserFollowersController';
import UserFollowingController from '@/actions/App/Http/Controllers/UserFollowingController';
import { BioText } from '@/components/bio-text';
import { GitHubIcon } from '@/components/github-icon';
import { HotTopics } from '@/components/hot-topics';
import { LiveFollowing } from '@/components/live-following';
import { Button } from '@/components/ui/button';
import { UserAvatar } from '@/components/user-avatar';
import { WhoToFollow } from '@/components/who-to-follow';
import { WordmarkText } from '@/components/wordmark-text';
import { useSharedState } from '@/hooks/use-shared-state';
import { commitStateKey } from '@/lib/commit-state';
import { useKeyLabels } from '@/lib/keyboard';
import { exactCountTitle, formatCount, formatExactCount } from '@/lib/numbers';
import { laneColor } from '@/lib/utils';
import { formatExactCount } from '@/lib/numbers';
import { login } from '@/routes';
import { show } from '@/routes/user';

export function AppAside({
onShowShortcuts,
Expand All @@ -30,13 +23,9 @@ export function AppAside({
onShowShortcuts: () => void;
onSearch: () => void;
}) {
const { user, bioProfiles } = usePage().props.auth;
const { user } = usePage().props.auth;
const { developersCount } = usePage().props;
const keys = useKeyLabels();
const [commitsCount] = useSharedState(
commitStateKey('commits_count', user?.id ?? ''),
user?.commits_count ?? 0,
);

return (
<aside className="sticky top-0 hidden h-svh w-80 shrink-0 scrollbar-none flex-col gap-8 overflow-y-auto p-6 xl:flex">
Expand All @@ -53,7 +42,7 @@ export function AppAside({
</kbd>
</button>

{user === null ? (
{user === null && (
<section
aria-labelledby="aside-sign-in"
className="rounded-lg border bg-card p-4"
Expand Down Expand Up @@ -93,90 +82,10 @@ export function AppAside({
.
</p>
</section>
) : (
<section aria-labelledby="aside-you">
<h2 id="aside-you" className="sr-only">
You
</h2>
<Link
href={show(user)}
prefetch
className="group flex items-center gap-3"
>
<UserAvatar user={user} className="size-12" />
<span className="min-w-0">
<span className="block truncate font-semibold group-hover:underline">
{user.name}
</span>
<span className="flex items-center gap-1.5 font-mono text-sm text-muted-foreground">
<span
aria-hidden
className="size-2 rounded-full"
style={{
background: laneColor(user.lane),
}}
/>
@{user.handle}
</span>
</span>
</Link>
<dl className="mt-4 grid grid-cols-3 gap-4 text-center text-sm">
{[
{
count: commitsCount,
label:
commitsCount === 1 ? 'commit' : 'commits',
href: show(user),
},
{
count: user.following_count,
label: 'following',
href: UserFollowingController.index(user),
},
{
count: user.followers_count,
label:
user.followers_count === 1
? 'follower'
: 'followers',
href: UserFollowersController.index(user),
},
].map((stat) => (
<div
key={stat.label}
className="group flex min-w-0 flex-col-reverse"
>
<dt className="text-muted-foreground">
<Link
href={stat.href}
prefetch
className="group-hover:underline"
>
{stat.label}
</Link>
</dt>
<dd className="font-mono font-medium tabular-nums">
<Link
href={stat.href}
prefetch
className="group-hover:underline"
title={exactCountTitle(stat.count)}
>
{formatCount(stat.count)}
</Link>
</dd>
</div>
))}
</dl>
{user.bio && (
<p className="mt-3 text-sm text-pretty text-muted-foreground">
<BioText bio={user.bio} profiles={bioProfiles} />
</p>
)}
</section>
)}

<LiveFollowing />
<HotTopics />
<WhoToFollow />

<div className="-mt-4 flex flex-col gap-2 border-t pt-4 text-sm text-muted-foreground">
Expand Down
Loading
Loading