Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
0dffb82
test: add embeddingAtDistance() for vectors at a known cosine distance
younes-io Oct 23, 2026
9cc891c
feat: query the nearest embedded commits to a commit
younes-io Oct 23, 2026
e5b28d3
test: related commits are the nearest top-level commits of other threads
younes-io Oct 23, 2026
949d817
test: related commits stop at the distance cutoff and cap at three
younes-io Oct 23, 2026
b270242
test: related commits are empty without a source or candidate vector
younes-io Oct 23, 2026
59ac350
test: related commits hide what search hides from the viewer
younes-io Oct 23, 2026
ff42cb6
test: related commits load card data in one query with no outside call
younes-io Oct 23, 2026
6b8009e
feat: deny related commits as not found when the commit is not visible
younes-io Oct 23, 2026
afb538a
feat: serve related commits at GET /commits/{commit}/related
younes-io Oct 23, 2026
aa49e98
test: related commits endpoint answers visible commits only, without …
younes-io Oct 23, 2026
a7e1887
feat: add the signed API request for related commits
younes-io Oct 23, 2026
b5875f8
feat: serve related commits at GET /api/v1/commits/{commit}/related
younes-io Oct 23, 2026
881c04a
test: API related commits carry the signed viewer's stars and hide in…
younes-io Oct 23, 2026
be81643
feat: type the related commits response
younes-io Oct 23, 2026
190f399
feat: fetchRelatedCommits() reads the rail and treats failures as empty
younes-io Oct 23, 2026
b84ebcb
test: fetchRelatedCommits() on success, 429, network failure and abort
younes-io Oct 23, 2026
333abf3
feat: useRelatedCommits() fetches the rail after the page renders
younes-io Oct 23, 2026
01798cc
feat: RelatedCommits rail renders neighbours as commit cards
younes-io Oct 23, 2026
cfa9b4a
feat: show the related commits rail under a thread
younes-io Oct 23, 2026
c271bac
docs: describe the related commits rail in the knowledge base
younes-io Oct 23, 2026
e1bf19d
feat: raise the related commits cutoff to 0.60 after measuring real p…
younes-io Oct 23, 2026
1bf9f0f
Merge remote-tracking branch 'origin/production' into feat/related-co…
younes-io Oct 26, 2026
ab4e0cd
Merge remote-tracking branch 'origin/production' into feat/related-co…
younes-io Oct 27, 2026
fc0e598
test: assert the related commit author by handle
younes-io Oct 27, 2026
7fcc681
fix: keep scanning the vector index until related commits pass the fi…
younes-io Oct 27, 2026
adb5b0d
fix: reopen a related commit when its delete is undone
younes-io Oct 27, 2026
d25243c
fix: rate limit the related commits route
younes-io Oct 27, 2026
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
6 changes: 6 additions & 0 deletions KNOWLEDGE_BASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,12 @@ Deleting a parent does not automatically delete other people's replies: an autho

Sources: [reply validation](app/Http/Requests/CreateCommitReplyRequest.php), [commit page](app/Http/Controllers/CommitController.php), [reply notifications](app/Actions/CreateReplyNotifications.php), [commit deletion](app/Actions/DeleteCommit.php).

### Related commits

Under a commit, /dev/null shows up to three commits from other threads that say something close to it. It only suggests commits you could find through search: nothing from people you block, who block you or whom you mute, and nothing still pending. When nothing is close, nothing shows.

Sources: [related commits query](app/Queries/RelatedCommitsQuery.php), [related commits rail](resources/js/components/related-commits.tsx).

### Quoting commits

Quote a visible commit to publish a new top-level commit with your own commentary and an embedded card pointing to the original. Quotes follow normal commit limits, image support, and moderation. The quote composer has a preview and link-card choice, but no poll or scheduling. Quoting is different from replying: the quote appears in logs as its own post, and it is also listed among the original's replies and counted in its reply count. The original author is notified after approval, except for self-quotes.
Expand Down
21 changes: 21 additions & 0 deletions app/Http/Controllers/Api/RelatedCommitController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers\Api;

use App\Http\Requests\ListApiRelatedCommitsRequest;
use App\Http\Resources\CommitResource;
use App\Models\Commit;
use App\Models\User;
use App\Queries\RelatedCommitsQuery;
use Illuminate\Container\Attributes\CurrentUser;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;

final readonly class RelatedCommitController
{
public function index(ListApiRelatedCommitsRequest $request, #[CurrentUser] ?User $user, Commit $commit, RelatedCommitsQuery $related): AnonymousResourceCollection
{
return CommitResource::collection($related->get($commit, $user));
}
}
20 changes: 20 additions & 0 deletions app/Http/Controllers/RelatedCommitController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use App\Http\Requests\ListRelatedCommitsRequest;
use App\Models\Commit;
use App\Models\User;
use App\Queries\RelatedCommitsQuery;
use Illuminate\Container\Attributes\CurrentUser;
use Illuminate\Http\JsonResponse;

final readonly class RelatedCommitController
{
public function index(ListRelatedCommitsRequest $request, #[CurrentUser] ?User $user, Commit $commit, RelatedCommitsQuery $related): JsonResponse
{
return response()->json(['commits' => $related->get($commit, $user)]);
}
}
28 changes: 28 additions & 0 deletions app/Http/Requests/ListApiRelatedCommitsRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

namespace App\Http\Requests;

use App\Models\Commit;
use Illuminate\Auth\Access\Response;
use Illuminate\Foundation\Http\FormRequest;

final class ListApiRelatedCommitsRequest extends FormRequest
{
public function authorize(): Response
{
$commit = $this->route('commit');
assert($commit instanceof Commit);

return $commit->isVisibleTo($this->user()) ? Response::allow() : Response::denyAsNotFound();
}

/**
* @return array{}
*/
public function rules(): array
{
return [];
}
}
28 changes: 28 additions & 0 deletions app/Http/Requests/ListRelatedCommitsRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

namespace App\Http\Requests;

use App\Models\Commit;
use Illuminate\Auth\Access\Response;
use Illuminate\Foundation\Http\FormRequest;

final class ListRelatedCommitsRequest extends FormRequest
{
public function authorize(): Response
{
$commit = $this->route('commit');
assert($commit instanceof Commit);

return $commit->isVisibleTo($this->user()) ? Response::allow() : Response::denyAsNotFound();
}

/**
* @return array{}
*/
public function rules(): array
{
return [];
}
}
5 changes: 5 additions & 0 deletions app/Providers/AppServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,11 @@ private function bootRateLimitingDefaults(): void
$headers,
)));

// Every commit page asks for its related commits, a vector index scan with no outside call...
RateLimiter::for('related-commits', fn (Request $request): Limit => Limit::perMinute(60)->by(
$request->user() instanceof User ? $this->userId($request) : 'ip:'.$request->ip(),
));

// Exports stream every commit a user ever made...
RateLimiter::for('exports', fn (Request $request): Limit => Limit::perHour(3)->by($this->userId($request)));
}
Expand Down
62 changes: 62 additions & 0 deletions app/Queries/RelatedCommitsQuery.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<?php

declare(strict_types=1);

namespace App\Queries;

use App\Models\Commit;
use App\Models\User;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;

final readonly class RelatedCommitsQuery
{
/**
* Cosine distance up to which two text-embedding-3-small vectors say about the same thing. Measured on 281 real
* posts: pairs under 0.45 are near duplicates, 0.45 to 0.60 share a topic, and past 0.60 the pairs come apart.
*/
public const float MAX_DISTANCE = 0.60;

public const int LIMIT = 3;

/**
* The top-level commits of other threads closest in meaning to the commit, closest first, loaded as the feeds load
* them so the same card renders them. Only commits the viewer could find through search are suggested, and none
* further than MAX_DISTANCE, so a commit with nothing close, or without an embedding, gets an empty list.
*
* The HNSW index hands back a fixed number of candidates before the filters run, so closer replies or hidden
* commits could use them all up. An iterative scan keeps reading the index until LIMIT commits pass the filters.
* The cutoff is applied to those afterwards, or a commit with nothing close would scan the whole index.
*
* @return Collection<int, Commit>
*/
public function get(Commit $commit, ?User $viewer): Collection
{
$nearest = Commit::query()
->select('commits.id')
->selectRaw(
'commits.embedding <=> (select source.embedding from commits as source where source.id = ?) as distance',

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.

@younes-io I'm just seeing this now, but just for future reference: Laravel AI SDK is a dependency on this project and offers similarity search out of the box. The commit search query already is an example of this! Missed this PR, but I'd like to refactor this.

@younes-io younes-io Oct 27, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@paulinevos ah i didn't know this, thanks for the info. sure, let me know if i can help refactor this.

UPDATE:

i looked into this with the help of claude.

the orderByVectorDistance / whereVectorSimilarTo helpers are laravel framework query builder methods rather than the ai sdk. the sdk's part is turning a typed term into a vector, which is what CommitSearchQuery needs. this query has no term, its question is a vector already in the commits table, so there's nothing for the sdk to do here.

i did try to move it onto the builder helpers. they all take the vector from php and bind it as json, none can point at a row, so the first cost is loading the source commit's embedding (which CommitBuilder strips from every load on purpose) and sending ~30 kb back as bindings per page view. the bigger cost is that whereVectorSimilarTo puts the 0.60 cutoff in the where clause. with hnsw.iterative_scan on, a where cutoff keeps the index scanning until it finds three matches, and a commit with nothing nearby is the common case for this rail. measured on 5000 random vectors in a local pgvector 0.8.7:

nothing within the cutoff rows read time
current query (cutoff after the limit) 4 0.8 ms
whereVectorSimilarTo shape, iterative scan on all 4897 25.7 ms
whereVectorSimilarTo shape, iterative scan off 40 0.7 ms

on production that would read up to the 20k scan budget per lonely commit. turning iterative scan off fixes the time but brings back the empty rail when closer replies eat all 40 candidates, which the "behind many closer replies" test pins.

so i'd leave the raw <=> subselect, and i'll add a line to the docblock saying why the builder helpers don't fit so the next reader doesn't retrace this. happy to work on it if you still see something i'm missing.

[$commit->id],
)
->topLevel()
->whereKeyNot($commit->threadRootId())
->visibleTo($viewer)
->withoutMutedAuthorsFor($viewer)
->orderBy('distance')
->limit(self::LIMIT);

return DB::transaction(function () use ($nearest, $viewer): Collection {
DB::statement('set local hnsw.iterative_scan = strict_order');

return Commit::query()
->joinSub($nearest, 'nearest', 'nearest.id', '=', 'commits.id')
->where('nearest.distance', '<=', self::MAX_DISTANCE)
->withVisibleReplyCount($viewer)
->withContinuationId($viewer)
->withCardDataFor($viewer)
->orderBy('nearest.distance')
->get()
->toBase();
});
}
}
31 changes: 31 additions & 0 deletions resources/js/components/related-commits.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { CommitCard } from '@/components/commit-card';
import { useRelatedCommits } from '@/hooks/use-related-commits';
import { laneOf } from '@/lib/utils';

export function RelatedCommits({ commitId }: { commitId: string }) {
const commits = useRelatedCommits(commitId);

if (commits.length === 0) {
return null;
}

return (
<section aria-labelledby="related-commits" className="border-t">
<div className="flex h-11 items-center border-b px-4 sm:px-6">
<h2 id="related-commits" className="font-semibold">
Related commits
</h2>
</div>

{commits.map((commit, index) => (
<CommitCard
key={commit.id}
commit={commit}
previousLane={
index === 0 ? undefined : laneOf(commits[index - 1])
}
/>
))}
</section>
);
}
46 changes: 46 additions & 0 deletions resources/js/hooks/use-related-commits.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { router } from '@inertiajs/react';
import { useEffect, useState } from 'react';
import RelatedCommitController from '@/actions/App/Http/Controllers/RelatedCommitController';
import { fetchRelatedCommits, hiddenAfterFlash } from '@/lib/related-commits';
import type { Commit } from '@/types';

export function useRelatedCommits(commitId: string): Commit[] {
const [loaded, setLoaded] = useState<{
id: string;
commits: Commit[];
hidden: ReadonlySet<string>;
}>({ id: '', commits: [], hidden: new Set() });

useEffect(() => {
const controller = new AbortController();

fetchRelatedCommits(
RelatedCommitController.index(commitId).url,
controller.signal,
)
.then((commits) =>
setLoaded({ id: commitId, commits, hidden: new Set() }),
)
.catch(() => {});

return () => controller.abort();
}, [commitId]);

useEffect(
() =>
router.on('flash', (event) =>
setLoaded((current) => ({
...current,
hidden: hiddenAfterFlash(
current.hidden,
event.detail.flash,
),
})),
),
[],
);

return loaded.id === commitId
? loaded.commits.filter((commit) => !loaded.hidden.has(commit.id))
: [];
}
54 changes: 54 additions & 0 deletions resources/js/lib/related-commits.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import type { Commit, RelatedCommits } from '@/types';

/**
* Takes the url rather than the commit id so the node test runner, which cannot resolve Wayfinder's imports, can load it.
*/
export function fetchRelatedCommits(
url: string,
signal: AbortSignal,
): Promise<Commit[]> {
return fetch(url, {
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
signal,
})
.then((response) =>
response.ok
? response.json().then((data: RelatedCommits) => data.commits)
: [],
)
.catch((error: unknown) => {
if (signal.aborted) {
throw error;
}

return [];
});
}

/**
* The rail keeps the order it was fetched in, so a deleted commit's card is hidden rather than dropped, and a restore
* mounts it afresh as the feeds do.
*/
export function hiddenAfterFlash(
hidden: ReadonlySet<string>,
{
deleted,
restored,
}: { deleted?: { id: string }; restored?: { id: string } },
): ReadonlySet<string> {
if (deleted) {
return new Set(hidden).add(deleted.id);
}

if (restored && hidden.has(restored.id)) {
const shown = new Set(hidden);
shown.delete(restored.id);

return shown;
}

return hidden;
}
3 changes: 3 additions & 0 deletions resources/js/pages/commit/show.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import LogController from '@/actions/App/Http/Controllers/LogController';
import { CommitCard, CommitTombstone } from '@/components/commit-card';
import { CopyLinkButton } from '@/components/copy-link-button';
import { GraphCanvas } from '@/components/graph-canvas';
import { RelatedCommits } from '@/components/related-commits';
import { useContinuation } from '@/hooks/use-continuation';
import AppLayout from '@/layouts/app-layout';
import { isDeleted, laneOf, shortHash } from '@/lib/utils';
Expand Down Expand Up @@ -164,6 +165,8 @@ export default function Show({
</div>
)}
</GraphCanvas>

{!isDeleted(commit) && <RelatedCommits commitId={commit.id} />}
</>
);
}
Expand Down
4 changes: 4 additions & 0 deletions resources/js/types/commit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,10 @@ export type ThreadContinuation = {
next_id: string | null;
};

export type RelatedCommits = {
commits: Commit[];
};

export type CursorPaginated<T> = {
data: T[];
next_cursor: string | null;
Expand Down
3 changes: 3 additions & 0 deletions routes/api.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
use App\Http\Controllers\Api\OrgPingController;
use App\Http\Controllers\Api\OrgReadController;
use App\Http\Controllers\Api\OrgTypingController;
use App\Http\Controllers\Api\RelatedCommitController;
use App\Http\Controllers\Api\RemoteCandidateController;
use App\Http\Controllers\Api\StarredCommitController;
use App\Http\Controllers\Api\SuggestedUserController;
Expand Down Expand Up @@ -89,6 +90,8 @@
->name('api-user-following.index');
Route::get('commits/{commit}', [ApiCommitController::class, 'show'])
->name('api-commit.show');
Route::get('commits/{commit}/related', [RelatedCommitController::class, 'index'])
->name('api-related-commit.index');
});

// The Mac app's latest release, which its update check compares with, unsigned so it sends nothing about anyone...
Expand Down
4 changes: 4 additions & 0 deletions routes/web.php
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@
use App\Http\Controllers\PrivacyController;
use App\Http\Controllers\ProductMetricsController;
use App\Http\Controllers\PushSubscriptionController;
use App\Http\Controllers\RelatedCommitController;
use App\Http\Controllers\RemoteCandidateController;
use App\Http\Controllers\ReportedCommitController;
use App\Http\Controllers\SafetyController;
Expand Down Expand Up @@ -254,6 +255,9 @@
->name('commit-reply.index');
Route::get('commits/{commit}/continuation', [CommitContinuationController::class, 'index'])
->name('commit-continuation.index');
Route::get('commits/{commit}/related', [RelatedCommitController::class, 'index'])
->middleware('throttle:related-commits')
->name('related-commit.index');
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Search...
Route::get('search', [SearchController::class, 'index'])
Expand Down
Loading
Loading