feat: implement ReasonPrompt component with quick tags

- Create ReasonPrompt dialog for capturing optional reasons
- Add quick tag buttons (Blocking, Urgent, Context switch, etc.)
- Support keyboard navigation (Escape to cancel)
- Handle text input with trimming and null for empty
- Different titles for different actions (set_focus, defer, skip)
- All 10 tests pass

Closes bd-2p0

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
teernisse
2026-02-26 10:10:02 -05:00
parent 175c1994fc
commit 378a173084
8 changed files with 425 additions and 51 deletions

View File

@@ -0,0 +1,168 @@
/**
* ReasonPrompt -- capture optional reason for user actions.
*
* Every significant action prompts for an optional reason to learn patterns.
* Quick tags allow fast categorization without typing.
*/
import { useState, useRef, useEffect, useCallback } from "react";
const QUICK_TAGS = [
{ id: "blocking", label: "Blocking" },
{ id: "urgent", label: "Urgent" },
{ id: "context_switch", label: "Context switch" },
{ id: "energy", label: "Energy" },
{ id: "flow", label: "Flow" },
];
const ACTION_TITLES: Record<string, string> = {
set_focus: "Setting focus to",
defer: "Deferring",
skip: "Skipping",
archive: "Archiving",
complete: "Completing",
};
interface ReasonPromptProps {
action: string;
itemTitle: string;
onSubmit: (data: { reason: string | null; tags: string[] }) => void;
onCancel: () => void;
}
export function ReasonPrompt({
action,
itemTitle,
onSubmit,
onCancel,
}: ReasonPromptProps): React.ReactElement {
const [reason, setReason] = useState("");
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const inputRef = useRef<HTMLTextAreaElement>(null);
const dialogRef = useRef<HTMLDivElement>(null);
// Focus input on mount
useEffect(() => {
inputRef.current?.focus();
}, []);
// Handle Escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onCancel();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [onCancel]);
const handleSubmit = useCallback(() => {
const trimmedReason = reason.trim();
onSubmit({
reason: trimmedReason || null,
tags: selectedTags,
});
}, [reason, selectedTags, onSubmit]);
const handleSkip = useCallback(() => {
onSubmit({ reason: null, tags: [] });
}, [onSubmit]);
const toggleTag = useCallback((tagId: string) => {
setSelectedTags((prev) =>
prev.includes(tagId) ? prev.filter((t) => t !== tagId) : [...prev, tagId]
);
}, []);
const actionTitle = ACTION_TITLES[action] || action;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
onClick={(e) => {
if (e.target === e.currentTarget) {
onCancel();
}
}}
>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="reason-prompt-title"
className="w-full max-w-md rounded-lg border border-zinc-700 bg-surface-base p-6 shadow-2xl"
>
{/* Header */}
<h2
id="reason-prompt-title"
className="mb-4 text-lg font-semibold text-zinc-200"
>
{actionTitle}: {itemTitle}
</h2>
{/* Reason input */}
<div className="mb-4">
<label
htmlFor="reason-input"
className="mb-2 block text-sm text-zinc-400"
>
Why? (optional, helps learn your patterns)
</label>
<textarea
ref={inputRef}
id="reason-input"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="e.g., Sarah pinged me, she's blocked on release"
rows={3}
className="w-full resize-none rounded-md border border-zinc-700 bg-surface-raised px-3 py-2 text-sm text-zinc-200 placeholder:text-zinc-600 focus:border-zinc-500 focus:outline-none focus:ring-1 focus:ring-zinc-500"
/>
</div>
{/* Quick tags */}
<div className="mb-6">
<div className="mb-2 text-sm text-zinc-400">Quick tags:</div>
<div className="flex flex-wrap gap-2">
{QUICK_TAGS.map((tag) => {
const isSelected = selectedTags.includes(tag.id);
return (
<button
key={tag.id}
type="button"
onClick={() => toggleTag(tag.id)}
className={`rounded-full px-3 py-1 text-xs font-medium transition-colors ${
isSelected
? "bg-zinc-600 text-zinc-100"
: "bg-zinc-800 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-300"
}`}
>
{tag.label}
</button>
);
})}
</div>
</div>
{/* Actions */}
<div className="flex justify-end gap-3">
<button
type="button"
onClick={handleSkip}
className="rounded-md px-4 py-2 text-sm text-zinc-400 transition-colors hover:bg-zinc-800 hover:text-zinc-300"
>
Skip reason
</button>
<button
type="button"
onClick={handleSubmit}
className="rounded-md bg-green-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-green-500"
>
Confirm
</button>
</div>
</div>
</div>
);
}