From f523d63928598f69c780b6be909c22a156471fc5 Mon Sep 17 00:00:00 2001 From: Pauline Vos Date: Tue, 3 Nov 2026 22:47:09 +0000 Subject: [PATCH] feat: add instant latest tab to semantic search --- KNOWLEDGE_BASE.md | 2 + resources/js/lib/commit-search-results.ts | 25 +++++++++++ resources/js/pages/search/index.tsx | 54 ++++++++++++++++++++--- tests/Unit/CommitSearchResults.test.ts | 42 ++++++++++++++++++ 4 files changed, 118 insertions(+), 5 deletions(-) create mode 100644 resources/js/lib/commit-search-results.ts create mode 100644 tests/Unit/CommitSearchResults.test.ts diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 4012c6e8bb..67bfec0230 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -204,6 +204,8 @@ Sources: [log types](app/Enums/Log.php), [log queries](app/Queries/LogQueries), Open search with **⌘/Ctrl+K** or **/**. Developer search finds people by name or GitHub username, and lists the GitHub organizations that already have a page on /dev/null below them, by name or handle. Commit search, also available at `/search`, finds posts by meaning rather than requiring an exact phrase: describe the topic you want to read about. Commit queries must contain at least three characters and at most 100, and cover replies as well as top-level commits. An AI relevance check filters the nearest matches, so a search can return fewer results than asked. Results respect visibility, mutes, and blocks; semantic matching does not guarantee that every relevant commit will be returned. Guests can search developers and organizations; searching commits, and `/search`, need sign-in, since each commit search pays for AI. Commit search is limited to 15 searches a minute per person. +The search results page opens on **Most relevant**. **Latest** shows only matches from the past seven days, newest first, from the same result set. Switching tabs is instant and does not run another search; Latest does not look for additional recent matches outside that set. + Signed-in users see their three most recent distinct searches above the suggested searches, newest first. Repeating a search moves it to the top; adding a fourth discards the oldest from storage, not just from the display. Recent searches stay in this browser, scoped to the signed-in account, and do not sync to other devices or create server-side history. They are cleared on sign-out or account deletion in that browser. Guests do not retain recent searches. The public search searches developers, organizations, and commits, not this knowledge-base file. Feature documentation is available to the internal knowledge-base retriever once indexed; there is no public knowledge-base search endpoint yet. diff --git a/resources/js/lib/commit-search-results.ts b/resources/js/lib/commit-search-results.ts new file mode 100644 index 0000000000..883ab574eb --- /dev/null +++ b/resources/js/lib/commit-search-results.ts @@ -0,0 +1,25 @@ +import type { Commit } from '@/types'; + +export class CommitSearchResults { + term: string; + commits: Commit[]; + + constructor(term: string, commits: Commit[]) { + this.term = term; + this.commits = commits; + } + + latest(): CommitSearchResults { + const since = Date.now() - 7 * 24 * 60 * 60 * 1000; + + return new CommitSearchResults( + this.term, + this.commits + .filter((commit) => Date.parse(commit.created_at) >= since) + .sort( + (a, b) => + Date.parse(b.created_at) - Date.parse(a.created_at), + ), + ); + } +} diff --git a/resources/js/pages/search/index.tsx b/resources/js/pages/search/index.tsx index d4e82683b0..a10e53feb8 100644 --- a/resources/js/pages/search/index.tsx +++ b/resources/js/pages/search/index.tsx @@ -1,3 +1,4 @@ +import { Tab, TabGroup, TabList, TabPanel, TabPanels } from '@headlessui/react'; import { Head, router, usePage } from '@inertiajs/react'; import { ArrowRight, History, Search, Sparkles, X } from 'lucide-react'; import type { FormEvent } from 'react'; @@ -7,11 +8,14 @@ import { SearchResults } from '@/components/commit-search-results'; import { rememberSearch, useRecentSearches } from '@/hooks/use-recent-searches'; import AppLayout from '@/layouts/app-layout'; import { searchCommits, suggestedQueries } from '@/lib/commit-search'; -import type { Commit } from '@/types'; +import { CommitSearchResults } from '@/lib/commit-search-results'; const skeletonDelay = 300; -type Results = { term: string; commits: Commit[] }; +type Results = { + relevant: CommitSearchResults; + latest: CommitSearchResults; +}; export default function Index({ query }: { query: string }) { const userId = usePage().props.auth.user?.id ?? null; @@ -85,7 +89,43 @@ export default function Index({ query }: { query: string }) { {query !== '' && ( - + + + {['Most relevant', 'Latest'].map((title) => ( + + + {title} + + + + ))} + + + + + + + + + + )} {recentSearches.length > 0 && ( @@ -114,10 +154,14 @@ function useCommitSearch(query: string) { const timeout = setTimeout(() => setIsWaiting(true), skeletonDelay); searchCommits(query, undefined, controller.signal) - .then((commits) => setResults({ term: query, commits })) + .then((commits) => { + const relevant = new CommitSearchResults(query, commits); + setResults({ relevant, latest: relevant.latest() }); + }) .catch(() => { if (!controller.signal.aborted) { - setResults({ term: query, commits: [] }); + const relevant = new CommitSearchResults(query, []); + setResults({ relevant, latest: relevant.latest() }); } }) .finally(() => { diff --git a/tests/Unit/CommitSearchResults.test.ts b/tests/Unit/CommitSearchResults.test.ts new file mode 100644 index 0000000000..1df88b4b67 --- /dev/null +++ b/tests/Unit/CommitSearchResults.test.ts @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { CommitSearchResults } from '../../resources/js/lib/commit-search-results.ts'; +import type { Commit } from '../../resources/js/types'; + +await test('latest keeps the past week newest first without changing relevance order', (context) => { + context.mock.method(Date, 'now', () => Date.parse('2026-11-03T12:00:00Z')); + const commits = [ + { id: 'old', created_at: '2026-10-27T11:59:59.999Z' }, + { id: 'boundary', created_at: '2026-10-27T12:00:00Z' }, + { id: 'recent', created_at: '2026-11-02T13:00:00+01:00' }, + { id: 'newest', created_at: '2026-11-03T11:00:00Z' }, + { id: 'middle', created_at: '2026-10-30T12:00:00Z' }, + ] as Commit[]; + const relevant = new CommitSearchResults('testing strategies', commits); + + const latest = relevant.latest(); + + assert.equal(latest.term, 'testing strategies'); + assert.deepEqual( + latest.commits.map((commit) => commit.id), + ['newest', 'recent', 'middle', 'boundary'], + ); + assert.deepEqual( + relevant.commits.map((commit) => commit.id), + ['old', 'boundary', 'recent', 'newest', 'middle'], + ); +}); + +await test('latest is empty when there are no recent matches', (context) => { + context.mock.method(Date, 'now', () => Date.parse('2026-11-03T12:00:00Z')); + + for (const commits of [ + [], + [{ id: 'old', created_at: '2026-10-20T12:00:00Z' }] as Commit[], + ]) { + assert.deepEqual( + new CommitSearchResults('an idea', commits).latest().commits, + [], + ); + } +});