From 4b60244d121f062d82e1d65a0d6313bcc4f59f92 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Tue, 20 Oct 2026 00:23:02 +0100 Subject: [PATCH 1/2] feat: write commits, open issues and go live from search Typing "commit", "feature", "bug" or "go live" in the search dialog now offers that action above the results. Picking one opens the commit dialog, already in /feature or /bug mode for those two, or the Go live dialog. While you're live, "end stream" ends it instead. The Go live dialog moves out of the profile menu into its own component so the layout can open it too. Co-Authored-By: Claude Opus 5.5 --- .../js/components/commit-composer-dialog.tsx | 7 + resources/js/components/commit-composer.tsx | 4 + resources/js/components/go-live-dialog.tsx | 142 ++++++++++++++++++ resources/js/components/issue-command.tsx | 6 +- .../js/components/profile-actions-menu.tsx | 137 +---------------- resources/js/components/search-dialog.tsx | 95 ++++++++++-- resources/js/layouts/app-layout.tsx | 40 ++++- resources/js/lib/search-actions.ts | 55 +++++++ tests/Unit/SearchActions.test.ts | 41 +++++ 9 files changed, 386 insertions(+), 141 deletions(-) create mode 100644 resources/js/components/go-live-dialog.tsx create mode 100644 resources/js/lib/search-actions.ts create mode 100644 tests/Unit/SearchActions.test.ts diff --git a/resources/js/components/commit-composer-dialog.tsx b/resources/js/components/commit-composer-dialog.tsx index 025490e3d..a9a5997b1 100644 --- a/resources/js/components/commit-composer-dialog.tsx +++ b/resources/js/components/commit-composer-dialog.tsx @@ -6,6 +6,7 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { useSharedState } from '@/hooks/use-shared-state'; +import type { IssueKind } from '@/lib/issue-commands'; const bodyId = 'commit-dialog-body'; @@ -43,12 +44,17 @@ export function useCommitDialogClosedCount(): number { return closedCount; } +/** + * Opens as a feature request or bug report when given an issue, as if `/feature` or `/bug` had been typed. + */ export function CommitComposerDialog({ open, onOpenChange, + issue = null, }: { open: boolean; onOpenChange: (open: boolean) => void; + issue?: IssueKind | null; }) { const [, setClosedCount] = useSharedState(closedCountKey, 0); @@ -99,6 +105,7 @@ export function CommitComposerDialog({ diff --git a/resources/js/components/commit-composer.tsx b/resources/js/components/commit-composer.tsx index 07eb5243c..b453ebc36 100644 --- a/resources/js/components/commit-composer.tsx +++ b/resources/js/components/commit-composer.tsx @@ -50,6 +50,7 @@ import { } from '@/hooks/use-stash'; import { useUser } from '@/hooks/use-user'; import { maxBodyLength, minBodyLength } from '@/lib/commit'; +import type { IssueKind } from '@/lib/issue-commands'; import { hasModKey, useKeyLabels } from '@/lib/keyboard'; import { characterCount, @@ -553,10 +554,12 @@ function CommitComposerBody({ export function CommitComposer({ id = 'body', isInDialog = false, + issue: initialIssue = null, onCommitted, }: { id?: string; isInDialog?: boolean; + issue?: IssueKind | null; onCommitted?: () => void; }) { const schedule = useCommitSchedule(); @@ -602,6 +605,7 @@ export function CommitComposer({ poll === null && !isScheduling, hasSecrets: secrets.findings.length > 0, + initialKind: initialIssue, onCreated: () => { drop(); focusBody(id); diff --git a/resources/js/components/go-live-dialog.tsx b/resources/js/components/go-live-dialog.tsx new file mode 100644 index 000000000..f9d37f198 --- /dev/null +++ b/resources/js/components/go-live-dialog.tsx @@ -0,0 +1,142 @@ +import { Form, router } from '@inertiajs/react'; +import { Radio } from 'lucide-react'; +import { useState } from 'react'; +import UserLiveController from '@/actions/App/Http/Controllers/UserLiveController'; +import InputError from '@/components/input-error'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogTitle, +} from '@/components/ui/dialog'; +import { Label } from '@/components/ui/label'; + +/** + * Mirrors `User::MAX_LIVE_HOURS`, the longest a stream can be shown as live. + */ +const maxHours = 12; + +const defaultHours = 2; + +export function endStream(callbacks?: { + onStart?: () => void; + onFinish?: () => void; +}): void { + router.visit(UserLiveController.destroy(), { + preserveScroll: true, + ...callbacks, + onSuccess: () => router.flushAll(), + }); +} + +export function GoLiveDialog({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const [hours, setHours] = useState(defaultHours); + const hoursLabel = hours === 1 ? '1 hour' : `${hours} hours`; + const filled = ((hours - 1) / (maxHours - 1)) * 100; + + return ( + + + Go live + + Share your YouTube stream and your avatar gets a live marker + that sends people to it, for as long as you choose. + + +
{ + onOpenChange(false); + setHours(defaultHours); + router.flushAll(); + }} + resetOnSuccess + > + {({ processing, errors }) => ( +
+
+ + + +
+ +
+
+ + + {hoursLabel} + +
+ + setHours( + event.currentTarget.valueAsNumber, + ) + } + aria-valuetext={hoursLabel} + style={{ + background: `linear-gradient(to right, var(--primary) ${filled}%, var(--muted) ${filled}%)`, + }} + className="h-1.5 w-full cursor-pointer appearance-none rounded-full outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 [&::-moz-range-thumb]:size-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-primary [&::-moz-range-thumb]:bg-background [&::-moz-range-thumb]:shadow-sm [&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-primary [&::-webkit-slider-thumb]:bg-background [&::-webkit-slider-thumb]:shadow-sm [&::-webkit-slider-thumb]:transition-transform active:[&::-webkit-slider-thumb]:scale-110 motion-reduce:[&::-webkit-slider-thumb]:transition-none" + data-test="live-hours-slider" + /> +
+ 1h + {maxHours}h +
+ +
+ + + + + + + +
+ )} +
+
+
+ ); +} diff --git a/resources/js/components/issue-command.tsx b/resources/js/components/issue-command.tsx index bf43bb884..495bc7418 100644 --- a/resources/js/components/issue-command.tsx +++ b/resources/js/components/issue-command.tsx @@ -34,15 +34,19 @@ export function useIssueCommand({ updateBody, enabled, hasSecrets, + initialKind = null, onCreated, }: { body: string; updateBody: (body: string) => void; enabled: boolean; hasSecrets: boolean; + initialKind?: IssueKind | null; onCreated: () => void; }) { - const [kind, setKind] = useState(null); + const [kind, setKind] = useState( + enabled ? initialKind : null, + ); const [selected, setSelected] = useState(0); const [dismissed, setDismissed] = useState(false); const [focused, setFocused] = useState(false); diff --git a/resources/js/components/profile-actions-menu.tsx b/resources/js/components/profile-actions-menu.tsx index a81735f16..e0f0014e2 100644 --- a/resources/js/components/profile-actions-menu.tsx +++ b/resources/js/components/profile-actions-menu.tsx @@ -1,50 +1,21 @@ -import { Form, router } from '@inertiajs/react'; import { Ellipsis, Radio, Square } from 'lucide-react'; import { useState } from 'react'; -import UserLiveController from '@/actions/App/Http/Controllers/UserLiveController'; -import InputError from '@/components/input-error'; +import { endStream, GoLiveDialog } from '@/components/go-live-dialog'; import { Button } from '@/components/ui/button'; -import { - Dialog, - DialogClose, - DialogContent, - DialogDescription, - DialogFooter, - DialogTitle, -} from '@/components/ui/dialog'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; -import { Label } from '@/components/ui/label'; import type { User } from '@/types'; -/** - * Mirrors `User::MAX_LIVE_HOURS`, the longest a stream can be shown as live. - */ -const maxHours = 12; - -const defaultHours = 2; - /** * The quick actions on your own profile, where other people's profiles have the mute and block menu. */ export function ProfileActionsMenu({ user }: { user: Pick }) { const [isGoingLive, setIsGoingLive] = useState(false); const [isEnding, setIsEnding] = useState(false); - const [hours, setHours] = useState(defaultHours); - const hoursLabel = hours === 1 ? '1 hour' : `${hours} hours`; - const filled = ((hours - 1) / (maxHours - 1)) * 100; - - const endStream = () => - router.visit(UserLiveController.destroy(), { - preserveScroll: true, - onStart: () => setIsEnding(true), - onFinish: () => setIsEnding(false), - onSuccess: () => router.flushAll(), - }); return ( <> @@ -64,7 +35,12 @@ export function ProfileActionsMenu({ user }: { user: Pick }) { {user.live ? ( + endStream({ + onStart: () => setIsEnding(true), + onFinish: () => setIsEnding(false), + }) + } data-test="end-stream" > @@ -82,104 +58,7 @@ export function ProfileActionsMenu({ user }: { user: Pick }) { - - - Go live - - Share your YouTube stream and your avatar gets a live - marker that sends people to it, for as long as you - choose. - - -
{ - setIsGoingLive(false); - setHours(defaultHours); - router.flushAll(); - }} - resetOnSuccess - > - {({ processing, errors }) => ( -
-
- - - -
- -
-
- - - {hoursLabel} - -
- - setHours( - event.currentTarget - .valueAsNumber, - ) - } - aria-valuetext={hoursLabel} - style={{ - background: `linear-gradient(to right, var(--primary) ${filled}%, var(--muted) ${filled}%)`, - }} - className="h-1.5 w-full cursor-pointer appearance-none rounded-full outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 [&::-moz-range-thumb]:size-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-primary [&::-moz-range-thumb]:bg-background [&::-moz-range-thumb]:shadow-sm [&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-primary [&::-webkit-slider-thumb]:bg-background [&::-webkit-slider-thumb]:shadow-sm [&::-webkit-slider-thumb]:transition-transform active:[&::-webkit-slider-thumb]:scale-110 motion-reduce:[&::-webkit-slider-thumb]:transition-none" - data-test="live-hours-slider" - /> -
- 1h - {maxHours}h -
- -
- - - - - - - -
- )} -
-
-
+ ); } diff --git a/resources/js/components/search-dialog.tsx b/resources/js/components/search-dialog.tsx index 9319c8ca2..cf2b72bd0 100644 --- a/resources/js/components/search-dialog.tsx +++ b/resources/js/components/search-dialog.tsx @@ -1,6 +1,16 @@ import { Link, router } from '@inertiajs/react'; import * as DialogPrimitive from '@radix-ui/react-dialog'; -import { ArrowRight, Search, Sparkles } from 'lucide-react'; +import type { LucideIcon } from 'lucide-react'; +import { + ArrowRight, + Bug, + Lightbulb, + Radio, + Search, + Sparkles, + Square, + SquarePen, +} from 'lucide-react'; import type { KeyboardEvent, ReactNode } from 'react'; import { useEffect, useState } from 'react'; import CommitController from '@/actions/App/Http/Controllers/CommitController'; @@ -17,6 +27,8 @@ import { TooManySearches, } from '@/lib/commit-search'; import { useKeyLabels } from '@/lib/keyboard'; +import type { SearchAction } from '@/lib/search-actions'; +import { searchActions } from '@/lib/search-actions'; import { awaitsDeveloperName, searchesCommits, @@ -38,6 +50,7 @@ const commitLimit = 5; type Found = { term: string; items: T[]; throttled?: boolean }; type Option = + | { kind: 'action'; id: string; action: SearchAction } | { kind: 'developer'; id: string; user: User; reason?: string | null } | { kind: 'commit'; id: string; commit: Commit } | { kind: 'all'; id: string } @@ -51,18 +64,23 @@ const suggestionOptions: Option[] = suggestedQueries.map((term, index) => ({ /** * Searches developers and commits. Picking a developer for a new conversation narrows it to developers, says why - * anyone can't be messaged, and hands the pick back instead of opening their profile. + * anyone can't be messaged, and hands the pick back instead of opening their profile. Given `onAction`, it also offers + * to write a commit, open an issue or go live when the search names one, above what it found. */ export function SearchDialog({ open, onOpenChange, onPickDeveloper, excludeId, + onAction, + isLive = false, }: { open: boolean; onOpenChange: (open: boolean) => void; onPickDeveloper?: (user: User) => void; excludeId?: string; + onAction?: (action: SearchAction) => void; + isLive?: boolean; }) { const keys = useKeyLabels(); const [query, setQuery] = useState(''); @@ -94,6 +112,7 @@ export function SearchDialog({ intent === 'topic' ? visibleDevelopers.slice(0, 2) : visibleDevelopers, visibleCommits, commitTerm, + onAction && !isPicking ? searchActions(term, { isLive }) : [], ); const isLoading = developers.isLoading || commits.isLoading; const hasSearched = @@ -133,6 +152,12 @@ export function SearchDialog({ close(); + if (option.kind === 'action') { + onAction?.(option.action); + + return; + } + if (option.kind === 'developer' && onPickDeveloper) { onPickDeveloper(option.user); @@ -261,7 +286,7 @@ export function SearchDialog({ isActive={index === activeIndex} onHover={() => setActiveIndex(index)} onOpen={close} - onPick={type} + onPick={() => openOption(option)} onPickDeveloper={ onPickDeveloper && ((user) => { @@ -414,7 +439,13 @@ function optionsFor( developers: FoundDeveloper[], commits: Commit[], commitTerm: string, + actions: SearchAction[], ): Option[] { + const actionOptions: Option[] = actions.map((action) => ({ + kind: 'action', + id: `action-${action}`, + action, + })); const developerOptions: Option[] = developers.map(({ user, reason }) => ({ kind: 'developer', id: `developer-${user.id}`, @@ -430,13 +461,19 @@ function optionsFor( commitTerm === '' ? [] : [{ kind: 'all', id: 'all' }]; if (intent === 'topic') { - return [...commitOptions, ...seeAll, ...developerOptions]; + return [ + ...actionOptions, + ...commitOptions, + ...seeAll, + ...developerOptions, + ]; } - return [...developerOptions, ...commitOptions, ...seeAll]; + return [...actionOptions, ...developerOptions, ...commitOptions, ...seeAll]; } const headings: Record = { + action: 'Actions', developer: 'Developers', commit: 'Most relevant commits', all: null, @@ -459,7 +496,7 @@ function headingBefore(options: Option[], index: number): ReactNode { } function hrefFor( - option: Exclude, + option: Exclude, term: string, ) { if (option.kind === 'developer') { @@ -492,7 +529,7 @@ function ResultOption({ isActive: boolean; onHover: () => void; onOpen: () => void; - onPick: (term: string) => void; + onPick: () => void; onPickDeveloper?: (user: User) => void; }) { const className = cn( @@ -529,7 +566,8 @@ function ResultOption({ role="option" aria-selected={isActive} > - {option.kind === 'suggestion' || + {option.kind === 'action' || + option.kind === 'suggestion' || (option.kind === 'developer' && onPickDeveloper) ? (