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: 2 additions & 2 deletions KNOWLEDGE_BASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ Open search with **⌘/Ctrl+K** or **/**. Developer search finds people by name

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 search dialog and results page show the current hot topics instead of canned search suggestions. Selecting a topic opens its recap and newest matching commits. Signed-in users see their three most recent distinct searches above the hot topics, 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 or see hot topics.

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.

Expand All @@ -224,7 +224,7 @@ Approved top-level commits about specific developments in technology, current af

The desktop sidebar shows signed-in members up to five **Hot topics** above **Who to follow**, with bold headlines and a truncated recap underneath when one is available. 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 and public replies in their threads, including nested replies. Both the AI usefulness check and the recap writer receive the reply context: a question can support a recap when its answers provide useful substance. The sample includes one strongest root post per author, up to one hundred roots, plus up to one hundred of the newest replies across those threads. 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 and reply text and the headline are sent to the AI providers, without account metadata. Removing a linked top-level source commit, rejecting it, or deleting its author's account clears the associated recap. Removing a reply or its author's account does not clear an existing recap: its paraphrased contribution can remain until the recap expires, at most two hours after generation. 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 threads include a post or reply from an author the viewer has muted or blocked. The results respect visibility, mutes and blocks too.
Hot topics can also receive a short AI-written recap based on approved, available top-level commits from the past eight hours and public replies in their threads, including nested replies. Both the AI usefulness check and the recap writer receive the reply context: a question can support a recap when its answers provide useful substance. The sample includes one strongest root post per author, up to one hundred roots, plus up to one hundred of the newest replies across those threads. 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 and reply text and the headline are sent to the AI providers, without account metadata. Removing a linked top-level source commit, rejecting it, or deleting its author's account clears the associated recap. Removing a reply or its author's account does not clear an existing recap: its paraphrased contribution can remain until the recap expires, at most two hours after generation. Signed-in members can open a headline to read its available recap above semantic search results from the past week, shown newest first by default without an additional search. Expired recaps are omitted, and a recap is hidden if linked public threads include a post or reply from 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
7 changes: 5 additions & 2 deletions resources/js/components/hot-topics.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
import { Link, usePage } from '@inertiajs/react';
import { Flame } from 'lucide-react';
import { useId } from 'react';
import { show } from '@/actions/App/Http/Controllers/HeadlineController';

export function HotTopics() {
const { hotTopics = [] } = usePage().props;
const headingId = useId();

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

return (
<section aria-labelledby="aside-hot-topics">
<section aria-labelledby={headingId}>
<h2
id="aside-hot-topics"
id={headingId}
className="mb-2 flex items-center gap-2 text-sm font-semibold"
>
<Flame
Expand All @@ -26,6 +28,7 @@ export function HotTopics() {
<li key={topic.id}>
<Link
href={show(topic.id)}
prefetch={false}
className="group -mx-2 flex min-h-8 flex-col justify-center rounded-lg px-2 py-1 text-sm transition-[background-color] hover:bg-accent focus-visible:outline-ring"
title={topic.name}
>
Expand Down
67 changes: 36 additions & 31 deletions resources/js/components/search-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,18 @@ import type { LucideIcon } from 'lucide-react';
import {
ArrowRight,
Bug,
Flame,
History,
Lightbulb,
Radio,
Search,
Sparkles,
Square,
SquarePen,
} from 'lucide-react';
import type { KeyboardEvent, ReactNode } from 'react';
import { useEffect, useRef, useState } from 'react';
import CommitController from '@/actions/App/Http/Controllers/CommitController';
import HeadlineController from '@/actions/App/Http/Controllers/HeadlineController';
import ProfileSearchController from '@/actions/App/Http/Controllers/ProfileSearchController';
import SearchController from '@/actions/App/Http/Controllers/SearchController';
import { AuthorAvatar } from '@/components/author-avatar';
Expand All @@ -30,11 +31,7 @@ import {
} from '@/hooks/use-recent-searches';
import { signIn } from '@/lib/auth';
import { searchCandidates } from '@/lib/chat';
import {
searchCommits,
suggestedQueries,
TooManySearches,
} from '@/lib/commit-search';
import { searchCommits, TooManySearches } from '@/lib/commit-search';
import { useKeyLabels } from '@/lib/keyboard';
import type { SearchAction } from '@/lib/search-actions';
import { searchActions } from '@/lib/search-actions';
Expand Down Expand Up @@ -69,15 +66,9 @@ type Option =
| { kind: 'organization'; id: string; organization: Organization }
| { kind: 'commit'; id: string; commit: Commit }
| { kind: 'all'; id: string; needsSignIn: boolean }
| { kind: 'suggestion'; id: string; term: string }
| { kind: 'topic'; id: string; term: string }
| { kind: 'recent'; id: string; term: string };

const suggestionOptions: Option[] = suggestedQueries.map((term, index) => ({
kind: 'suggestion',
id: `suggestion-${index}`,
term,
}));

/**
* Searches developers, organizations and commits. Guests only search developers and organizations: each commit search pays for AI, so they're asked to
* sign in for those instead. Picking a developer for a new conversation narrows it to developers, says why
Expand Down Expand Up @@ -107,7 +98,8 @@ export function SearchDialog({
pickerScope?: 'remote' | 'share';
onCloseAutoFocus?: (event: Event) => void;
}) {
const userId = usePage().props.auth.user?.id ?? null;
const { auth, hotTopics = [] } = usePage().props;
const userId = auth.user?.id ?? null;
const recentSearches = useRecentSearches(userId);
const keys = useKeyLabels();
const [query, setQuery] = useState('');
Expand Down Expand Up @@ -178,10 +170,15 @@ export function SearchDialog({
id: `recent-${index}`,
term: recent,
}));
const topicOptions: Option[] = hotTopics.map((topic) => ({
kind: 'topic',
id: topic.id,
term: topic.name,
}));
const options =
isPicking || !(isEmpty || foundNothing)
? results
: [...recentOptions, ...suggestionOptions];
: [...recentOptions, ...topicOptions];

useEffect(() => {
if (open && !isPicking && hasSearched && !isLoading && !isThrottled) {
Expand All @@ -201,7 +198,7 @@ export function SearchDialog({
};

const openOption = (option: Option) => {
if (option.kind === 'suggestion' || option.kind === 'recent') {
if (option.kind === 'recent') {
type(option.term);

return;
Expand Down Expand Up @@ -398,14 +395,19 @@ export function SearchDialog({
))}
</ul>
) : (
<div
role="status"
className="px-5 py-8 text-center text-sm text-muted-foreground"
>
{isAwaitingDeveloperName
? 'Type a name or handle to find a developer'
: 'Searching…'}
</div>
!foundNothing &&
!isThrottled && (
<div
role="status"
className="px-5 py-8 text-center text-sm text-muted-foreground"
>
{isAwaitingDeveloperName
? 'Type a name or handle to find a developer'
: isEmpty
? 'Start typing to search'
: 'Searching…'}
</div>
)
)}

<div className="hidden items-center gap-4 border-t bg-muted/40 px-4 py-2 text-xs text-muted-foreground sm:flex">
Expand Down Expand Up @@ -599,10 +601,10 @@ const headings: Record<Option['kind'], ReactNode> = {
Recent searches
</span>
),
suggestion: (
topic: (
<span className="flex items-center gap-1.5">
<Sparkles aria-hidden className="size-3.5 text-primary" />
Try searching for…
<Flame aria-hidden className="size-3.5 text-(--lane-0)" />
Hot topics
</span>
),
};
Expand All @@ -618,9 +620,13 @@ function headingBefore(options: Option[], index: number): ReactNode {
}

function hrefFor(
option: Exclude<Option, { kind: 'action' | 'suggestion' | 'recent' }>,
option: Exclude<Option, { kind: 'action' | 'recent' }>,
term: string,
) {
if (option.kind === 'topic') {
return HeadlineController.show(option.id);
}

if (option.kind === 'developer') {
return show(option.user);
}
Expand Down Expand Up @@ -693,7 +699,6 @@ function ResultOption({
aria-selected={isActive}
>
{option.kind === 'action' ||
option.kind === 'suggestion' ||
option.kind === 'recent' ||
(option.kind === 'all' && option.needsSignIn) ||
(option.kind === 'developer' && onPickDeveloper) ? (
Expand All @@ -716,7 +721,7 @@ function ResultOption({
) : (
<Link
href={hrefFor(option, term)}
prefetch
prefetch={option.kind !== 'topic'}
tabIndex={-1}
onMouseMove={onHover}
onClick={onOpen}
Expand Down Expand Up @@ -747,7 +752,7 @@ function OptionContent({ option }: { option: Option }): ReactNode {
return <CommitResult commit={option.commit} />;
}

if (option.kind === 'suggestion' || option.kind === 'recent') {
if (option.kind === 'topic' || option.kind === 'recent') {
return (
<span className="flex min-w-0 flex-1 items-center justify-between gap-3 text-sm">
<span className="truncate" title={option.term}>
Expand Down
4 changes: 2 additions & 2 deletions resources/js/lib/commit-search-results.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ export class CommitSearchResults {
this.commits = commits;
}

latest(): CommitSearchResults {
const since = Date.now() - 7 * 24 * 60 * 60 * 1000;
latest(now = Date.now()): CommitSearchResults {
const since = now - 7 * 24 * 60 * 60 * 1000;

return new CommitSearchResults(
this.term,
Expand Down
9 changes: 0 additions & 9 deletions resources/js/lib/commit-search.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,6 @@
import CommitSearchController from '@/actions/App/Http/Controllers/CommitSearchController';
import type { Commit } from '@/types';

export const suggestedQueries = [
'shipping a side project',
'debugging in production',
'testing strategies that actually work',
'learning a new programming language',
'speeding up a slow app',
'working with AI coding tools',
];

/**
* Thrown when the server asks the client to slow down, so it can say so instead of claiming nothing matched.
*/
Expand Down
9 changes: 7 additions & 2 deletions resources/js/pages/headline/show.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Head } from '@inertiajs/react';
import { Head, usePage } from '@inertiajs/react';
import { SearchResults } from '@/components/commit-search-results';
import AppLayout from '@/layouts/app-layout';
import { CommitSearchResults } from '@/lib/commit-search-results';
import type { Commit } from '@/types';

export default function Show({
Expand All @@ -12,6 +13,10 @@ export default function Show({
summary: string | null;
commits: Commit[];
}) {
const results = new CommitSearchResults(name, commits).latest(
usePage().props.now,
);

return (
<>
<Head title={`${name} · Hot topics`} />
Expand All @@ -35,7 +40,7 @@ export default function Show({
)}
</header>
<SearchResults
results={{ term: name, commits }}
results={results}
heading="What nullers have been saying"
empty="No matching commits from the past week."
/>
Expand Down
43 changes: 18 additions & 25 deletions resources/js/pages/search/index.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
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 { ArrowRight, History, Search, X } from 'lucide-react';
import type { FormEvent } from 'react';
import { useEffect, useRef, useState } from 'react';
import SearchController from '@/actions/App/Http/Controllers/SearchController';
import { SearchResults } from '@/components/commit-search-results';
import { HotTopics } from '@/components/hot-topics';
import { rememberSearch, useRecentSearches } from '@/hooks/use-recent-searches';
import AppLayout from '@/layouts/app-layout';
import { searchCommits, suggestedQueries } from '@/lib/commit-search';
import { searchCommits } from '@/lib/commit-search';
import { CommitSearchResults } from '@/lib/commit-search-results';

const skeletonDelay = 300;
Expand Down Expand Up @@ -97,7 +98,7 @@ export default function Index({ query }: { query: string }) {
{['Most relevant', 'Latest'].map((title) => (
<Tab
key={title}
className="relative flex min-h-12 flex-1 justify-center py-3.5 text-[15px] font-medium text-muted-foreground outline-offset-[-3px] transition-colors hover:bg-accent hover:text-foreground data-selected:text-foreground"
className="relative flex min-h-12 flex-1 justify-center py-3.5 text-[15px] font-medium text-muted-foreground transition-colors outline-none hover:bg-accent hover:text-foreground data-selected:text-foreground md:outline-offset-[-3px] md:focus-visible:[outline-style:auto]"
>
<span className="relative">
{title}
Expand Down Expand Up @@ -128,9 +129,11 @@ export default function Index({ query }: { query: string }) {
</TabGroup>
)}
{recentSearches.length > 0 && (
<Suggestions onPick={pick} recent={recentSearches} />
<RecentSearches onPick={pick} recent={recentSearches} />
)}
<Suggestions onPick={pick} exclude={query} />
<div className="px-4 py-6 sm:px-6">
<HotTopics />
</div>
</>
);
}
Expand Down Expand Up @@ -180,37 +183,27 @@ function useCommitSearch(query: string) {
return { results, isWaiting };
}

function Suggestions({
function RecentSearches({
onPick,
exclude,
recent,
}: {
onPick: (term: string) => void;
exclude?: string;
recent?: string[];
recent: string[];
}) {
const visible =
recent ?? suggestedQueries.filter((term) => term !== exclude);
const headingId = recent ? 'recent-searches' : 'search-suggestions';

return (
<section aria-labelledby={headingId} className="px-4 py-6 sm:px-6">
<section
aria-labelledby="recent-searches"
className="px-4 py-6 sm:px-6"
>
<h2
id={headingId}
id="recent-searches"
className="mb-3 flex items-center gap-2 font-semibold"
>
{recent ? (
<History
aria-hidden
className="size-4 text-muted-foreground"
/>
) : (
<Sparkles aria-hidden className="size-4 text-primary" />
)}
{recent ? 'Recent searches' : 'Try searching for…'}
<History aria-hidden className="size-4 text-muted-foreground" />
Recent searches
</h2>
<ul className="space-y-2">
{visible.map((term) => (
{recent.map((term) => (
<li key={term}>
<button
type="button"
Expand Down
16 changes: 16 additions & 0 deletions tests/Unit/CommitSearchResults.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,22 @@ await test('latest keeps the past week newest first without changing relevance o
);
});

await test('latest can use the server render time instead of the browser clock', (context) => {
context.mock.method(Date, 'now', () => Date.parse('2026-11-20T12:00:00Z'));
const results = new CommitSearchResults('an idea', [
{ id: 'recent', created_at: '2026-11-01T12:00:00Z' },
{ id: 'old', created_at: '2026-10-25T12:00:00Z' },
] as Commit[]);

assert.deepEqual(
results
.latest(Date.parse('2026-11-03T12:00:00Z'))
.commits.map((commit) => commit.id),
['recent'],
);
assert.deepEqual(results.latest().commits, []);
});

await test('latest is empty when there are no recent matches', (context) => {
context.mock.method(Date, 'now', () => Date.parse('2026-11-03T12:00:00Z'));

Expand Down
Loading
Loading