From 61d4dfa15e30f25c95b7a38480bdcb03dba02691 Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Tue, 27 Oct 2026 17:45:33 -0400 Subject: [PATCH 1/8] feat: add the slash command registry --- resources/js/lib/slash-commands.ts | 217 +++++++++++++++++++++++++++++ tests/Unit/SlashCommands.test.ts | 162 +++++++++++++++++++++ 2 files changed, 379 insertions(+) create mode 100644 resources/js/lib/slash-commands.ts create mode 100644 tests/Unit/SlashCommands.test.ts diff --git a/resources/js/lib/slash-commands.ts b/resources/js/lib/slash-commands.ts new file mode 100644 index 000000000..af07439d9 --- /dev/null +++ b/resources/js/lib/slash-commands.ts @@ -0,0 +1,217 @@ +import type { LucideIcon } from 'lucide-react'; +import { Braces, Bug, ChartBar, ImagePlus, Lightbulb } from 'lucide-react'; + +// Keep in sync with maxImages in resources/js/lib/read-picked-images.ts. +const maxImages = 3; + +export type SlashCommandId = 'image' | 'poll' | 'code' | 'bug' | 'feature'; + +export type SlashCommand = { + id: SlashCommandId; + label: string; + description: string; + aliases: string[]; + icon: LucideIcon; +}; + +export type ComposerSurface = { + surface: 'commit' | 'reply' | 'quote'; + hasPoll: boolean; + imageCount: number; + isScheduling: boolean; + processing: boolean; +}; + +export type SlashOption = { + command: SlashCommand; + enabled: boolean; + reason: string | null; +}; + +const commands: SlashCommand[] = [ + { + id: 'image', + label: 'Image', + description: 'Attach up to 3 images', + aliases: ['img'], + icon: ImagePlus, + }, + { + id: 'poll', + label: 'Poll', + description: 'Ask a question with up to 4 options', + aliases: [], + icon: ChartBar, + }, + { + id: 'code', + label: 'Code', + description: 'Insert a code block', + aliases: [], + icon: Braces, + }, + { + id: 'bug', + label: 'Bug', + description: 'Report a bug', + aliases: [], + icon: Bug, + }, + { + id: 'feature', + label: 'Feature', + description: 'Request a feature', + aliases: [], + icon: Lightbulb, + }, +]; + +/** + * The commands that turn the draft into a GitHub issue. + */ +export function isIssueCommand(command: SlashCommand): boolean { + return command.id === 'bug' || command.id === 'feature'; +} + +function disabledReason( + command: SlashCommand, + surface: ComposerSurface, +): string | null { + if (command.id === 'image') { + if (surface.hasPoll) { + return 'Remove the poll first'; + } + + if (surface.imageCount >= maxImages) { + return 'Maximum of 3 images'; + } + + return null; + } + + if (command.id === 'poll') { + if (surface.hasPoll) { + return 'A poll is already added'; + } + + if (surface.imageCount > 0) { + return 'Remove the images first'; + } + + return null; + } + + if (isIssueCommand(command)) { + if (surface.hasPoll) { + return 'Remove the poll first'; + } + + if (surface.imageCount > 0) { + return 'Remove the images first'; + } + + return null; + } + + return null; +} + +/** + * The commands a composer offers, in menu order, each with its availability or the reason it is disabled. + */ +export function availableCommands(surface: ComposerSurface): SlashOption[] { + return commands + .filter((command) => { + if ( + (command.id === 'poll' || isIssueCommand(command)) && + surface.surface !== 'commit' + ) { + return false; + } + + return !(isIssueCommand(command) && surface.isScheduling); + }) + .map((command) => { + if (surface.processing) { + return { command, enabled: false, reason: null }; + } + + const reason = disabledReason(command, surface); + + return { command, enabled: reason === null, reason }; + }); +} + +export function filterCommands( + options: SlashOption[], + query: string, +): SlashOption[] { + if (query === '') { + return options; + } + + return options.filter( + ({ command }) => + command.id.startsWith(query) || + command.aliases.some((alias) => alias.startsWith(query)), + ); +} + +export function matchCommand(query: string): SlashCommand | null { + return ( + commands.find( + (command) => + command.id === query || command.aliases.includes(query), + ) ?? null + ); +} + +const slashCommandBeforeCaretPattern = /(?:^|[\s([{>])\/([a-z]*)$/; + +/** + * The "/command" at the end of a prose segment, if any. + */ +export function slashCommandInProse(prose: string): string | null { + return slashCommandBeforeCaretPattern.exec(prose)?.[1] ?? null; +} + +/** + * The body without the "/command" token, dropping one adjacent space. + */ +export function consumeSlashToken( + value: string, + start: number, + query: string, +): string { + const before = value.slice(0, start); + const after = value.slice(start + query.length + 1); + + if (before.endsWith(' ')) { + return before.slice(0, -1) + after; + } + + if (after.startsWith(' ')) { + return before + after.slice(1); + } + + return before + after; +} + +/** + * The body with a fenced code block where the token was, and the caret index inside it. + */ +export function codeFenceInsertion( + value: string, + start: number, + end: number, +): { value: string; caret: number } { + const before = value.slice(0, start); + const after = value.slice(end); + const prefix = before === '' || before.endsWith('\n') ? '' : '\n'; + const suffix = after === '' || after.startsWith('\n') ? '' : '\n'; + + return { + value: `${before}${prefix}\`\`\`\n\n\`\`\`${suffix}${after}`, + caret: before.length + prefix.length + 4, + }; +} diff --git a/tests/Unit/SlashCommands.test.ts b/tests/Unit/SlashCommands.test.ts new file mode 100644 index 000000000..6ef3071fc --- /dev/null +++ b/tests/Unit/SlashCommands.test.ts @@ -0,0 +1,162 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import type { ComposerSurface } from '../../resources/js/lib/slash-commands.ts'; +import { + availableCommands, + codeFenceInsertion, + consumeSlashToken, + filterCommands, + matchCommand, + slashCommandInProse, +} from '../../resources/js/lib/slash-commands.ts'; + +const surface = ( + overrides: Partial = {}, +): ComposerSurface => ({ + surface: 'commit', + hasPoll: false, + imageCount: 0, + isScheduling: false, + processing: false, + ...overrides, +}); + +void test('lists image, poll, code, bug, and feature with stock copy', () => { + assert.deepEqual( + availableCommands(surface()).map((option) => [ + option.command.id, + option.enabled, + option.reason, + ]), + [ + ['image', true, null], + ['poll', true, null], + ['code', true, null], + ['bug', true, null], + ['feature', true, null], + ], + ); +}); + +void test('poll and the issue commands are only offered on top-level commits', () => { + for (const kind of ['reply', 'quote'] as const) { + assert.deepEqual( + availableCommands(surface({ surface: kind })).map( + (option) => option.command.id, + ), + ['image', 'code'], + ); + } +}); + +void test('issue commands are absent while scheduling and disabled by images and polls', () => { + const ids = (overrides: Partial) => + availableCommands(surface(overrides)).map( + (option) => option.command.id, + ); + const reason = (id: string, overrides: Partial) => + availableCommands(surface(overrides)).find( + (option) => option.command.id === id, + )!.reason; + + assert.deepEqual(ids({ isScheduling: true }), ['image', 'poll', 'code']); + assert.equal( + reason('bug', { imageCount: 1 }), + 'Remove the images first', + ); + assert.equal(reason('feature', { hasPoll: true }), 'Remove the poll first'); +}); + +void test('image is disabled with a reason by poll and the image limit', () => { + const reason = (overrides: Partial) => + availableCommands(surface(overrides)).find( + (option) => option.command.id === 'image', + )!.reason; + + assert.equal(reason({ hasPoll: true }), 'Remove the poll first'); + assert.equal(reason({ imageCount: 3 }), 'Maximum of 3 images'); +}); + +void test('poll is disabled with a reason by images and an existing poll', () => { + const reason = (overrides: Partial) => + availableCommands(surface(overrides)).find( + (option) => option.command.id === 'poll', + )!.reason; + + assert.equal(reason({ imageCount: 1 }), 'Remove the images first'); + assert.equal(reason({ hasPoll: true }), 'A poll is already added'); +}); + +void test('scheduling keeps images and polls available', () => { + const options = availableCommands(surface({ isScheduling: true })); + + assert.ok(options.every((option) => option.enabled)); +}); + +void test('every command is disabled without a reason while submitting', () => { + const options = availableCommands(surface({ processing: true })); + + assert.ok(options.every((option) => !option.enabled)); + assert.ok(options.every((option) => option.reason === null)); +}); + +void test('filters by prefix, aliases included', () => { + const options = availableCommands(surface()); + const ids = (query: string) => + filterCommands(options, query).map((option) => option.command.id); + + assert.deepEqual(ids(''), ['image', 'poll', 'code', 'bug', 'feature']); + assert.deepEqual(ids('i'), ['image']); + assert.deepEqual(ids('img'), ['image']); + assert.deepEqual(ids('po'), ['poll']); + assert.deepEqual(ids('c'), ['code']); + assert.deepEqual(ids('b'), ['bug']); + assert.deepEqual(ids('f'), ['feature']); + assert.deepEqual(ids('x'), []); +}); + +void test('resolves exact names and aliases for direct runs', () => { + assert.equal(matchCommand('image')?.id, 'image'); + assert.equal(matchCommand('img')?.id, 'image'); + assert.equal(matchCommand('poll')?.id, 'poll'); + assert.equal(matchCommand('code')?.id, 'code'); + assert.equal(matchCommand('bug')?.id, 'bug'); + assert.equal(matchCommand('feature')?.id, 'feature'); + assert.equal(matchCommand('imagex'), null); + assert.equal(matchCommand(''), null); +}); + +void test('consuming a token drops one adjacent space', () => { + assert.equal(consumeSlashToken('ask /poll', 4, 'poll'), 'ask'); + assert.equal(consumeSlashToken('/image tell us', 0, 'image'), 'tell us'); + assert.equal(consumeSlashToken('/code', 0, 'code'), ''); + assert.equal(consumeSlashToken('line\n/image', 5, 'image'), 'line\n'); +}); + +void test('a code insertion keeps the fences on their own lines', () => { + assert.deepEqual(codeFenceInsertion('see /code', 4, 9), { + value: 'see \n```\n\n```', + caret: 9, + }); + assert.deepEqual(codeFenceInsertion('/code', 0, 5), { + value: '```\n\n```', + caret: 4, + }); + assert.deepEqual(codeFenceInsertion('see /code here', 4, 9), { + value: 'see \n```\n\n```\n here', + caret: 9, + }); +}); + + +void test('finds the slash command being typed before the caret', () => { + assert.equal(slashCommandInProse('/'), ''); + assert.equal(slashCommandInProse('try /po'), 'po'); + assert.equal(slashCommandInProse('line\n/img'), 'img'); +}); + +void test('does not treat urls, dates, or mid-word slashes as commands', () => { + assert.equal(slashCommandInProse('https://example.com'), null); + assert.equal(slashCommandInProse('on 10/20'), null); + assert.equal(slashCommandInProse('foo/bar'), null); +}); From 49ea1e99489c41c5eeeef31f2431ed124f2feb2e Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Tue, 27 Oct 2026 17:45:33 -0400 Subject: [PATCH 2/8] feat: wire slash commands into the composers --- KNOWLEDGE_BASE.md | 4 +- PRODUCT.md | 2 +- .../js/components/commit-composer-dialog.tsx | 4 +- resources/js/components/commit-composer.tsx | 55 +- .../js/components/commit-image-picker.tsx | 10 +- .../js/components/commit-quote-button.tsx | 16 +- resources/js/components/commit-thread.tsx | 10 + resources/js/components/issue-command.tsx | 97 ---- resources/js/components/mention-textarea.tsx | 343 +++++++++++- resources/js/lib/issue-commands.ts | 9 - tests/Unit/ComposerDraft.test.tsx | 128 +++-- tests/Unit/IssueCommands.test.ts | 18 +- tests/Unit/QuoteDraft.test.tsx | 41 ++ tests/Unit/SlashCommandMenu.test.tsx | 524 ++++++++++++++++++ 14 files changed, 1028 insertions(+), 233 deletions(-) create mode 100644 tests/Unit/SlashCommandMenu.test.tsx diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 97b28096d..3c141a496 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -180,9 +180,11 @@ Livestream history: the team agreed to build semantic search on [day two at 2:57 A **commit** is a post on /dev/null, not a Git commit in a repository. Open the composer or press **c**, write your update, and choose **Commit** or press **⌘/Ctrl+Enter**. Text-only commits accept 1–4,000 characters; as you type, the ring around your avatar in the composer fills toward that limit. On the web and in the app, an image attachment makes the text optional (terminal commits still need text), but the 4,000-character maximum still applies. Runs of blank lines outside code collapse to one before the length is checked. Lists fold posts longer than 280 characters or 8 lines behind **Show more**, without cutting through code. Commits can be created and deleted, but not edited after publication. There are no plans to add commit editing, and it will never be added: to change a commit, delete it and post a new one. +Typing `/` in the composer opens a slash command menu: `/image` attaches images (alias `/img`), `/poll` starts a poll, `/code` inserts a fenced code block, and `/bug` or `/feature` turn a fresh draft into a GitHub issue draft. Choose with ↑/↓ and Enter or Tab, or dismiss with Esc; an exact command followed by a space runs immediately. Commands a draft cannot use right now are hidden: `/poll`, `/bug`, and `/feature` are unavailable in replies and quotes, and issue commands are also hidden while scheduling. Commands that conflict with the current draft, such as images combined with polls, appear disabled with the reason. + Commits, replies, quotes, scheduling, terminal submissions, and app (signed API) commits share a limit of 10 attempts per minute per user. Posts go through [moderation](#moderation), so a newly submitted commit may be pending rather than public immediately. -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). +Sources: [composer](resources/js/components/commit-composer.tsx), [slash commands](resources/js/lib/slash-commands.ts), [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). Livestream history: the team settled on calling posts **commits** and their list a **log** on [day one at 2:56:00](https://www.youtube.com/watch?v=6zCNQa3hNm0&t=10560s). At [3:02:19](https://www.youtube.com/watch?v=6zCNQa3hNm0&t=10939s), they agreed to rename the post model too, rather than changing only the UI. These are naming decisions, not timestamps proving the rename had finished. diff --git a/PRODUCT.md b/PRODUCT.md index a874d12eb..9ed8fc626 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -31,7 +31,7 @@ Developers who left X or Bluesky, or are thinking about it, and want a feed with What exists, one line each (details in the knowledge base): -- **Commits:** text with code, images, link cards and embedded players, or a poll; replies, quotes, threads, scheduling, translation. +- **Commits:** text with code, images, link cards and embedded players, or a poll; slash commands for images, polls, code, and GitHub issue drafts; replies, quotes, threads, scheduling, translation. - **Logs:** Following, Latest, and Trending. - **Engagement:** follows, stars, saved commits, pins, mentions, and pastéis (an opt-in daily allowance of pastries). - **Profiles:** mirrored from GitHub, with an activity graph, streaks, achievements, a Unix rank, and a live marker while you stream on YouTube. diff --git a/resources/js/components/commit-composer-dialog.tsx b/resources/js/components/commit-composer-dialog.tsx index 572f7c317..ec968cefb 100644 --- a/resources/js/components/commit-composer-dialog.tsx +++ b/resources/js/components/commit-composer-dialog.tsx @@ -77,7 +77,7 @@ export function CommitComposerDialog({ onEscapeKeyDown={(event) => { if ( document.querySelector( - '[data-mention-suggestions], [data-command-suggestions], [role="dialog"].yarl__root', + '[data-mention-suggestions], [data-slash-suggestions], [data-emoji-suggestions], [data-issue-suggestions], [role="dialog"].yarl__root', ) || (typeof Element !== 'undefined' && event.target instanceof Element && @@ -91,7 +91,7 @@ export function CommitComposerDialog({ typeof Element !== 'undefined' && event.target instanceof Element && event.target.closest( - '[data-mention-suggestions], [data-command-suggestions], [role="dialog"].yarl__root', + '[data-mention-suggestions], [data-slash-suggestions], [data-emoji-suggestions], [data-issue-suggestions], [role="dialog"].yarl__root', ) ) { event.preventDefault(); diff --git a/resources/js/components/commit-composer.tsx b/resources/js/components/commit-composer.tsx index a8898760d..852f72883 100644 --- a/resources/js/components/commit-composer.tsx +++ b/resources/js/components/commit-composer.tsx @@ -25,7 +25,6 @@ import type { UseIssueCommandReturn } from '@/components/issue-command'; import { IssueCommandBadge, IssueCommandHint, - IssueCommandSuggestions, useIssueCommand, } from '@/components/issue-command'; import { LinkCardPicker } from '@/components/link-card-picker'; @@ -151,9 +150,17 @@ function CommitComposerBody({ : undefined); const textareaRef = useRef(null); const textBox = useRef(null); + const imageInputRef = useRef(null); const preview = useComposerPreview({ body, textareaRef, isSubmittable }); const isIssue = issue.command !== null; + const startPoll = () => { + setPoll(emptyPollDraft); + requestAnimationFrame(() => + document.getElementById(`${id}-poll-option-0`)?.focus(), + ); + }; + // When the dialog's text box shrinks, as the keyboard opens or an image is added, it keeps the caret in view. useEffect(() => { const box = textBox.current; @@ -257,17 +264,24 @@ function CommitComposerBody({ preview.handleShortcut(event) || onKeyDown(event) } - onFocus={(event) => { - issue.focus(); - - if (isRestored) { - moveCaretToEnd(event); - } - }} - onBlur={issue.blur} + onFocus={isRestored ? moveCaretToEnd : undefined} maxLength={ isIssue ? maxBodyLength : maxRawBodyLength } + onImage={ + isIssue + ? undefined + : () => imageInputRef.current?.click() + } + onPoll={isIssue ? undefined : startPoll} + onIssue={(kind, rest) => issue.start(kind, rest)} + commandContext={{ + surface: 'commit', + hasPoll: poll !== null, + imageCount: attachments.images.length, + isScheduling, + processing, + }} placeholder={ issue.command?.placeholder ?? (poll ? 'Ask a question' : 'What did you ship?') @@ -305,17 +319,6 @@ function CommitComposerBody({ /> )} - {issue.suggestions.length > 0 && ( - { - issue.start(kind, ''); - focusBody(id); - }} - /> - )} - { - setPoll(emptyPollDraft); - requestAnimationFrame(() => - document - .getElementById( - `${id}-poll-option-0`, - ) - ?.focus(), - ); - }} + onClick={startPoll} aria-label="Add poll" className="text-muted-foreground" data-test="add-poll-button" diff --git a/resources/js/components/commit-image-picker.tsx b/resources/js/components/commit-image-picker.tsx index 071453fee..433ab8727 100644 --- a/resources/js/components/commit-image-picker.tsx +++ b/resources/js/components/commit-image-picker.tsx @@ -1,4 +1,5 @@ import { ImagePlus, X } from 'lucide-react'; +import type { RefObject } from 'react'; import { useId, useRef, useState } from 'react'; import { ActionTooltip } from '@/components/action-tooltip'; import type { ShownImage } from '@/components/commit-images'; @@ -90,19 +91,22 @@ export function ImagePickerButton({ reading = false, onPick, className, + inputRef, }: { count: number; disabled?: boolean; reading?: boolean; onPick: (files: File[]) => Promise; className?: string; + inputRef?: RefObject; }) { - const inputRef = useRef(null); + const fallbackRef = useRef(null); + const ref = inputRef ?? fallbackRef; return ( <> = maxImages} - onClick={() => inputRef.current?.click()} + onClick={() => ref.current?.click()} aria-label={reading ? 'Reading images' : 'Attach images'} aria-busy={reading} className={cn('text-muted-foreground', className)} diff --git a/resources/js/components/commit-quote-button.tsx b/resources/js/components/commit-quote-button.tsx index b2501ea1c..917633bfc 100644 --- a/resources/js/components/commit-quote-button.tsx +++ b/resources/js/components/commit-quote-button.tsx @@ -138,6 +138,7 @@ function QuoteComposer({ ); const linkCard = useLinkCard(body); const textareaRef = useRef(null); + const imageInputRef = useRef(null); const commitRules = useCommitRules(); const keys = useKeyLabels(); @@ -171,7 +172,7 @@ function QuoteComposer({ onEscapeKeyDown={(event) => { if ( document.querySelector( - '[data-mention-suggestions], [role="dialog"].yarl__root', + '[data-mention-suggestions], [data-slash-suggestions], [data-emoji-suggestions], [role="dialog"].yarl__root', ) || (typeof Element !== 'undefined' && event.target instanceof Element && @@ -185,7 +186,7 @@ function QuoteComposer({ typeof Element !== 'undefined' && event.target instanceof Element && event.target.closest( - '[data-mention-suggestions], [role="dialog"].yarl__root', + '[data-mention-suggestions], [data-slash-suggestions], [data-emoji-suggestions], [role="dialog"].yarl__root', ) ) { event.preventDefault(); @@ -262,6 +263,16 @@ function QuoteComposer({ value={body} onValueChange={updateBody} onKeyDown={submitOnModEnter} + onImage={() => + imageInputRef.current?.click() + } + commandContext={{ + surface: 'quote', + hasPoll: false, + imageCount: images.length, + isScheduling: false, + processing, + }} onPaste={attachments.onPaste} onFocus={ isRestored ? moveCaretToEnd : undefined @@ -330,6 +341,7 @@ function QuoteComposer({ disabled={processing} reading={attachments.reading} onPick={attachments.pick} + inputRef={imageInputRef} className="size-8" /> (null); + const imageInputRef = useRef(null); const commitRules = useCommitRules(); const isReplyable = (images.length > 0 || characterCount(body.trim()) >= minBodyLength) && @@ -1493,6 +1494,14 @@ function ReplyComposer({ value={body} onValueChange={updateBody} onKeyDown={onKeyDown} + onImage={() => imageInputRef.current?.click()} + commandContext={{ + surface: 'reply', + hasPoll: false, + imageCount: images.length, + isScheduling: false, + processing, + }} onPaste={attachments.onPaste} onFocus={isRestored ? moveCaretToEnd : undefined} maxLength={maxRawBodyLength} @@ -1536,6 +1545,7 @@ function ReplyComposer({ disabled={processing} reading={attachments.reading} onPick={attachments.pick} + inputRef={imageInputRef} className="size-8" /> diff --git a/resources/js/components/issue-command.tsx b/resources/js/components/issue-command.tsx index 56b0513c7..b176c64fd 100644 --- a/resources/js/components/issue-command.tsx +++ b/resources/js/components/issue-command.tsx @@ -12,11 +12,9 @@ import type { IssueCommand, IssueKind } from '@/lib/issue-commands'; import { hintedCommand, issueCommands, - suggestedCommands, typedCommand, } from '@/lib/issue-commands'; import { hasModKey } from '@/lib/keyboard'; -import { cn } from '@/lib/utils'; const hintDismissedKey = 'issue-command-hint-dismissed'; @@ -57,22 +55,15 @@ export function useIssueCommand({ >({ command: '', body: '' }); const command = issueCommands.find((candidate) => candidate.kind === kind) ?? null; - // A draft restored from the stash can be a lone slash: the menu only opens while the user is typing in it. - const suggestions = - enabled && kind === null && !dismissed && focused - ? suggestedCommands(body) - : []; const hint = enabled && kind === null && !hintDismissed ? hintedCommand(body) : null; const start = (next: IssueKind, rest: string) => { setKind(next); - setSelected(0); updateBody(rest); }; const change = (value: string) => { - setDismissed(false); const typed = enabled && kind === null ? typedCommand(value) : null; if (typed) { @@ -145,37 +136,6 @@ export function useIssueCommand({ return false; } - if (suggestions.length > 0) { - if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { - event.preventDefault(); - setSelected( - (index) => - (index + - (event.key === 'ArrowDown' ? 1 : -1) + - suggestions.length) % - suggestions.length, - ); - return true; - } - - if ( - (event.key === 'Tab' || event.key === 'Enter') && - !event.shiftKey && - !hasModKey(event) - ) { - event.preventDefault(); - start(suggestions[selected % suggestions.length].kind, ''); - return true; - } - - if (event.key === 'Escape') { - event.preventDefault(); - event.stopPropagation(); - setDismissed(true); - return true; - } - } - if (command === null) { return false; } @@ -201,7 +161,6 @@ export function useIssueCommand({ return { command, - suggestions, hint, dismissHint: () => { setHintDismissed(true); @@ -211,11 +170,8 @@ export function useIssueCommand({ // Without storage, the hint stays dismissed until the page reloads. } }, - selected: selected % Math.max(suggestions.length, 1), processing: request.processing, change, - focus: () => setFocused(true), - blur: () => setFocused(false), start, exit: () => setKind(null), submit, @@ -259,59 +215,6 @@ export function IssueCommandBadge({ ); } -export function IssueCommandSuggestions({ - suggestions, - selected, - onPick, -}: { - suggestions: IssueCommand[]; - selected: number; - onPick: (kind: IssueKind) => void; -}) { - return ( -
- {suggestions.map((command, index) => { - const Icon = icons[command.kind]; - - return ( - - ); - })} -

- ↑↓ select · Tab or Enter pick · Esc dismiss -

-
- ); -} - export function IssueCommandHint({ command, onUse, diff --git a/resources/js/components/mention-textarea.tsx b/resources/js/components/mention-textarea.tsx index 199831642..52b99782f 100644 --- a/resources/js/components/mention-textarea.tsx +++ b/resources/js/components/mention-textarea.tsx @@ -10,13 +10,22 @@ import { typedProse } from '@/lib/code'; import type { EmojiSuggestion } from '@/lib/emoji'; import { emojifyInput, searchEmoji } from '@/lib/emoji'; import { issueReferenceBeforeCaret } from '@/lib/issue-reference'; +import { + availableCommands, + codeFenceInsertion, + consumeSlashToken, + filterCommands, + matchCommand, + slashCommandInProse, +} from '@/lib/slash-commands'; +import type { ComposerSurface, SlashOption } from '@/lib/slash-commands'; import { suggestionPosition } from '@/lib/suggestion-position'; import { toastWhenThrottled } from '@/lib/throttle'; import { characterCount, cn, laneColor, limitCharacters } from '@/lib/utils'; import type { OrgMember, User } from '@/types'; type Trigger = { - kind: 'mention' | 'emoji' | 'issue'; + kind: 'mention' | 'emoji' | 'issue' | 'slash'; start: number; query: string; }; @@ -115,7 +124,15 @@ function triggerAt( ? issueReferenceBeforeCaret(textarea.value, caret) : null; - return issue ? { kind: 'issue', ...issue } : null; + if (issue) { + return { kind: 'issue', ...issue }; + } + + const slash = slashCommandInProse(typedProse(before).at(-1) ?? ''); + + return slash === null + ? null + : { kind: 'slash', start: caret - slash.length - 1, query: slash }; } /** @@ -260,6 +277,10 @@ export function MentionTextarea({ members, reply, quote, + commandContext, + onImage, + onPoll, + onIssue, ...props }: Omit, 'value' | 'onChange'> & { ref?: Ref; @@ -271,12 +292,17 @@ export function MentionTextarea({ members?: OrgMember[]; reply?: string; quote?: string; + commandContext?: ComposerSurface; + onImage?: () => void; + onPoll?: () => void; + onIssue?: (kind: 'bug' | 'feature', rest: string) => void; }) { const { repository } = usePage().props; const id = useId(); const textareaRef = useRef(null); const listRef = useRef(null); const dismissedIssue = useRef(null); + const dismissedSlash = useRef(null); const [trigger, setTrigger] = useState(null); const [position, setPosition] = useState(null); const [results, setResults] = useState(null); @@ -313,6 +339,10 @@ export function MentionTextarea({ const track = (textarea: HTMLTextAreaElement) => { const next = triggerAt(textarea, suggestsMentions, suggestsIssues); + if (next?.kind !== 'slash' || next.start !== dismissedSlash.current) { + dismissedSlash.current = null; + } + if ( next?.kind === 'issue' && dismissedIssue.current === `${next.start}:${next.query}` @@ -497,7 +527,42 @@ export function MentionTextarea({ ? issue !== null : trigger.kind === 'emoji' ? emojis.length > 0 - : suggestions.length > 0 || query !== ''); + : trigger.kind === 'slash' + ? false + : suggestions.length > 0 || query !== ''); + const slash = trigger?.kind === 'slash' ? trigger : null; + const slashOptions = + slash !== null && commandContext !== undefined + ? filterCommands( + availableCommands(commandContext), + slash.query, + ).filter((option) => { + const id = option.command.id; + + if (id === 'image') { + return onImage !== undefined; + } + + if (id === 'poll') { + return onPoll !== undefined; + } + + if (id === 'bug' || id === 'feature') { + return onIssue !== undefined; + } + + return true; + }) + : []; + const isSlashOpen = + slash !== null && + position !== null && + slashOptions.length > 0 && + dismissedSlash.current !== slash.start; + const slashIndex = + slashOptions.length > 0 + ? Math.min(activeIndex, slashOptions.length - 1) + : 0; const insert = (text: string, separator: string) => { const textarea = textareaRef.current; @@ -552,7 +617,160 @@ export function MentionTextarea({ const selectEmoji = (emoji: EmojiSuggestion) => insert(emoji.emoji, ''); + const runSlash = (option: SlashOption): boolean => { + if (slash === null || !option.enabled) { + return false; + } + + const textarea = textareaRef.current; + const focus = (caret?: number) => + requestAnimationFrame(() => { + textarea?.focus(); + + if (caret !== undefined) { + textarea?.setSelectionRange(caret, caret); + } + }); + + if (option.command.id === 'code') { + const insertion = codeFenceInsertion( + value, + slash.start, + slash.start + slash.query.length + 1, + ); + + if ( + maxLength !== undefined && + characterCount(insertion.value) > maxLength + ) { + return false; + } + + onValueChange(insertion.value); + close(); + focus(insertion.caret); + + return true; + } + + if (option.command.id === 'bug' || option.command.id === 'feature') { + const rest = consumeSlashToken(value, slash.start, slash.query); + + close(); + focus(); + onIssue?.(option.command.id, rest); + + return true; + } + + onValueChange(consumeSlashToken(value, slash.start, slash.query)); + close(); + focus(); + + if (option.command.id === 'image') { + onImage?.(); + } + + if (option.command.id === 'poll') { + onPoll?.(); + } + + return true; + }; + + const slashOptionFits = (index: number): boolean => { + const option = slashOptions[index]; + + if (option === undefined || !option.enabled) { + return false; + } + + if ( + option.command.id !== 'code' || + maxLength === undefined || + slash === null + ) { + return true; + } + + const insertion = codeFenceInsertion( + value, + slash.start, + slash.start + slash.query.length + 1, + ); + + return characterCount(insertion.value) <= maxLength; + }; + + const handleSlashKeyDown = ( + event: KeyboardEvent, + ): boolean => { + if (!isSlashOpen || event.nativeEvent.isComposing || slash === null) { + return false; + } + + const hasModifier = event.metaKey || event.ctrlKey || event.altKey; + + if ( + (event.key === 'ArrowDown' || event.key === 'ArrowUp') && + slashOptions.length > 0 + ) { + event.preventDefault(); + const step = event.key === 'ArrowDown' ? 1 : -1; + setActiveIndex( + (slashIndex + step + slashOptions.length) % slashOptions.length, + ); + + return true; + } + + if ( + (event.key === 'Enter' || event.key === 'Tab') && + !hasModifier && + !event.shiftKey + ) { + if (!slashOptionFits(slashIndex)) { + return false; + } + + event.preventDefault(); + runSlash(slashOptions[slashIndex]); + + return true; + } + + if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + dismissedSlash.current = slash.start; + close(); + + return true; + } + + if (event.key === ' ') { + const exact = matchCommand(slash.query); + const index = slashOptions.findIndex( + (option) => option.command.id === exact?.id, + ); + const option = slashOptions[index]; + + if (option !== undefined && slashOptionFits(index)) { + event.preventDefault(); + runSlash(option); + + return true; + } + } + + return false; + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (handleSlashKeyDown(event)) { + return; + } + if (isOpen && !event.nativeEvent.isComposing) { const hasModifier = event.metaKey || event.ctrlKey || event.altKey; @@ -605,23 +823,28 @@ export function MentionTextarea({ onKeyDown?.(event); }; - const activeId = !isOpen - ? undefined - : trigger?.kind === 'issue' - ? `${id}-issue-${issue?.number}` - : trigger?.kind === 'emoji' - ? emojis[selectedIndex] && - `${id}-emoji-${emojis[selectedIndex].name}` - : suggestions[selectedIndex] && - `${id}-mention-${suggestions[selectedIndex].id}`; + const activeId = isSlashOpen + ? slashOptions[slashIndex] && + `${id}-slash-${slashOptions[slashIndex].command.id}` + : !isOpen + ? undefined + : trigger?.kind === 'issue' + ? `${id}-issue-${issue?.number}` + : trigger?.kind === 'emoji' + ? emojis[selectedIndex] && + `${id}-emoji-${emojis[selectedIndex].name}` + : suggestions[selectedIndex] && + `${id}-mention-${suggestions[selectedIndex].id}`; const isSearching = current?.key !== key; const listId = - trigger?.kind === 'issue' - ? `${id}-issues` - : trigger?.kind === 'emoji' - ? `${id}-emojis` - : `${id}-mentions`; + trigger?.kind === 'slash' + ? `${id}-slash` + : trigger?.kind === 'issue' + ? `${id}-issues` + : trigger?.kind === 'emoji' + ? `${id}-emojis` + : `${id}-mentions`; return ( <> @@ -671,7 +894,7 @@ export function MentionTextarea({ }} onKeyDown={handleKeyDown} aria-autocomplete="list" - aria-controls={isOpen ? listId : undefined} + aria-controls={isOpen || isSlashOpen ? listId : undefined} aria-activedescendant={activeId} /> @@ -682,7 +905,13 @@ export function MentionTextarea({ style={position} onMouseDown={(event) => event.preventDefault()} data-mention-suggestions - data-test="mention-suggestions" + data-test={ + trigger?.kind === 'issue' + ? 'issue-suggestions' + : trigger?.kind === 'emoji' + ? 'emoji-suggestions' + : 'mention-suggestions' + } > {trigger?.kind === 'issue' && issue ? (
    , document.body, )} + + {isSlashOpen && + position && + createPortal( +
    event.preventDefault()} + data-slash-suggestions + data-test="slash-suggestions" + > +
      + {slashOptions.map((option, index) => { + const Icon = option.command.icon; + + return ( +
    • + setActiveIndex(index) + } + onClick={() => runSlash(option)} + className={cn( + 'flex cursor-pointer items-center gap-2.5 rounded-sm px-2.5 py-2', + { + 'bg-accent': + index === slashIndex, + 'cursor-not-allowed opacity-60': + !option.enabled, + }, + )} + data-test="slash-command" + > + + + + {`/${option.command.id}`} + + + {option.reason ?? + option.command.description} + + + + ↵ + +
    • + ); + })} +
    +
    , + document.body, + )} ); } diff --git a/resources/js/lib/issue-commands.ts b/resources/js/lib/issue-commands.ts index 81947133b..131f7ee2d 100644 --- a/resources/js/lib/issue-commands.ts +++ b/resources/js/lib/issue-commands.ts @@ -24,15 +24,6 @@ export const issueCommands: IssueCommand[] = [ }, ]; -/** - * The commands a draft could still become while it is only a slash and the start of a name. - */ -export function suggestedCommands(body: string): IssueCommand[] { - return /^\/[a-z]*$/.test(body) - ? issueCommands.filter((command) => command.name.startsWith(body)) - : []; -} - /** * A complete command followed by a space or a new line turns the draft into an issue, without the command. */ diff --git a/tests/Unit/ComposerDraft.test.tsx b/tests/Unit/ComposerDraft.test.tsx index f786609a5..7faef67c5 100644 --- a/tests/Unit/ComposerDraft.test.tsx +++ b/tests/Unit/ComposerDraft.test.tsx @@ -8,11 +8,11 @@ const image = new File(['restored'], 'draft.png', { type: 'image/png' }); let state: unknown[] = []; let cursor = 0; const draft = { - key: 'stash:viewer:commit', body: 'A restored draft https://example.com', images: [] as File[], descriptions: [] as string[], isRestored: true, + key: 'stash:viewer:commit', updateBody: vi.fn(), updateImages: vi.fn((images: File[], descriptions: string[]) => { draft.images = images; @@ -76,7 +76,7 @@ vi.doMock('../../resources/js/hooks/use-stash', () => ({ const { CommitComposer } = await import('../../resources/js/components/commit-composer'); -const { IssueCommandBadge, IssueCommandHint, IssueCommandSuggestions } = +const { IssueCommandBadge, IssueCommandHint } = await import('../../resources/js/components/issue-command'); const { ImageDropOverlay, ImagePickerButton, ImagePickerPreviews } = await import('../../resources/js/components/commit-image-picker'); @@ -471,49 +471,14 @@ function keyDown( return event; } -function focus(view: ReturnType, handler = 'onFocus') { - const textarea = view.fields.find( - (element) => element.type === MentionTextarea, - )!; - (textarea.props[handler] as (event: unknown) => void)({ - currentTarget: { selectionStart: 0, selectionEnd: 0 }, - }); -} - -function hasSuggestions(): boolean { - return render().fields.some( - (element) => element.type === IssueCommandSuggestions, - ); -} - -test('a restored slash only opens the command menu while the composer is focused', () => { - draft.body = '/'; - assert.equal(hasSuggestions(), false); - - focus(render()); - assert.equal(hasSuggestions(), true); - - focus(render(), 'onBlur'); - assert.equal(hasSuggestions(), false); -}); - test('typing a command and a space turns the draft into an issue, and backspace turns it back into a commit', () => { draft.body = '/bu'; - focus(render()); let view = render(); - const suggestions = view.fields.find( - (element) => element.type === IssueCommandSuggestions, - )!; - assert.deepEqual( - (suggestions.props.suggestions as { name: string }[]).map( - (command) => command.name, - ), - ['/bug'], - ); - const textarea = view.fields.find( (element) => element.type === MentionTextarea, )!; + assert.equal(typeof textarea.props.onIssue, 'function'); + (textarea.props.onValueChange as (value: string) => void)( '/bug The timeline jumps', ); @@ -535,6 +500,11 @@ test('typing a command and a space turns the draft into an issue, and backspace false, ); assert.equal(find('data-test', 'add-poll-button'), undefined); + const issueTextarea = view.fields.find( + (element) => element.type === MentionTextarea, + )!; + assert.equal(issueTextarea.props.onImage, undefined); + assert.equal(issueTextarea.props.onPoll, undefined); assert.equal(view.form.props.onBefore(), false); keyDown(view, 'Backspace'); @@ -551,7 +521,7 @@ test('typing a command and a space turns the draft into an issue, and backspace assert.equal(view.form.props.onBefore(), true); }); -test('picking a suggestion with Enter starts an empty issue, and the shortcut opens it instead of committing', () => { +test('picking the issue command starts an empty issue, and the shortcut opens it instead of committing', () => { vi.stubGlobal('navigator', { userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X) Chrome/140.0.0.0', language: 'en-US', @@ -565,11 +535,15 @@ test('picking a suggestion with Enter starts an empty issue, and the shortcut op }); try { - draft.body = '/'; - focus(render()); - keyDown(render(), 'ArrowDown'); - const enter = keyDown(render(), 'Enter'); - assert.equal(enter.preventDefault.mock.calls.length, 1); + const view = render(); + const textarea = view.fields.find( + (element) => element.type === MentionTextarea, + )!; + + (textarea.props.onIssue as (kind: string, rest: string) => void)( + 'bug', + '', + ); assert.deepEqual(draft.updateBody.mock.calls.at(-1), ['']); draft.body = @@ -673,12 +647,17 @@ test('switching a discounted commit to an issue enforces the literal limit for t test('commands are not offered while the draft has images, a poll or a schedule', () => { draft.images = [image]; draft.body = '/'; - focus(render()); const view = render(); - assert.equal( - view.fields.some((element) => element.type === IssueCommandSuggestions), - false, - ); + const textarea = view.fields.find( + (element) => element.type === MentionTextarea, + )!; + assert.deepEqual(textarea.props.commandContext, { + surface: 'commit', + hasPoll: false, + imageCount: 1, + isScheduling: false, + processing: false, + }); draft.body = 'Found a bug'; assert.equal( render().fields.some((element) => element.type === IssueCommandHint), @@ -686,9 +665,6 @@ test('commands are not offered while the draft has images, a poll or a schedule' ); draft.body = '/'; - const textarea = view.fields.find( - (element) => element.type === MentionTextarea, - )!; (textarea.props.onValueChange as (value: string) => void)('/bug '); assert.deepEqual(draft.updateBody.mock.calls.at(-1), ['/bug ']); assert.equal( @@ -728,6 +704,52 @@ test('turning scheduling off clears the chosen time and commits now', () => { assert.equal(find('id', 'body-scheduled-at')!.props.value, ''); }); +test('passes slash command context and callbacks to the composer textarea', () => { + let view = render(); + let textarea = view.fields.find( + (element) => element.type === MentionTextarea, + )!; + assert.deepEqual(textarea.props.commandContext, { + surface: 'commit', + hasPoll: false, + imageCount: 0, + isScheduling: false, + processing: false, + }); + assert.equal(typeof textarea.props.onImage, 'function'); + assert.equal(typeof textarea.props.onPoll, 'function'); + assert.equal(typeof textarea.props.onIssue, 'function'); + assert.equal( + ( + view.fields.find((element) => element.type === ImagePickerButton)! + .props.inputRef as { current: unknown } + ).current, + null, + ); + + vi.stubGlobal('requestAnimationFrame', vi.fn()); + try { + const pollButton = view.fields.find( + (element) => element.props['data-test'] === 'add-poll-button', + )!; + (pollButton.props.onClick as () => void)(); + view = render(); + textarea = view.fields.find( + (element) => element.type === MentionTextarea, + )!; + assert.equal( + (textarea.props.commandContext as { hasPoll: boolean }).hasPoll, + true, + ); + + (textarea.props.onPoll as () => void)(); + view = render(); + assert.ok(view.fields.some((element) => element.type === PollEditor)); + } finally { + vi.unstubAllGlobals(); + } +}); + test('picker clears its selection before a pending read finishes so the same file can be retried', async () => { const reads: Array<() => void> = []; const selections: File[][] = []; diff --git a/tests/Unit/IssueCommands.test.ts b/tests/Unit/IssueCommands.test.ts index 82f553b67..c1af2817f 100644 --- a/tests/Unit/IssueCommands.test.ts +++ b/tests/Unit/IssueCommands.test.ts @@ -2,24 +2,10 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { hintedCommand, - suggestedCommands, typedCommand, } from '../../resources/js/lib/issue-commands.ts'; -test('suggests commands only while the draft is a slash and the start of a name', () => { - const names = (body: string) => - suggestedCommands(body).map((command) => command.name); - - assert.deepEqual(names('/'), ['/feature', '/bug']); - assert.deepEqual(names('/f'), ['/feature']); - assert.deepEqual(names('/bug'), ['/bug']); - assert.deepEqual(names('/deploy'), []); - assert.deepEqual(names('/bug '), []); - assert.deepEqual(names('Shipped /bug fixes'), []); - assert.deepEqual(names(''), []); -}); - -test('a complete command and a space or new line becomes an issue without the command', () => { +void test('a complete command and a space or new line becomes an issue without the command', () => { assert.deepEqual(typedCommand('/bug '), { kind: 'bug', body: '' }); assert.deepEqual(typedCommand('/feature Dark mode'), { kind: 'feature', @@ -34,7 +20,7 @@ test('a complete command and a space or new line becomes an issue without the co assert.equal(typedCommand('Fixed a /bug today'), null); }); -test('hints the command a plain draft mentions', () => { +void test('hints the command a plain draft mentions', () => { const name = (body: string) => hintedCommand(body)?.name ?? null; assert.equal(name('The search has a bug'), '/bug'); diff --git a/tests/Unit/QuoteDraft.test.tsx b/tests/Unit/QuoteDraft.test.tsx index 125df1f92..4398c5b8b 100644 --- a/tests/Unit/QuoteDraft.test.tsx +++ b/tests/Unit/QuoteDraft.test.tsx @@ -59,6 +59,8 @@ const { CommitQuoteButton } = await import('../../resources/js/components/commit-quote-button'); const { ImagePickerButton, ImagePickerPreviews } = await import('../../resources/js/components/commit-image-picker'); +const { MentionTextarea } = + await import('../../resources/js/components/mention-textarea'); const { StashLabel } = await import('../../resources/js/components/stash-label'); const { LinkCardPicker } = @@ -193,6 +195,45 @@ test('quotes submit restored images and clear the complete draft only on success } }); +test('passes slash command context to the quote composer textarea', () => { + const commit = { + id: 'quoted', + user_id: 'author', + user: { handle: 'author' }, + status: 'approved', + } as Commit; + const button = CommitQuoteButton({ commit })!; + const dialog = React.Children.toArray( + button.props.children, + )[1] as ReactElement<{ + children: ReactElement<{ commit: Commit; onClose: () => void }>; + }>; + const composer = dialog.props.children; + const renderComposer = composer.type as ( + props: typeof composer.props, + ) => ReactElement; + const form = elements(renderComposer(composer.props)).find( + (element) => element.type === Form, + )!; + const renderFields = form.props.children as (props: { + processing: boolean; + errors: Record; + }) => ReactNode; + const fields = elements(renderFields({ processing: false, errors: {} })); + const textarea = fields.find( + (element) => element.type === MentionTextarea, + )!; + + assert.deepEqual(textarea.props.commandContext, { + surface: 'quote', + hasPoll: false, + imageCount: 1, + isScheduling: false, + processing: false, + }); + assert.equal(textarea.props.onPoll, undefined); + assert.equal(typeof textarea.props.onImage, 'function'); +}); test('escape in the preview returns to writing without closing the quote', () => { const commit = { id: 'quoted', diff --git a/tests/Unit/SlashCommandMenu.test.tsx b/tests/Unit/SlashCommandMenu.test.tsx new file mode 100644 index 000000000..3c2b15082 --- /dev/null +++ b/tests/Unit/SlashCommandMenu.test.tsx @@ -0,0 +1,524 @@ +/// +import assert from 'node:assert/strict'; +import * as React from 'react'; +import type { ReactElement, ReactNode } from 'react'; +import { beforeEach, test, vi } from 'vite-plus/test'; + +let state: unknown[] = []; +let cursor = 0; + +vi.doMock('react', () => ({ + ...React, + useEffect: () => {}, + useEffectEvent: (callback: () => void) => callback, + useMemo: (factory: () => unknown) => factory(), + useId: () => `menu-${cursor++}`, + useSyncExternalStore: (_subscribe: unknown, snapshot: () => unknown) => + snapshot(), + useRef: (initial: unknown) => { + const index = cursor++; + if (!(index in state)) { + state[index] = { current: initial }; + } + return state[index]; + }, + useState: (initial: unknown) => { + const index = cursor++; + if (!(index in state)) { + state[index] = typeof initial === 'function' ? initial() : initial; + } + return [ + state[index], + (value: unknown) => { + state[index] = + typeof value === 'function' ? value(state[index]) : value; + }, + ]; + }, +})); +vi.doMock('react-dom', () => ({ + createPortal: (children: unknown) => children, +})); +vi.doMock('@inertiajs/react', () => ({ + usePage: () => ({ props: { repository: 'nunomaduro/dev-null.sh' } }), +})); +vi.doMock('../../resources/js/lib/suggestion-position', () => ({ + suggestionPosition: () => ({ left: 0, top: 0 }), +})); + +const { MentionTextarea } = + await import('../../resources/js/components/mention-textarea'); + +type Element = ReactElement>; + +const surface = (overrides: Record = {}) => ({ + surface: 'commit', + hasPoll: false, + imageCount: 0, + isScheduling: false, + processing: false, + ...overrides, +}); + +let value = ''; +const onValueChange = vi.fn((next: string) => { + value = next; +}); +const onImage = vi.fn(); +const onPoll = vi.fn(); +const onIssue = vi.fn(); + +const renderer = MentionTextarea as unknown as ( + props: Record, +) => ReactElement; + +function render(overrides: Record = {}) { + cursor = 0; + + return renderer({ + id: 'body', + value, + onValueChange, + commandContext: surface(), + onImage, + onPoll, + onIssue, + maxLength: 4000, + ...overrides, + }); +} + +function elements(node: ReactNode): Element[] { + if (!React.isValidElement(node)) { + return []; + } + + const element = node as Element; + + return [ + element, + ...React.Children.toArray(element.props.children as ReactNode).flatMap( + elements, + ), + ]; +} + +function field(tree: ReactElement): Element { + return elements(tree).find((element) => element.type === 'textarea')!; +} + +function type(next: string, overrides: Record = {}) { + const tree = render(overrides); + (field(tree).props.onChange as (event: unknown) => void)({ + currentTarget: { + value: next, + selectionStart: next.length, + selectionEnd: next.length, + getBoundingClientRect: () => ({ + top: 0, + left: 0, + bottom: 0, + right: 0, + width: 0, + height: 0, + }), + scrollTop: 0, + scrollLeft: 0, + }, + nativeEvent: { isComposing: false }, + }); + + return render(overrides); +} + +function press(tree: ReactElement, key: string) { + const event = { + key, + metaKey: false, + ctrlKey: false, + altKey: false, + shiftKey: false, + nativeEvent: { isComposing: false }, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + }; + + (field(tree).props.onKeyDown as (event: unknown) => void)(event); + + return event; +} + +function row(tree: ReactElement, command: string): Element | undefined { + return elements(tree).find( + (element) => + element.props['data-test'] === 'slash-command' && + elements(element).some( + (child) => child.props.children === `/${command}`, + ), + ); +} + +function menu(tree: ReactElement, testId: string): Element | undefined { + return elements(tree).find( + (element) => + element.props['data-test'] === testId || + element.props.testId === testId, + ); +} + +function mentionRows(tree: ReactElement): Element[] { + return elements(tree).filter( + (element) => element.props['data-test'] === 'mention-suggestion', + ); +} + +function emojiRows(tree: ReactElement): Element[] { + return elements(tree).filter( + (element) => element.props['data-test'] === 'emoji-suggestion', + ); +} + +function searching(tree: ReactElement): boolean { + return elements(tree).some((element) => + React.Children.toArray(element.props.children as ReactNode).includes( + 'Searching…', + ), + ); +} + +const alice = { id: 'alice', handle: 'alice', name: 'Alice', lane: 1 }; + +beforeEach(() => { + vi.clearAllMocks(); + state = []; + value = ''; + vi.stubGlobal('requestAnimationFrame', vi.fn()); + vi.stubGlobal('window', { + getComputedStyle: () => ({ + getPropertyValue: () => '0px', + lineHeight: '20px', + fontSize: '16px', + }), + visualViewport: { + offsetLeft: 0, + offsetTop: 0, + width: 100, + height: 100, + }, + innerWidth: 100, + innerHeight: 100, + }); + vi.stubGlobal('document', { + createElement: () => ({ + style: { setProperty: vi.fn() }, + textContent: '', + appendChild: vi.fn(), + offsetTop: 0, + offsetLeft: 0, + remove: vi.fn(), + }), + body: { appendChild: vi.fn() }, + }); +}); + +test('typing / opens the command menu with every available command', () => { + const tree = type('/'); + + assert.ok(row(tree, 'image')); + assert.ok(row(tree, 'poll')); + assert.ok(row(tree, 'code')); +}); + +test('filters the menu by prefix, alias included', () => { + assert.ok(row(type('/po'), 'poll')); + assert.equal(row(type('/po'), 'image'), undefined); + assert.ok(row(type('/img'), 'image')); + assert.equal(row(type('/x'), 'image'), undefined); +}); + +test('selecting poll activates the poll editor and removes the command text', () => { + const tree = type('Ask /poll'); + + (row(tree, 'poll')!.props.onClick as () => void)(); + + assert.equal(onValueChange.mock.lastCall?.[0], 'Ask'); + assert.equal(onPoll.mock.calls.length, 1); + assert.equal(row(render(), 'poll'), undefined); +}); + +test('an exact command followed by space runs without picking', () => { + const tree = type('see /image'); + const event = press(tree, ' '); + + assert.equal(event.preventDefault.mock.calls.length, 1); + assert.equal(onImage.mock.calls.length, 1); + assert.equal(onValueChange.mock.lastCall?.[0], 'see'); +}); + +test('disabled commands stay listed with their reason and never run', () => { + const overrides = { commandContext: surface({ hasPoll: true }) }; + const tree = type('/image', overrides); + + const image = row(tree, 'image')!; + assert.equal(image.props['aria-disabled'], true); + assert.ok( + elements(image).some( + (element) => element.props.children === 'Remove the poll first', + ), + ); + + const event = press(tree, ' '); + + assert.equal(event.preventDefault.mock.calls.length, 0); + assert.equal(onImage.mock.calls.length, 0); + assert.ok(row(render(overrides), 'image')); +}); + +test('enter and tab on a disabled command are left to the caller', () => { + const overrides = { + commandContext: surface({ hasPoll: true }), + }; + const onKeyDown = vi.fn(); + + for (const key of ['Enter', 'Tab']) { + const tree = type('/image', { ...overrides, onKeyDown }); + const event = press(tree, key); + + assert.equal(event.preventDefault.mock.calls.length, 0, key); + assert.equal(onImage.mock.calls.length, 0, key); + } + + assert.equal(onKeyDown.mock.calls.length, 2); +}); + +test('escape keeps the text, and the menu stays dismissed until the token is re-formed', () => { + const tree = type('/po'); + press(tree, 'Escape'); + + assert.equal(row(render(), 'poll'), undefined); + assert.equal(row(type('/pol'), 'poll'), undefined); + type(''); + assert.ok(row(type('/po'), 'poll')); +}); + +test('code inserts a fenced block where the command was typed', () => { + const tree = type('see /code'); + press(tree, ' '); + + assert.equal(onValueChange.mock.lastCall?.[0], 'see \n```\n\n```'); +}); + +test('code at the start of the body inserts without padding', () => { + const tree = type('/code'); + press(tree, ' '); + + assert.equal(onValueChange.mock.lastCall?.[0], '```\n\n```'); +}); + +test('code does not insert when the block would exceed the character limit', () => { + const prefix = `${'a'.repeat(4000 - '/code'.length - 1)} `; + value = prefix; + const onKeyDown = vi.fn(); + const tree = type(`${prefix}/code`, { onKeyDown }); + const event = press(tree, ' '); + + assert.equal(event.preventDefault.mock.calls.length, 0); + assert.equal(onValueChange.mock.calls.length, 1); + assert.equal(onValueChange.mock.lastCall?.[0], `${prefix}/code`); + assert.equal(onKeyDown.mock.calls.length, 1); +}); + +test('enter runs the active command when the block fits', () => { + const tree = type('see /code'); + const event = press(tree, 'Enter'); + + assert.equal(event.preventDefault.mock.calls.length, 1); + assert.equal(onValueChange.mock.lastCall?.[0], 'see \n```\n\n```'); +}); + +test('enter at the character limit is left to the caller', () => { + const prefix = `${'a'.repeat(4000 - '/code'.length - 1)} `; + value = prefix; + const onKeyDown = vi.fn(); + const tree = type(`${prefix}/code`, { onKeyDown }); + const event = press(tree, 'Enter'); + + assert.equal(event.preventDefault.mock.calls.length, 0); + assert.equal(onValueChange.mock.calls.length, 1); + assert.equal(onKeyDown.mock.calls.length, 1); +}); + +test('the two menus do not open for each other', () => { + assert.equal(menu(type('/'), 'mention-suggestions'), undefined); + + const mention = type('@ali'); + + assert.ok(menu(mention, 'mention-suggestions')); + assert.equal(row(mention, 'image'), undefined); +}); + +test('typing @ opens the mention menu in its searching state', () => { + const tree = type('@ali'); + + assert.ok(menu(tree, 'mention-suggestions')); + assert.equal(mentionRows(tree).length, 0); + assert.ok(searching(tree)); +}); + +test('mention results for a stale query are not shown', () => { + state[7] = { + key: ':bob', + users: [{ id: 'bob', handle: 'bob', name: 'Bob', lane: 2 }], + }; + const tree = type('@ali'); + + assert.ok(menu(tree, 'mention-suggestions')); + assert.equal(mentionRows(tree).length, 0); + assert.ok(searching(tree)); +}); + +test('selecting a mention inserts @handle and closes the menu', () => { + state[7] = { key: ':::ali', users: [alice] }; + const tree = type('@ali'); + const suggestion = mentionRows(tree)[0]!; + + assert.equal(suggestion.props.id, 'menu-0-mention-alice'); + + (field(tree).props.ref as (element: unknown) => void)({ + focus: vi.fn(), + setSelectionRange: vi.fn(), + }); + + (suggestion.props.onClick as () => void)(); + + assert.equal(onValueChange.mock.lastCall?.[0], '@alice '); + + const closed = render(); + + assert.equal(mentionRows(closed).length, 0); + assert.equal(menu(closed, 'mention-suggestions'), undefined); +}); + +test('poll is absent in replies and when no poll callback is given', () => { + assert.equal( + row( + type('/', { commandContext: surface({ surface: 'reply' }) }), + 'poll', + ), + undefined, + ); + assert.equal(row(type('/', { onPoll: undefined }), 'poll'), undefined); + assert.ok(row(type('/', { onPoll: undefined }), 'code')); +}); + +test('typing /b offers the issue commands and picking bug starts the flow', () => { + const tree = type('/b'); + const bug = row(tree, 'bug')!; + + assert.ok(bug); + assert.equal(row(tree, 'feature'), undefined); + + (bug.props.onClick as () => void)(); + + assert.deepEqual(onIssue.mock.lastCall, ['bug', '']); + assert.equal(row(render(), 'bug'), undefined); +}); + +test('an exact issue command followed by space runs without picking', () => { + const tree = type('/feature'); + const event = press(tree, ' '); + + assert.equal(event.preventDefault.mock.calls.length, 1); + assert.deepEqual(onIssue.mock.lastCall, ['feature', '']); +}); + +test('issue commands need the composer callback and stay out of replies and scheduling', () => { + assert.equal(row(type('/b', { onIssue: undefined }), 'bug'), undefined); + assert.equal( + row( + type('/', { commandContext: surface({ surface: 'reply' }) }), + 'bug', + ), + undefined, + ); + assert.equal( + row( + type('/', { commandContext: surface({ isScheduling: true }) }), + 'bug', + ), + undefined, + ); +}); + +test('the menu closes when the composer loses focus', () => { + const tree = type('/b'); + + assert.ok(row(tree, 'bug')); + + (field(tree).props.onBlur as (event: unknown) => void)({}); + + assert.equal(row(render(), 'bug'), undefined); +}); + +test('typing a shortcode opens the emoji menu', () => { + const tree = type('ship it :sm'); + + assert.ok(menu(tree, 'emoji-suggestions')); + assert.ok(emojiRows(tree).length > 0); + assert.equal(row(tree, 'image'), undefined); +}); + +test('selecting an emoji inserts it and closes the menu', () => { + const tree = type('ship it :sm'); + const suggestion = emojiRows(tree)[0]!; + + (field(tree).props.ref as (element: unknown) => void)({ + focus: vi.fn(), + setSelectionRange: vi.fn(), + }); + + (suggestion.props.onClick as () => void)(); + + const inserted = onValueChange.mock.lastCall?.[0] ?? ''; + + assert.ok(inserted.startsWith('ship it ')); + assert.ok(!inserted.includes(':sm')); + + const closed = render(); + + assert.equal(emojiRows(closed).length, 0); + assert.equal(menu(closed, 'emoji-suggestions'), undefined); +}); + +test('the emoji menu stays quiet inside code spans', () => { + const tree = type('`code :sm`'); + + assert.equal(menu(tree, 'emoji-suggestions'), undefined); +}); + +test('disabling mention suggestions keeps emoji and slash commands working', () => { + assert.equal( + menu(type('@ali', { suggestsMentions: false }), 'mention-suggestions'), + undefined, + ); + assert.ok( + menu(type(':sm', { suggestsMentions: false }), 'emoji-suggestions'), + ); + assert.ok( + menu(type('/', { suggestsMentions: false }), 'slash-suggestions'), + ); +}); + +test('submitting disables every command', () => { + const tree = type('/', { + commandContext: surface({ processing: true }), + }); + + const code = row(tree, 'code')!; + assert.equal(code.props['aria-disabled'], true); + assert.ok( + elements(code).some( + (element) => element.props.children === 'Insert a code block', + ), + ); +}); From ceeb0b97d1e202adc7e40537c8f20cdffa746777 Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Thu, 29 Oct 2026 07:45:05 -0400 Subject: [PATCH 3/8] feat: show the slash menu keyboard helper --- resources/js/components/mention-textarea.tsx | 3 +++ tests/Unit/SlashCommandMenu.test.tsx | 10 ++++++++++ 2 files changed, 13 insertions(+) diff --git a/resources/js/components/mention-textarea.tsx b/resources/js/components/mention-textarea.tsx index d21fd6590..aea70ac51 100644 --- a/resources/js/components/mention-textarea.tsx +++ b/resources/js/components/mention-textarea.tsx @@ -1251,6 +1251,9 @@ export function MentionTextarea({ ); })}
+

+ ↑↓ select · Tab or Enter pick · Esc dismiss +

, document.body, )} diff --git a/tests/Unit/SlashCommandMenu.test.tsx b/tests/Unit/SlashCommandMenu.test.tsx index 3da772a1e..ef0ccc819 100644 --- a/tests/Unit/SlashCommandMenu.test.tsx +++ b/tests/Unit/SlashCommandMenu.test.tsx @@ -229,6 +229,16 @@ test('typing / opens the command menu with every available command', () => { assert.ok(row(tree, 'code')); }); +test('shows the keyboard helper under the commands', () => { + const tree = type('/'); + + assert.ok( + elements(tree).some((element) => + String(element.props.children).includes('Tab or Enter pick'), + ), + ); +}); + test('filters the menu by prefix, alias included', () => { assert.ok(row(type('/po'), 'poll')); assert.equal(row(type('/po'), 'image'), undefined); From c17954547e979c4d7ac462361801133c3de7b7bf Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Thu, 29 Oct 2026 07:47:40 -0400 Subject: [PATCH 4/8] fix: keep the slash menu helper visible --- resources/js/components/mention-textarea.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/js/components/mention-textarea.tsx b/resources/js/components/mention-textarea.tsx index aea70ac51..461fa78f6 100644 --- a/resources/js/components/mention-textarea.tsx +++ b/resources/js/components/mention-textarea.tsx @@ -1251,7 +1251,7 @@ export function MentionTextarea({ ); })} -

+

↑↓ select · Tab or Enter pick · Esc dismiss

, From cc228f9781476ea7d1f43d7f87ae59051a0007ae Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Thu, 29 Oct 2026 07:48:58 -0400 Subject: [PATCH 5/8] fix: widen the slash command menu --- resources/js/components/mention-textarea.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/resources/js/components/mention-textarea.tsx b/resources/js/components/mention-textarea.tsx index 461fa78f6..2f261d90f 100644 --- a/resources/js/components/mention-textarea.tsx +++ b/resources/js/components/mention-textarea.tsx @@ -1183,8 +1183,11 @@ export function MentionTextarea({ position && createPortal(
event.preventDefault()} data-slash-suggestions data-test="slash-suggestions" @@ -1212,7 +1215,7 @@ export function MentionTextarea({ } onClick={() => runSlash(option)} className={cn( - 'flex cursor-pointer items-center gap-2.5 rounded-sm px-2.5 py-2', + 'flex cursor-pointer items-center gap-2.5 rounded-sm px-2.5 py-1.5', { 'bg-accent': index === slashIndex, @@ -1251,7 +1254,7 @@ export function MentionTextarea({ ); })} -

+

↑↓ select · Tab or Enter pick · Esc dismiss

, From ddf5991ad5df9c9b2d9a091b0f9d2c0b5cf6d103 Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Thu, 29 Oct 2026 12:01:54 -0400 Subject: [PATCH 6/8] ci: pin the repository for tests on forks --- .github/workflows/tests.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4d2a1d9c1..9eb384dc3 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -111,6 +111,7 @@ jobs: DB_PORT: 5432 DB_USERNAME: root DB_PASSWORD: password + GITHUB_REPOSITORY: dev-null-sh/dev-null.sh steps: - name: Checkout From 39bb4691998ea3091d3508fd784e826ea1def3bc Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Thu, 29 Oct 2026 12:14:54 -0400 Subject: [PATCH 7/8] test: pin the repository in every test --- .github/workflows/tests.yml | 1 - tests/Pest.php | 2 ++ 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9eb384dc3..4d2a1d9c1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -111,7 +111,6 @@ jobs: DB_PORT: 5432 DB_USERNAME: root DB_PASSWORD: password - GITHUB_REPOSITORY: dev-null-sh/dev-null.sh steps: - name: Checkout diff --git a/tests/Pest.php b/tests/Pest.php index ac43ed690..3083021e6 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -33,6 +33,8 @@ Process::preventStrayProcesses(); Sleep::fake(); + config(['services.github.repository' => 'dev-null-sh/dev-null.sh']); + $this->freezeTime(); }) ->in('Feature', 'Unit'); From d4fdcd76cb5f602ebdcb6119b1e0d2a40020a231 Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Tue, 3 Nov 2026 15:27:25 -0500 Subject: [PATCH 8/8] test: run the TSX suite with its own Vite config --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 0f1cb929f..17065b7a8 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "lint": "NODE_OPTIONS='--experimental-strip-types' vp fmt resources/ && NODE_OPTIONS='--experimental-strip-types' vp lint --fix", "test:lint": "NODE_OPTIONS='--experimental-strip-types' vp fmt --check resources/ && NODE_OPTIONS='--experimental-strip-types' vp lint", "test:types": "tsc --noEmit && tsc --noEmit --project tests/tsconfig.json", - "test:unit": "php artisan wayfinder:generate --with-form && node --experimental-strip-types --test tests/Unit/*.test.ts && vp test run tests/Unit/*.test.tsx" + "test:unit": "php artisan wayfinder:generate --with-form && node --experimental-strip-types --test tests/Unit/*.test.ts && vp test run --config tests/draft-images.config.ts" }, "dependencies": { "@daypicker/react": "^10.0.2",