Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions KNOWLEDGE_BASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ Sources: [composer](resources/js/components/commit-composer.tsx), [issue command

### Stashed drafts

The app stashes unfinished commit and reply text, attached images, and image descriptions locally in your browser so you can return to them. Drafts belong to the signed-in user on that browser; they are not cloud drafts and do not synchronize between devices. Publishing or dropping a draft clears that stash. Browser storage restrictions or clearing site data can remove it; do not use the stash as your only copy of important writing.
The app stashes unfinished commit and reply text, attached images, image descriptions, and reply recipient exclusions locally in your browser so you can return to them. Drafts belong to the signed-in user on that browser; they are not cloud drafts and do not synchronize between devices. Publishing or dropping a draft clears that stash. Browser storage restrictions or clearing site data can remove it; do not use the stash as your only copy of important writing.

Sources: [stash hook](resources/js/hooks/use-stash.ts), [image storage](resources/js/lib/image-storage.ts).

Expand Down Expand Up @@ -747,7 +747,7 @@ Originally, Tom had no particular purpose beyond that nod to MySpace. He now has

Tom's lookups start with **OpenAI's Decisions API**, which evaluates simple criteria for each read-only tool using the latest message and the same bounded public thread context. Finding social posts and community reactions uses post search; factual platform questions and notable events, including milestones, downtime and security patches, use the knowledge base; current repository numbers and contribution opportunities use repository insights; feature development and attribution questions use repository history; and supported member counts use community statistics. Sources are complementary: a question about a feature's behavior, evolution and community reception can use the knowledge base, posts and repository history together, without needing to request each search explicitly. Tom compiles the relevant evidence into one answer, distinguishing documented facts, community opinions and development history. An inconclusive search can lead to a follow-up with different keywords, and each new reply has a fresh search allowance. When no tool is needed, such as small talk, ambiguous requests or anything outside his role, Tom simply replies as regular chat.

Tag **@tom** in a public commit or reply, or reply directly to one of his posts without tagging him. Once it is approved, Tom can reply under that commit, at most once, addressing its author rather than the thread's root poster. Replies further down the thread still need to tag him unless their direct parent is a Tom post. Mentions in code do not summon him. His replies use the normal commit moderation and notification pipeline. He does not reply to himself, across a block, in private messages, or to deleted or unapproved commits.
Tag **@tom** in a public commit or reply, or reply directly to one of his posts without tagging him. Once it is approved, Tom can reply under that commit, at most once, addressing its author rather than the thread's root poster. Replies further down the thread still need to tag him unless their direct parent is a Tom post. Mentions in code do not summon him. Selecting him as a reply notification recipient does not summon him either. His replies use the normal commit moderation and notification pipeline. He does not reply to himself, across a block, in private messages, or to deleted or unapproved commits.

If the real GitHub owner of `tom` signs in, the bot moves to **@devnull-tom** with the same profile, posts and friendships. Mention **@devnull-tom** to summon him after that; direct replies and mentions captured before the rename still reach the bot. New mentions of the human **@tom** do not summon him. He remains every newcomer's first friend.

Expand Down
7 changes: 4 additions & 3 deletions app/Actions/CreateCommit.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,24 +51,25 @@ public function __construct(
*
* @param list<UploadedFile> $images
* @param array<int, string|null> $imageDescriptions
* @param array<string, string> $replyRecipients
*/
public function handle(User $user, string $body, ?Commit $parent = null, ?Commit $quotedCommit = null, array $images = [], array $imageDescriptions = [], bool|string $linkPreview = true, ?NewPoll $poll = null): Commit
public function handle(User $user, string $body, ?Commit $parent = null, ?Commit $quotedCommit = null, array $images = [], array $imageDescriptions = [], bool|string $linkPreview = true, ?NewPoll $poll = null, array $replyRecipients = []): Commit
{
$url = match ($linkPreview) {
true => $this->urls->first($body),
false => null,
default => $linkPreview,
};

return DB::transaction(function () use ($user, $body, $parent, $quotedCommit, $images, $imageDescriptions, $url, $poll): Commit {
return DB::transaction(function () use ($user, $body, $parent, $quotedCommit, $images, $imageDescriptions, $url, $poll, $replyRecipients): Commit {
$linkPreview = $this->linkPreviewFor($url);
$createdAt = now();
$hasGoodReputation = $user->hasGoodReputation();
$mentions = $this->mentionedUsers->get($body);

$commit = $user->commits()->create([
'body' => $this->emojiShortcodes->replace($body),
'mentions' => $mentions,
'mentions' => [...$replyRecipients, ...$mentions],
'parent_id' => $parent?->id,
'root_id' => $parent?->threadRootId(),
'status' => $user->isAdmin() || $hasGoodReputation ? CommitStatus::Approved : CommitStatus::Pending,
Expand Down
3 changes: 2 additions & 1 deletion app/Http/Controllers/CommitReplyController.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,10 @@ public function index(ListCommitRepliesRequest $request, #[CurrentUser] ?User $u
return response()->json($replies->paginate($commit, $user, $request->boolean('newest'), $request->integer('limit', CommitRepliesQuery::PER_PAGE), $request->exceptedReplyId()));
}

/** Creates a reply addressed to the parent author and the validated selection of conversation participants. */
public function store(CreateCommitReplyRequest $request, #[CurrentUser] User $user, Commit $commit, CreateCommit $action, CommitThreadQuery $thread): JsonResponse
{
$reply = $action->handle($user, $request->string('body')->value(), $commit, images: $request->images(), imageDescriptions: $request->imageDescriptions(), linkPreview: $request->linkPreview());
$reply = $action->handle($user, $request->string('body')->value(), $commit, images: $request->images(), imageDescriptions: $request->imageDescriptions(), linkPreview: $request->linkPreview(), replyRecipients: $request->replyRecipients());
$parent = $thread->get($commit, $user);

return response()->json([
Expand Down
44 changes: 44 additions & 0 deletions app/Http/Requests/CreateCommitReplyRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use App\Actions\CreateCommitImages;
use App\Models\Commit;
use App\Models\User;
use App\Queries\MentionedUsersQuery;
use App\Services\Urls;
use Illuminate\Auth\Access\Response;
use Illuminate\Contracts\Validation\ValidationRule;
Expand All @@ -16,9 +17,12 @@
use Illuminate\Validation\Rules\Dimensions;
use Illuminate\Validation\Rules\File;
use Illuminate\Validation\Rules\In;
use Illuminate\Validation\Validator;

final class CreateCommitReplyRequest extends FormRequest
{
public const int MAX_REPLY_RECIPIENTS = 100;

public function authorize(): Response
{
$user = $this->user();
Expand All @@ -43,8 +47,12 @@ public function prepareForValidation(): void
public function rules(): array
{
$body = $this->input('body');
$parent = $this->route('commit');
assert($parent instanceof Commit);

return [
'reply_recipients' => ['sometimes', 'list', 'max:'.self::MAX_REPLY_RECIPIENTS],
'reply_recipients.*' => ['required', 'string', 'distinct', Rule::in(array_values($parent->mentions ?? []))],
'body' => CreateCommitRequest::bodyRules($this->hasFile('images')),
'link_preview_url' => ['nullable', 'string', Rule::in(is_string($body) ? resolve(Urls::class)->all($body) : [])],
'images' => ['nullable', 'list', 'max:'.CreateCommitImages::MAX_IMAGES],
Expand All @@ -61,6 +69,26 @@ public function rules(): array
];
}

/**
* Bound the combined inherited and explicit recipients so reply chains cannot grow notification fanout indefinitely.
*
* @return array<callable(Validator): void>
*/
public function after(): array
{
return [function (Validator $validator): void {
if ($validator->errors()->isNotEmpty()) {
return;
}

$recipients = [...$this->replyRecipients(), ...resolve(MentionedUsersQuery::class)->get($this->string('body')->value())];

if (count(array_unique($recipients)) > self::MAX_REPLY_RECIPIENTS) {
$validator->errors()->add('reply_recipients', 'A reply may include at most '.self::MAX_REPLY_RECIPIENTS.' participants across selected recipients and explicit mentions.');
}
}];
}

/**
* @return array<string, string>
*/
Expand Down Expand Up @@ -103,6 +131,22 @@ public function images(): array
return is_array($images) ? array_values($images) : [];
}

/**
* Only people already addressed by the parent may be included without an explicit body mention.
*
* @return array<string, string>
*/
public function replyRecipients(): array
{
$parent = $this->route('commit');
assert($parent instanceof Commit);

/** @var list<string> $recipients */
$recipients = $this->validated('reply_recipients', []);

return array_intersect($parent->mentions ?? [], $recipients);
}

/**
* @return list<string|null>
*/
Expand Down
4 changes: 3 additions & 1 deletion app/Models/Commit.php
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,9 @@ public function mentionedNonUsers(): array

public function isAddressedTo(User $user): bool
{
return in_array($user->id, $this->mentions ?? [], true) || $this->parent?->user_id === $user->id;
$explicitMentions = array_intersect_key($this->mentions ?? [], array_flip(resolve(Mentions::class)->handles($this->body)));

return in_array($user->id, $explicitMentions, true) || $this->parent?->user_id === $user->id;
}

/**
Expand Down
53 changes: 51 additions & 2 deletions resources/js/components/commit-thread.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
import { LinkCardPicker } from '@/components/link-card-picker';
import { LinkPreview } from '@/components/link-preview';
import { MentionTextarea } from '@/components/mention-textarea';
import { ReplyRecipientPicker } from '@/components/reply-recipient-picker';
import { StashLabel } from '@/components/stash-label';
import { showTimeout, timeoutFrom } from '@/components/timeout-dialog';
import { TimestampTooltip } from '@/components/timestamp-tooltip';
Expand Down Expand Up @@ -85,7 +86,7 @@
} from '@/lib/commit';
import { commitStateKey } from '@/lib/commit-state';
import { hasModKey } from '@/lib/keyboard';
import { exactCountTitle, formatCount, formatExactCount } from '@/lib/numbers';

Check warning on line 89 in resources/js/components/commit-thread.tsx

View workflow job for this annotation

GitHub Actions / lint

eslint(no-unused-vars)

resources/js/components/commit-thread.tsx:89:40: Identifier 'formatExactCount' is imported but never used.
import {
characterCount,
cn,
Expand Down Expand Up @@ -309,7 +310,7 @@
if (initial !== previousInitial) {
setPreviousInitial(initial);
if (initial !== undefined && (replies?.length ?? 0) <= repliesPerPage) {
++replyRequest.current;

Check warning on line 313 in resources/js/components/commit-thread.tsx

View workflow job for this annotation

GitHub Actions / lint

react(refs)

resources/js/components/commit-thread.tsx:313:27: Cannot access refs during render

Check warning on line 313 in resources/js/components/commit-thread.tsx

View workflow job for this annotation

GitHub Actions / lint

react(refs)

resources/js/components/commit-thread.tsx:313:15: Cannot access refs during render
setIsLoading(false);
setThread((current) => ({
...current,
Expand Down Expand Up @@ -395,7 +396,7 @@

let isCurrent = true;
const request = ++replyRequest.current;
setIsLoading(true);

Check warning on line 399 in resources/js/components/commit-thread.tsx

View workflow job for this annotation

GitHub Actions / lint

react(set-state-in-effect)

resources/js/components/commit-thread.tsx:399:9: Calling setState synchronously within an effect can trigger cascading renders

fetchRepliesUntil(
{ id: commitId },
Expand Down Expand Up @@ -679,6 +680,7 @@

return thread.isReplying && !isDeleted(commit) ? (
<ReplyComposer
key={commit.id}
parent={commit}
onCancel={thread.cancelReply}
onReplied={thread.onReplied}
Expand Down Expand Up @@ -1333,6 +1335,7 @@
);
}

/** Composes a reply with optional recipients inherited from the parent, excluding the signed-in user. */
function ReplyComposer({
parent,
onCancel,
Expand All @@ -1344,12 +1347,30 @@
}) {
const { user } = useUser();
const commitNotFound = useCommitNotFound();
const participants = Array.from(
new Map(
[
[parent.user.id, parent.user.handle],
...Object.entries(parent.mentions ?? {}).map(([handle, id]) => [
id,
handle,
]),
]
.filter(([id]) => id !== user.id)
.map(([id, handle]) => [id, { id, handle }]),
).values(),
);
const hasRecipientChoice = participants.some(
({ id }) => id !== parent.user.id,
);
const {
body,
images,
descriptions,
isRestored,
key: stashKey,
excludedRecipientIds,
updateExcludedRecipientIds,
updateBody,
updateImages,
drop,
Expand All @@ -1367,6 +1388,7 @@
body: string;
images: File[];
image_descriptions: string[];
reply_recipients: string[];
link_preview_url?: string;
commit_rules_accepted?: boolean;
},
Expand All @@ -1375,6 +1397,7 @@
body: '',
images: [],
image_descriptions: [],
reply_recipients: [],
});
const textareaRef = useRef<HTMLTextAreaElement>(null);
const commitRules = useCommitRules();
Expand Down Expand Up @@ -1435,6 +1458,13 @@
body,
images,
image_descriptions: descriptions,
reply_recipients: participants
.filter(
({ id }) =>
id !== parent.user.id &&
!excludedRecipientIds.includes(id),
)
.map(({ id }) => id),
commit_rules_accepted: commitRules.hasAccepted(),
...(linkCard.isChosen
? { link_preview_url: linkCard.link ?? '' }
Expand Down Expand Up @@ -1484,6 +1514,14 @@
<label htmlFor={`reply-${parent.id}`} className="sr-only">
Reply to @{parent.user.handle}
</label>
{hasRecipientChoice && (
<ReplyRecipientPicker
participants={participants}
authorId={parent.user.id}
excludedIds={excludedRecipientIds}
onExcludedIdsChange={updateExcludedRecipientIds}
/>
)}
<div className="grid">
<MentionTextarea
ref={textareaRef}
Expand All @@ -1498,7 +1536,11 @@
onPaste={attachments.onPaste}
onFocus={isRestored ? moveCaretToEnd : undefined}
maxLength={maxRawBodyLength}
placeholder={`Reply to @${parent.user.handle}`}
placeholder={
hasRecipientChoice
? 'Post your reply'
: `Reply to @${parent.user.handle}`
}
className={cn(
'col-start-1 row-start-1 block field-sizing-content max-h-[50dvh] min-h-9 w-full resize-none overflow-x-hidden overflow-y-auto bg-transparent px-1 text-base leading-relaxed outline-none placeholder:text-muted-foreground focus-visible:outline-none md:text-[15px]',
{ invisible: preview.isPreviewing },
Expand All @@ -1515,7 +1557,14 @@
)}
</div>
<InputError
message={errors.body ?? errors.link_preview_url}
message={
errors.body ??
errors.reply_recipients ??
Object.entries(errors).find(([key]) =>
key.startsWith('reply_recipients.'),
)?.[1] ??
errors.link_preview_url
}
className="px-1"
/>
{!preview.isPreviewing && (
Expand Down
96 changes: 96 additions & 0 deletions resources/js/components/reply-recipient-picker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';

export type ReplyParticipant = { id: string; handle: string };

/** Lets the author choose additional reply recipients while keeping the parent author selected. */
export function ReplyRecipientPicker({
participants,
authorId,
excludedIds,
onExcludedIdsChange,
}: {
participants: ReplyParticipant[];
authorId: string;
excludedIds: string[];
onExcludedIdsChange: (ids: string[]) => void;
}) {
const [open, setOpen] = useState(false);
const selected = participants.filter(({ id }) => !excludedIds.includes(id));

return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<button
type="button"
className="mb-2 block px-1 text-left text-sm text-muted-foreground hover:underline"
>
Replying to{' '}
<span className="text-primary">
{selected.length === 0
? 'no additional participants'
: selected
.map(
({ handle }, index) =>
`${index > 0 ? (index === selected.length - 1 ? ' and ' : ', ') : ''}@${handle}`,
)
.join('')}
</span>
</button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Replying to</DialogTitle>
<DialogDescription>
Choose who to include. The commit author stays selected.
Anyone you mention in your reply will also be notified.
</DialogDescription>
</DialogHeader>
<div className="divide-y">
{participants.map(({ id, handle }) => (
<label
key={id}
className="flex cursor-pointer items-center justify-between gap-4 py-4"
>
<span className="min-w-0 font-medium break-all">
@{handle}
{id === authorId && (
<span className="mt-1 block text-xs font-normal text-muted-foreground">
Commit author
</span>
)}
</span>
<Checkbox
checked={!excludedIds.includes(id)}
disabled={id === authorId}
onCheckedChange={(checked) =>
onExcludedIdsChange(
checked === true
? excludedIds.filter(
(excluded) => excluded !== id,
)
: [...excludedIds, id],
)
}
aria-label={`Include @${handle}`}
className="size-5"
/>
</label>
))}
</div>
<Button type="button" onClick={() => setOpen(false)}>
Done
</Button>
</DialogContent>
</Dialog>
);
}
Loading
Loading