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

File diff suppressed because one or more lines are too long

View File

@@ -11,8 +11,8 @@ use serde::Serialize;
use specta::Type; use specta::Type;
/// Simple greeting command for testing IPC /// Simple greeting command for testing IPC
#[tauri_specta::command] #[tauri::command]
#[specta(crate = "specta")] #[specta::specta]
pub fn greet(name: &str) -> String { pub fn greet(name: &str) -> String {
format!("Hello, {}! Welcome to Mission Control.", name) format!("Hello, {}! Welcome to Mission Control.", name)
} }
@@ -35,8 +35,8 @@ pub struct LoreSummaryStatus {
} }
/// Get the current status of lore integration by calling the real CLI. /// Get the current status of lore integration by calling the real CLI.
#[tauri_specta::command] #[tauri::command]
#[specta(crate = "specta")] #[specta::specta]
pub async fn get_lore_status() -> Result<LoreStatus, McError> { pub async fn get_lore_status() -> Result<LoreStatus, McError> {
get_lore_status_with(&RealLoreCli) get_lore_status_with(&RealLoreCli)
} }
@@ -107,8 +107,8 @@ pub struct BridgeStatus {
} }
/// Get the current status of the bridge (mapping counts, sync times). /// Get the current status of the bridge (mapping counts, sync times).
#[tauri_specta::command] #[tauri::command]
#[specta(crate = "specta")] #[specta::specta]
pub async fn get_bridge_status() -> Result<BridgeStatus, McError> { pub async fn get_bridge_status() -> Result<BridgeStatus, McError> {
// Bridge IO is blocking; run off the async executor // Bridge IO is blocking; run off the async executor
tokio::task::spawn_blocking(|| get_bridge_status_inner(None)) tokio::task::spawn_blocking(|| get_bridge_status_inner(None))
@@ -140,8 +140,8 @@ fn get_bridge_status_inner(
} }
/// Trigger an incremental sync (process since_last_check events). /// Trigger an incremental sync (process since_last_check events).
#[tauri_specta::command] #[tauri::command]
#[specta(crate = "specta")] #[specta::specta]
pub async fn sync_now() -> Result<SyncResult, McError> { pub async fn sync_now() -> Result<SyncResult, McError> {
tokio::task::spawn_blocking(|| sync_now_inner(None)) tokio::task::spawn_blocking(|| sync_now_inner(None))
.await .await
@@ -165,8 +165,8 @@ fn sync_now_inner(
} }
/// Trigger a full reconciliation pass. /// Trigger a full reconciliation pass.
#[tauri_specta::command] #[tauri::command]
#[specta(crate = "specta")] #[specta::specta]
pub async fn reconcile() -> Result<SyncResult, McError> { pub async fn reconcile() -> Result<SyncResult, McError> {
tokio::task::spawn_blocking(|| reconcile_inner(None)) tokio::task::spawn_blocking(|| reconcile_inner(None))
.await .await
@@ -198,8 +198,8 @@ pub struct CaptureResult {
} }
/// Quick-capture a thought as a new bead. /// Quick-capture a thought as a new bead.
#[tauri_specta::command] #[tauri::command]
#[specta(crate = "specta")] #[specta::specta]
pub async fn quick_capture(title: String) -> Result<CaptureResult, McError> { pub async fn quick_capture(title: String) -> Result<CaptureResult, McError> {
tokio::task::spawn_blocking(move || quick_capture_inner(&RealBeadsCli, &title)) tokio::task::spawn_blocking(move || quick_capture_inner(&RealBeadsCli, &title))
.await .await
@@ -216,8 +216,8 @@ fn quick_capture_inner(cli: &dyn BeadsCli, title: &str) -> Result<CaptureResult,
/// Read persisted frontend state from ~/.local/share/mc/state.json. /// Read persisted frontend state from ~/.local/share/mc/state.json.
/// ///
/// Returns null if no state exists (first run). /// Returns null if no state exists (first run).
#[tauri_specta::command] #[tauri::command]
#[specta(crate = "specta")] #[specta::specta]
pub async fn read_state() -> Result<Option<FrontendState>, McError> { pub async fn read_state() -> Result<Option<FrontendState>, McError> {
tokio::task::spawn_blocking(read_frontend_state) tokio::task::spawn_blocking(read_frontend_state)
.await .await
@@ -228,8 +228,8 @@ pub async fn read_state() -> Result<Option<FrontendState>, McError> {
/// Write frontend state to ~/.local/share/mc/state.json. /// Write frontend state to ~/.local/share/mc/state.json.
/// ///
/// Uses atomic rename pattern to prevent corruption. /// Uses atomic rename pattern to prevent corruption.
#[tauri_specta::command] #[tauri::command]
#[specta(crate = "specta")] #[specta::specta]
pub async fn write_state(state: FrontendState) -> Result<(), McError> { pub async fn write_state(state: FrontendState) -> Result<(), McError> {
tokio::task::spawn_blocking(move || write_frontend_state(&state)) tokio::task::spawn_blocking(move || write_frontend_state(&state))
.await .await
@@ -238,8 +238,8 @@ pub async fn write_state(state: FrontendState) -> Result<(), McError> {
} }
/// Clear persisted frontend state. /// Clear persisted frontend state.
#[tauri_specta::command] #[tauri::command]
#[specta(crate = "specta")] #[specta::specta]
pub async fn clear_state() -> Result<(), McError> { pub async fn clear_state() -> Result<(), McError> {
tokio::task::spawn_blocking(clear_frontend_state) tokio::task::spawn_blocking(clear_frontend_state)
.await .await

View File

@@ -219,29 +219,18 @@ impl<L: LoreCli, B: BeadsCli> Bridge<L, B> {
/// Clean up orphaned .tmp files from interrupted atomic writes. /// Clean up orphaned .tmp files from interrupted atomic writes.
/// ///
/// Called on startup to remove any .json.tmp files left behind from /// Called on startup to remove any bridge-owned .tmp files left behind from
/// crashes during save_map(). Returns the number of files cleaned up. /// crashes during save_map(). Returns the number of files cleaned up.
pub fn cleanup_tmp_files(&self) -> Result<usize, BridgeError> { pub fn cleanup_tmp_files(&self) -> Result<usize, BridgeError> {
if !self.data_dir.exists() { let tmp_path = self.map_path().with_extension("json.tmp");
return Ok(0);
if tmp_path.exists() {
tracing::info!("Cleaning up orphaned tmp file: {:?}", tmp_path);
fs::remove_file(&tmp_path)?;
return Ok(1);
} }
let mut cleaned = 0; Ok(0)
for entry in fs::read_dir(&self.data_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().is_some_and(|e| e == "tmp") {
tracing::info!("Cleaning up orphaned tmp file: {:?}", path);
fs::remove_file(&path)?;
cleaned += 1;
}
}
if cleaned > 0 {
tracing::info!("Cleaned up {} orphaned tmp file(s)", cleaned);
}
Ok(cleaned)
} }
/// Save the mapping file atomically (write to .tmp, then rename) /// Save the mapping file atomically (write to .tmp, then rename)
@@ -1357,6 +1346,23 @@ mod tests {
assert!(json_file.exists()); assert!(json_file.exists());
} }
#[test]
fn test_cleanup_tmp_files_ignores_other_modules_tmp_files() {
let dir = TempDir::new().unwrap();
let bridge = test_bridge(MockLoreCli::new(), MockBeadsCli::new(), &dir);
// Create tmp files belonging to other modules (should not be removed)
let state_tmp = dir.path().join("state.json.tmp");
std::fs::write(&state_tmp, "state data").unwrap();
let other_tmp = dir.path().join("other.tmp");
std::fs::write(&other_tmp, "other data").unwrap();
let cleaned = bridge.cleanup_tmp_files().unwrap();
assert_eq!(cleaned, 0);
assert!(state_tmp.exists(), "state.json.tmp should not be deleted");
assert!(other_tmp.exists(), "other.tmp should not be deleted");
}
#[test] #[test]
fn test_cleanup_tmp_files_handles_missing_dir() { fn test_cleanup_tmp_files_handles_missing_dir() {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();

View File

@@ -93,7 +93,25 @@ pub fn write_frontend_state(state: &FrontendState) -> io::Result<()> {
let content = serde_json::to_string_pretty(state) let content = serde_json::to_string_pretty(state)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
fs::write(&tmp_path, &content)?; // Use explicit 0600 permissions on Unix -- state may contain user session data
#[cfg(unix)]
{
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let mut file = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(&tmp_path)?;
file.write_all(content.as_bytes())?;
file.sync_all()?;
}
#[cfg(not(unix))]
{
fs::write(&tmp_path, &content)?;
}
fs::rename(&tmp_path, &path)?; fs::rename(&tmp_path, &path)?;
Ok(()) Ok(())

View File

@@ -25,10 +25,16 @@ use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
fn toggle_window_visibility(app: &tauri::AppHandle) { fn toggle_window_visibility(app: &tauri::AppHandle) {
if let Some(window) = app.get_webview_window("main") { if let Some(window) = app.get_webview_window("main") {
if window.is_visible().unwrap_or(false) && window.is_focused().unwrap_or(false) { if window.is_visible().unwrap_or(false) && window.is_focused().unwrap_or(false) {
let _ = window.hide(); if let Err(e) = window.hide() {
tracing::warn!("Failed to hide window: {}", e);
}
} else { } else {
let _ = window.show(); if let Err(e) = window.show() {
let _ = window.set_focus(); tracing::warn!("Failed to show window: {}", e);
}
if let Err(e) = window.set_focus() {
tracing::warn!("Failed to focus window: {}", e);
}
} }
} }
} }
@@ -136,13 +142,21 @@ pub fn run() {
if shortcut == &capture { if shortcut == &capture {
// Show window and signal the frontend to open capture overlay // Show window and signal the frontend to open capture overlay
if let Some(window) = app.get_webview_window("main") { if let Some(window) = app.get_webview_window("main") {
let _ = window.show(); if let Err(e) = window.show() {
let _ = window.set_focus(); tracing::warn!("Failed to show window for capture: {}", e);
}
if let Err(e) = window.set_focus() {
tracing::warn!("Failed to focus window for capture: {}", e);
}
}
if let Err(e) = app.emit("global-shortcut-triggered", "quick-capture") {
tracing::error!("Failed to emit quick-capture event: {}", e);
} }
let _ = app.emit("global-shortcut-triggered", "quick-capture");
} else { } else {
toggle_window_visibility(app); toggle_window_visibility(app);
let _ = app.emit("global-shortcut-triggered", "toggle-window"); if let Err(e) = app.emit("global-shortcut-triggered", "toggle-window") {
tracing::error!("Failed to emit toggle-window event: {}", e);
}
} }
} }
}) })

View File

@@ -35,8 +35,16 @@ pub fn start_lore_watcher(app: AppHandle) -> Option<RecommendedWatcher> {
// Create the watcher with a debounce of 1 second // Create the watcher with a debounce of 1 second
let mut watcher = RecommendedWatcher::new( let mut watcher = RecommendedWatcher::new(
move |res: Result<Event, notify::Error>| { move |res: Result<Event, notify::Error>| {
if let Ok(event) = res { match res {
let _ = tx.send(event); Ok(event) => {
if tx.send(event).is_err() {
// Receiver dropped -- watcher thread has exited
tracing::debug!("Watcher event channel closed, receiver dropped");
}
}
Err(e) => {
tracing::warn!("File watcher error: {}", e);
}
} }
}, },
Config::default().with_poll_interval(Duration::from_secs(2)), Config::default().with_poll_interval(Duration::from_secs(2)),
@@ -62,7 +70,9 @@ pub fn start_lore_watcher(app: AppHandle) -> Option<RecommendedWatcher> {
let affects_db = event.paths.iter().any(|p| p.ends_with("lore.db")); let affects_db = event.paths.iter().any(|p| p.ends_with("lore.db"));
if affects_db { if affects_db {
tracing::debug!("lore.db changed, emitting refresh event"); tracing::debug!("lore.db changed, emitting refresh event");
let _ = app.emit("lore-data-changed", ()); if let Err(e) = app.emit("lore-data-changed", ()) {
tracing::warn!("Failed to emit lore-data-changed event: {}", e);
}
} }
} }
} }

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>
);
}

View File

@@ -0,0 +1,158 @@
/**
* Tests for ReasonPrompt component.
*
* TDD: These tests define the expected behavior before implementation.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ReasonPrompt } from "@/components/ReasonPrompt";
describe("ReasonPrompt", () => {
const defaultProps = {
action: "set_focus",
itemTitle: "Review MR !847",
onSubmit: vi.fn(),
onCancel: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
});
it("renders with action context", () => {
render(<ReasonPrompt {...defaultProps} />);
expect(
screen.getByText(/Setting focus to.*Review MR !847/i)
).toBeInTheDocument();
});
it("captures text input", async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<ReasonPrompt {...defaultProps} onSubmit={onSubmit} />);
await user.type(
screen.getByRole("textbox"),
"Sarah pinged me, she is blocked"
);
await user.click(screen.getByRole("button", { name: /confirm/i }));
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
reason: "Sarah pinged me, she is blocked",
})
);
});
it("allows selecting quick tags", async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<ReasonPrompt {...defaultProps} onSubmit={onSubmit} />);
await user.click(screen.getByRole("button", { name: /blocking/i }));
await user.click(screen.getByRole("button", { name: /urgent/i }));
await user.click(screen.getByRole("button", { name: /confirm/i }));
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
tags: expect.arrayContaining(["blocking", "urgent"]),
})
);
});
it("toggles tag off when clicked again", async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<ReasonPrompt {...defaultProps} onSubmit={onSubmit} />);
const blockingTag = screen.getByRole("button", { name: /blocking/i });
// Select then deselect
await user.click(blockingTag);
await user.click(blockingTag);
await user.click(screen.getByRole("button", { name: /confirm/i }));
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
tags: [],
})
);
});
it("allows skipping reason", async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<ReasonPrompt {...defaultProps} onSubmit={onSubmit} />);
await user.click(screen.getByRole("button", { name: /skip reason/i }));
expect(onSubmit).toHaveBeenCalledWith({
reason: null,
tags: [],
});
});
it("trims whitespace from reason", async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<ReasonPrompt {...defaultProps} onSubmit={onSubmit} />);
await user.type(screen.getByRole("textbox"), " spaced reason ");
await user.click(screen.getByRole("button", { name: /confirm/i }));
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
reason: "spaced reason",
})
);
});
it("treats empty reason as null", async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<ReasonPrompt {...defaultProps} onSubmit={onSubmit} />);
// Just click confirm without typing
await user.click(screen.getByRole("button", { name: /confirm/i }));
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
reason: null,
})
);
});
it("cancels on Escape key", async () => {
const user = userEvent.setup();
const onCancel = vi.fn();
render(<ReasonPrompt {...defaultProps} onCancel={onCancel} />);
await user.keyboard("{Escape}");
expect(onCancel).toHaveBeenCalled();
});
it("displays all quick tag options", () => {
render(<ReasonPrompt {...defaultProps} />);
expect(screen.getByRole("button", { name: /blocking/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /urgent/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /context switch/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /energy/i })).toBeInTheDocument();
});
it("shows different titles for different actions", () => {
const { rerender } = render(
<ReasonPrompt {...defaultProps} action="defer" itemTitle="Issue #42" />
);
expect(screen.getByText(/Deferring.*Issue #42/i)).toBeInTheDocument();
rerender(
<ReasonPrompt {...defaultProps} action="skip" itemTitle="MR !100" />
);
expect(screen.getByText(/Skipping.*MR !100/i)).toBeInTheDocument();
});
});