diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 8ff42d0322..90e4128ee4 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -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. @@ -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). diff --git a/resources/js/components/hot-topics.tsx b/resources/js/components/hot-topics.tsx index cef4156c58..345a32caa5 100644 --- a/resources/js/components/hot-topics.tsx +++ b/resources/js/components/hot-topics.tsx @@ -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 ( -
+

diff --git a/resources/js/components/search-dialog.tsx b/resources/js/components/search-dialog.tsx index 78db82cb5e..42044de953 100644 --- a/resources/js/components/search-dialog.tsx +++ b/resources/js/components/search-dialog.tsx @@ -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'; @@ -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'; @@ -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 @@ -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(''); @@ -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) { @@ -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; @@ -398,14 +395,19 @@ export function SearchDialog({ ))} ) : ( -
- {isAwaitingDeveloperName - ? 'Type a name or handle to find a developer' - : 'Searching…'} -
+ !foundNothing && + !isThrottled && ( +
+ {isAwaitingDeveloperName + ? 'Type a name or handle to find a developer' + : isEmpty + ? 'Start typing to search' + : 'Searching…'} +
+ ) )}
@@ -599,10 +601,10 @@ const headings: Record = { Recent searches ), - suggestion: ( + topic: ( - - Try searching for… + + Hot topics ), }; @@ -618,9 +620,13 @@ function headingBefore(options: Option[], index: number): ReactNode { } function hrefFor( - option: Exclude, + option: Exclude, term: string, ) { + if (option.kind === 'topic') { + return HeadlineController.show(option.id); + } + if (option.kind === 'developer') { return show(option.user); } @@ -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) ? ( @@ -716,7 +721,7 @@ function ResultOption({ ) : ( ; } - if (option.kind === 'suggestion' || option.kind === 'recent') { + if (option.kind === 'topic' || option.kind === 'recent') { return ( diff --git a/resources/js/lib/commit-search-results.ts b/resources/js/lib/commit-search-results.ts index 883ab574eb..741c8f4add 100644 --- a/resources/js/lib/commit-search-results.ts +++ b/resources/js/lib/commit-search-results.ts @@ -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, diff --git a/resources/js/lib/commit-search.ts b/resources/js/lib/commit-search.ts index 2c882ec274..77cec24b0d 100644 --- a/resources/js/lib/commit-search.ts +++ b/resources/js/lib/commit-search.ts @@ -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. */ diff --git a/resources/js/pages/headline/show.tsx b/resources/js/pages/headline/show.tsx index fb659c9246..47ea89617f 100644 --- a/resources/js/pages/headline/show.tsx +++ b/resources/js/pages/headline/show.tsx @@ -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({ @@ -12,6 +13,10 @@ export default function Show({ summary: string | null; commits: Commit[]; }) { + const results = new CommitSearchResults(name, commits).latest( + usePage().props.now, + ); + return ( <> @@ -35,7 +40,7 @@ export default function Show({ )} diff --git a/resources/js/pages/search/index.tsx b/resources/js/pages/search/index.tsx index a10e53feb8..5d9057c4e0 100644 --- a/resources/js/pages/search/index.tsx +++ b/resources/js/pages/search/index.tsx @@ -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; @@ -97,7 +98,7 @@ export default function Index({ query }: { query: string }) { {['Most relevant', 'Latest'].map((title) => ( {title} @@ -128,9 +129,11 @@ export default function Index({ query }: { query: string }) { )} {recentSearches.length > 0 && ( - + )} - +
+ +
); } @@ -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 ( -
+

- {recent ? ( - - ) : ( - - )} - {recent ? 'Recent searches' : 'Try searching for…'} + + Recent searches

    - {visible.map((term) => ( + {recent.map((term) => (