Repository navigation
feat: list related commits under every thread #1069
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
xHeaven
merged 27 commits into
dev-null-sh:production
from
younes-io:feat/related-commits
Oct 27, 2026
+721
−0
Merged
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 9cc891c
feat: query the nearest embedded commits to a commit
younes-io e5b28d3
test: related commits are the nearest top-level commits of other threads
younes-io 949d817
test: related commits stop at the distance cutoff and cap at three
younes-io b270242
test: related commits are empty without a source or candidate vector
younes-io 59ac350
test: related commits hide what search hides from the viewer
younes-io ff42cb6
test: related commits load card data in one query with no outside call
younes-io 6b8009e
feat: deny related commits as not found when the commit is not visible
younes-io afb538a
feat: serve related commits at GET /commits/{commit}/related
younes-io aa49e98
test: related commits endpoint answers visible commits only, without …
younes-io a7e1887
feat: add the signed API request for related commits
younes-io b5875f8
feat: serve related commits at GET /api/v1/commits/{commit}/related
younes-io 881c04a
test: API related commits carry the signed viewer's stars and hide in…
younes-io be81643
feat: type the related commits response
younes-io 190f399
feat: fetchRelatedCommits() reads the rail and treats failures as empty
younes-io b84ebcb
test: fetchRelatedCommits() on success, 429, network failure and abort
younes-io 333abf3
feat: useRelatedCommits() fetches the rail after the page renders
younes-io 01798cc
feat: RelatedCommits rail renders neighbours as commit cards
younes-io cfa9b4a
feat: show the related commits rail under a thread
younes-io c271bac
docs: describe the related commits rail in the knowledge base
younes-io e1bf19d
feat: raise the related commits cutoff to 0.60 after measuring real p…
younes-io 1bf9f0f
Merge remote-tracking branch 'origin/production' into feat/related-co…
younes-io ab4e0cd
Merge remote-tracking branch 'origin/production' into feat/related-co…
younes-io fc0e598
test: assert the related commit author by handle
younes-io 7fcc681
fix: keep scanning the vector index until related commits pass the fi…
younes-io adb5b0d
fix: reopen a related commit when its delete is undone
younes-io d25243c
fix: rate limit the related commits route
younes-io File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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)); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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)]); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 []; | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 []; | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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', | ||
| [$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(); | ||
| }); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
| ); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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)) | ||
| : []; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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/whereVectorSimilarTohelpers 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 whatCommitSearchQueryneeds. this query has no term, its question is a vector already in thecommitstable, 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
CommitBuilderstrips from every load on purpose) and sending ~30 kb back as bindings per page view. the bigger cost is thatwhereVectorSimilarToputs the 0.60 cutoff in the where clause. withhnsw.iterative_scanon, 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:whereVectorSimilarToshape, iterative scan onwhereVectorSimilarToshape, iterative scan offon 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.