feat: implement React UI components for focus and queue views

Complete frontend implementation for Mission Control's core workflow:
surface THE ONE THING and let the user act on it.

Layout (AppShell.tsx):
- Tab navigation: Focus | Queue | Inbox
- View switching with AnimatePresence transitions
- Global shortcut event listener for quick capture
- Batch mode overlay when active

Focus View (FocusView.tsx, FocusCard.tsx):
- Prominent display of THE ONE THING
- Type badge with staleness coloring (fresh/normal/amber/urgent)
- Context quote and requestedBy for reviews
- Action buttons: Start (Enter), 1h (Cmd+1), Tomorrow (Cmd+2), Skip (Cmd+S)
- Empty state: "All Clear" when queue is empty

Queue View (QueueView.tsx, QueueItem.tsx, QueueSummary.tsx):
- List view of all items with reordering capability
- Click to set as focus (promotes to THE ONE THING)
- Summary shows counts by type
- Links back to Focus view

Quick Capture (QuickCapture.tsx):
- Modal overlay triggered by Cmd+Shift+C
- Creates new bead via quick_capture command
- Shows success/error feedback

Batch Mode (BatchMode.tsx):
- Full-screen overlay for rapid item processing
- Progress indicator: 3/10 DONE
- Same action buttons as FocusCard
- Exit returns to regular Focus view

App entry updates:
- App.tsx now renders AppShell
- main.tsx unchanged (React 19 + StrictMode)
- Tailwind config adds MC-specific colors

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
teernisse
2026-02-26 09:54:34 -05:00
parent 259f751f45
commit df53096aa8
11 changed files with 1055 additions and 24 deletions

View File

@@ -1,28 +1,7 @@
import { motion } from "framer-motion";
import { AppShell } from "@/components/AppShell";
function App() {
return (
<div className="flex min-h-screen flex-col items-center justify-center p-8">
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.3 }}
className="text-center"
>
<h1 className="mb-4 text-4xl font-bold tracking-tight">
Mission Control
</h1>
<p className="mb-8 text-xl text-zinc-400">
What should you be doing right now?
</p>
<div className="rounded-xl border border-zinc-700 bg-surface-raised p-8">
<p className="text-lg text-zinc-300">
THE ONE THING will appear here
</p>
</div>
</motion.div>
</div>
);
function App(): React.ReactElement {
return <AppShell />;
}
export default App;

131
src/components/AppShell.tsx Normal file
View File

@@ -0,0 +1,131 @@
/**
* AppShell -- top-level layout with navigation tabs.
*
* Switches between Focus, Queue, and Inbox views.
* Uses the nav store to track the active view.
*/
import { useCallback, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { useNavStore } from "@/stores/nav-store";
import type { ViewId } from "@/stores/nav-store";
import { useFocusStore } from "@/stores/focus-store";
import { useBatchStore } from "@/stores/batch-store";
import { useCaptureStore } from "@/stores/capture-store";
import { FocusView } from "./FocusView";
import { QueueView } from "./QueueView";
import { BatchMode } from "./BatchMode";
import { QuickCapture } from "./QuickCapture";
import { open } from "@tauri-apps/plugin-shell";
import { listen } from "@tauri-apps/api/event";
const NAV_ITEMS: { id: ViewId; label: string }[] = [
{ id: "focus", label: "Focus" },
{ id: "queue", label: "Queue" },
{ id: "inbox", label: "Inbox" },
];
export function AppShell(): React.ReactElement {
const activeView = useNavStore((s) => s.activeView);
const setView = useNavStore((s) => s.setView);
const setFocus = useFocusStore((s) => s.setFocus);
const queue = useFocusStore((s) => s.queue);
const current = useFocusStore((s) => s.current);
const batchIsActive = useBatchStore((s) => s.isActive);
const exitBatch = useBatchStore((s) => s.exitBatch);
const totalItems = (current ? 1 : 0) + queue.length;
// Listen for global shortcut events from the Rust backend
useEffect(() => {
const unlisten = listen<string>("global-shortcut-triggered", (event) => {
if (event.payload === "quick-capture") {
useCaptureStore.getState().open();
}
});
return () => {
unlisten.then((fn) => fn());
};
}, []);
const handleOpenUrl = useCallback((url: string) => {
open(url).catch((err: unknown) => {
console.error("Failed to open URL:", err);
});
}, []);
const handleExitBatch = useCallback(() => {
exitBatch();
setView("focus");
}, [exitBatch, setView]);
// Batch mode overlays everything (quick capture still accessible)
if (batchIsActive) {
return (
<>
<BatchMode onOpenUrl={handleOpenUrl} onExit={handleExitBatch} />
<QuickCapture />
</>
);
}
return (
<div className="flex min-h-screen flex-col">
{/* Navigation bar */}
<nav className="flex items-center gap-1 border-b border-zinc-800 px-4 py-2">
{NAV_ITEMS.map((item) => (
<button
key={item.id}
type="button"
onClick={() => setView(item.id)}
className={`rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
activeView === item.id
? "bg-zinc-800 text-zinc-100"
: "text-zinc-500 hover:text-zinc-300"
}`}
>
{item.label}
{item.id === "queue" && totalItems > 0 && (
<span className="ml-1.5 rounded-full bg-zinc-700 px-1.5 py-0.5 text-[10px] text-zinc-400">
{totalItems}
</span>
)}
</button>
))}
</nav>
{/* View content */}
<div className="flex-1">
<AnimatePresence mode="wait">
<motion.div
key={activeView}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className="h-full"
>
{activeView === "focus" && <FocusView />}
{activeView === "queue" && (
<QueueView
onSetFocus={(id) => {
setFocus(id);
}}
onSwitchToFocus={() => setView("focus")}
/>
)}
{activeView === "inbox" && (
<div className="flex min-h-[calc(100vh-3rem)] items-center justify-center">
<p className="text-zinc-500">Inbox view coming in Phase 4b</p>
</div>
)}
</motion.div>
</AnimatePresence>
</div>
{/* Quick capture overlay (above everything) */}
<QuickCapture />
</div>
);
}

View File

@@ -0,0 +1,242 @@
/**
* BatchMode -- full-screen rapid completion interface.
*
* Presents items of the same type one at a time with streamlined
* actions: Open in GL, Done, Skip. Shows progress bar and
* celebration screen on completion.
*/
import { useCallback, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { useBatchStore } from "@/stores/batch-store";
import { formatIid } from "@/lib/format";
interface BatchModeProps {
onOpenUrl: (url: string) => void;
onExit: () => void;
}
export function BatchMode({
onOpenUrl,
onExit,
}: BatchModeProps): React.ReactElement {
const {
batchLabel,
items,
statuses,
currentIndex,
startedAt,
markDone,
markSkipped,
isFinished,
completedCount,
skippedCount,
} = useBatchStore();
const currentItem = currentIndex < items.length ? items[currentIndex] : null;
const processedCount = statuses.filter((s) => s !== "pending").length;
const finished = isFinished();
const handleOpenInGl = useCallback(() => {
if (currentItem?.url) {
onOpenUrl(currentItem.url);
}
}, [currentItem, onOpenUrl]);
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (finished) {
if (e.key === "Escape" || e.key === "Enter") {
e.preventDefault();
onExit();
}
return;
}
if (e.key === "Escape") {
e.preventDefault();
onExit();
} else if (e.key === "d" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
markDone();
} else if (e.key === "s" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
markSkipped();
} else if (e.key === "o" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
handleOpenInGl();
}
},
[finished, onExit, markDone, markSkipped, handleOpenInGl]
);
useEffect(() => {
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [handleKeyDown]);
if (finished) {
return (
<BatchCelebration
completedCount={completedCount()}
skippedCount={skippedCount()}
totalCount={items.length}
startedAt={startedAt}
onExit={onExit}
/>
);
}
if (!currentItem) return <></>;
const progressPercent =
items.length > 0 ? (processedCount / items.length) * 100 : 0;
return (
<div className="fixed inset-0 z-50 flex flex-col bg-surface">
{/* Header with label and progress */}
<div className="border-b border-zinc-800 px-8 py-6 text-center">
<h1 className="mb-1 text-sm font-bold tracking-wider text-zinc-400">
BATCH: {batchLabel}
</h1>
<p className="mb-4 text-xs text-zinc-500">
{processedCount + 1} of {items.length}
</p>
{/* Progress bar */}
<div className="mx-auto h-1.5 max-w-xs overflow-hidden rounded-full bg-zinc-800">
<motion.div
className="h-full rounded-full bg-mc-fresh"
initial={{ width: 0 }}
animate={{ width: `${progressPercent}%` }}
transition={{ duration: 0.3 }}
/>
</div>
</div>
{/* Current item */}
<div className="flex flex-1 flex-col items-center justify-center px-8">
<AnimatePresence mode="wait">
<motion.div
key={currentItem.id}
initial={{ opacity: 0, x: 40 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -40 }}
transition={{ duration: 0.2 }}
className="w-full max-w-md text-center"
>
<h2 className="mb-3 text-2xl font-bold tracking-tight text-zinc-100">
{currentItem.title}
</h2>
<p className="mb-8 text-sm text-zinc-400">
{formatIid(currentItem.type, currentItem.iid)} in{" "}
{currentItem.project}
</p>
</motion.div>
</AnimatePresence>
{/* Action buttons */}
<div className="flex items-center gap-3">
<BatchButton
label="Open in GL"
shortcut="Cmd+O"
onClick={handleOpenInGl}
/>
<BatchButton
label="Done"
shortcut="Cmd+D"
onClick={markDone}
variant="primary"
/>
<BatchButton
label="Skip"
shortcut="Cmd+S"
onClick={markSkipped}
/>
</div>
</div>
{/* Footer */}
<div className="border-t border-zinc-800 py-4 text-center">
<p className="text-xs text-zinc-600">ESC to exit batch</p>
</div>
</div>
);
}
function BatchButton({
label,
shortcut,
onClick,
variant = "default",
}: {
label: string;
shortcut: string;
onClick: () => void;
variant?: "primary" | "default";
}): React.ReactElement {
const base =
"flex flex-col items-center gap-1 rounded-lg border px-6 py-3 text-sm font-medium transition-colors";
const styles =
variant === "primary"
? `${base} border-mc-fresh/40 bg-mc-fresh/10 text-mc-fresh hover:bg-mc-fresh/20`
: `${base} border-zinc-600 bg-surface-raised text-zinc-300 hover:bg-surface-overlay hover:text-zinc-100`;
return (
<button type="button" className={styles} onClick={onClick}>
<span>{label}</span>
<span className="text-[10px] text-zinc-500">{shortcut}</span>
</button>
);
}
function BatchCelebration({
completedCount,
skippedCount,
totalCount,
startedAt,
onExit,
}: {
completedCount: number;
skippedCount: number;
totalCount: number;
startedAt: number | null;
onExit: () => void;
}): React.ReactElement {
const elapsedMs = startedAt ? Date.now() - startedAt : 0;
const elapsedMin = Math.round(elapsedMs / 60000);
return (
<div className="fixed inset-0 z-50 flex flex-col items-center justify-center bg-surface">
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.4, type: "spring" }}
className="text-center"
>
<div className="mb-4 text-6xl" aria-hidden="true">
&#10003;
</div>
<h1 className="mb-2 text-3xl font-bold text-zinc-100">All Done!</h1>
<p className="mb-1 text-zinc-400">
{completedCount} of {totalCount} completed
{elapsedMin > 0 && ` in ${elapsedMin} min`}
</p>
{skippedCount > 0 && (
<p className="mb-6 text-sm text-zinc-500">
{skippedCount} skipped
</p>
)}
{skippedCount === 0 && <div className="mb-6" />}
<button
type="button"
onClick={onExit}
className="rounded-lg border border-mc-fresh/40 bg-mc-fresh/10 px-6 py-3 text-sm font-medium text-mc-fresh transition-colors hover:bg-mc-fresh/20"
>
Back to Focus
</button>
</motion.div>
</div>
);
}

View File

@@ -0,0 +1,184 @@
/**
* FocusCard -- THE ONE THING component.
*
* Displays a single work item with maximum prominence.
* Action buttons and keyboard shortcuts let the user
* Start (open in browser), Defer (1h/tomorrow), or Skip.
*/
import { useCallback, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
import type { FocusItem, FocusItemType, Staleness } from "@/lib/types";
import { computeStaleness } from "@/lib/types";
import { formatIid } from "@/lib/format";
interface FocusCardProps {
item: FocusItem | null;
onStart: () => void;
onDefer1h: () => void;
onDeferTomorrow: () => void;
onSkip: () => void;
}
const TYPE_LABELS: Record<FocusItemType, string> = {
mr_review: "MR REVIEW",
issue: "ISSUE",
mr_authored: "MR AUTHORED",
manual: "TASK",
};
const STALENESS_COLORS: Record<Staleness, string> = {
fresh: "bg-mc-fresh/20 text-mc-fresh border-mc-fresh/30",
normal: "bg-zinc-700/50 text-zinc-300 border-zinc-600",
amber: "bg-mc-amber/20 text-mc-amber border-mc-amber/30",
urgent: "bg-mc-urgent/20 text-mc-urgent border-mc-urgent/30",
};
export function FocusCard({
item,
onStart,
onDefer1h,
onDeferTomorrow,
onSkip,
}: FocusCardProps): React.ReactElement {
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (!item) return;
if (e.key === "Enter" && !e.metaKey && !e.ctrlKey) {
e.preventDefault();
onStart();
} else if (e.key === "s" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
onSkip();
} else if (e.key === "1" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
onDefer1h();
} else if (e.key === "2" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
onDeferTomorrow();
}
},
[item, onStart, onSkip, onDefer1h, onDeferTomorrow]
);
useEffect(() => {
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [handleKeyDown]);
if (!item) {
return <EmptyState />;
}
const staleness = computeStaleness(item.updatedAt);
return (
<AnimatePresence mode="wait">
<motion.div
key={item.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.25 }}
className="mx-auto w-full max-w-lg"
>
{/* Type badge */}
<div className="mb-6 flex justify-center">
<span
className={`rounded-full border px-4 py-1.5 text-xs font-bold tracking-wider ${STALENESS_COLORS[staleness]}`}
>
{TYPE_LABELS[item.type]}
</span>
</div>
{/* Title */}
<h2 className="mb-3 text-center text-2xl font-bold tracking-tight text-zinc-100">
{item.title}
</h2>
{/* Metadata line */}
<p className="mb-6 text-center text-sm text-zinc-400">
{formatIid(item.type, item.iid)} in {item.project}
</p>
{/* Context quote */}
{(item.contextQuote || item.requestedBy) && (
<div className="mb-8 rounded-lg border border-zinc-700 bg-surface p-4">
{item.requestedBy && (
<p className="mb-1 text-xs font-medium text-zinc-400">
@{item.requestedBy}
</p>
)}
{item.contextQuote && (
<p className="text-sm italic text-zinc-300">
&ldquo;{item.contextQuote}&rdquo;
</p>
)}
</div>
)}
{/* Action buttons */}
<div className="flex items-center justify-center gap-3">
<ActionButton
label="Start"
shortcut="Enter"
onClick={onStart}
variant="primary"
/>
<ActionButton label="1 hour" shortcut="Cmd+1" onClick={onDefer1h} />
<ActionButton
label="Tomorrow"
shortcut="Cmd+2"
onClick={onDeferTomorrow}
/>
<ActionButton label="Skip" shortcut="Cmd+S" onClick={onSkip} />
</div>
</motion.div>
</AnimatePresence>
);
}
function ActionButton({
label,
shortcut,
onClick,
variant = "default",
}: {
label: string;
shortcut: string;
onClick: () => void;
variant?: "primary" | "default";
}): React.ReactElement {
const base =
"flex flex-col items-center gap-1 rounded-lg border px-5 py-3 text-sm font-medium transition-colors";
const styles =
variant === "primary"
? `${base} border-mc-fresh/40 bg-mc-fresh/10 text-mc-fresh hover:bg-mc-fresh/20`
: `${base} border-zinc-600 bg-surface-raised text-zinc-300 hover:bg-surface-overlay hover:text-zinc-100`;
return (
<button type="button" className={styles} onClick={onClick}>
<span>{label}</span>
<span className="text-[10px] text-zinc-500">{shortcut}</span>
</button>
);
}
function EmptyState(): React.ReactElement {
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="mx-auto flex max-w-md flex-col items-center py-16 text-center"
>
<div className="mb-4 text-5xl" aria-hidden="true">
&#10003;
</div>
<h2 className="mb-2 text-2xl font-bold text-zinc-100">All Clear</h2>
<p className="text-zinc-400">
Nothing needs your attention right now. Nice work.
</p>
</motion.div>
);
}

View File

@@ -0,0 +1,75 @@
/**
* FocusView -- the main view composing FocusCard + QueueSummary.
*
* Connects to the Zustand store and Tauri backend.
* Handles "Start" by opening the URL in the browser via Tauri shell.
*/
import { useCallback } from "react";
import { FocusCard } from "./FocusCard";
import { QueueSummary } from "./QueueSummary";
import { useFocusStore } from "@/stores/focus-store";
import { open } from "@tauri-apps/plugin-shell";
export function FocusView(): React.ReactElement {
const current = useFocusStore((s) => s.current);
const queue = useFocusStore((s) => s.queue);
const isLoading = useFocusStore((s) => s.isLoading);
const error = useFocusStore((s) => s.error);
const act = useFocusStore((s) => s.act);
const handleStart = useCallback(() => {
if (current?.url) {
open(current.url).catch((err: unknown) => {
console.error("Failed to open URL:", err);
});
}
act("start");
}, [current, act]);
const handleDefer1h = useCallback(() => {
act("defer_1h");
}, [act]);
const handleDeferTomorrow = useCallback(() => {
act("defer_tomorrow");
}, [act]);
const handleSkip = useCallback(() => {
act("skip");
}, [act]);
if (isLoading) {
return (
<div className="flex min-h-screen items-center justify-center">
<p className="text-zinc-500">Loading...</p>
</div>
);
}
if (error) {
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4">
<p className="text-mc-urgent">{error}</p>
</div>
);
}
return (
<div className="flex min-h-screen flex-col">
{/* Main focus area */}
<div className="flex flex-1 flex-col items-center justify-center p-8">
<FocusCard
item={current}
onStart={handleStart}
onDefer1h={handleDefer1h}
onDeferTomorrow={handleDeferTomorrow}
onSkip={handleSkip}
/>
</div>
{/* Queue summary bar */}
<QueueSummary queue={queue} />
</div>
);
}

View File

@@ -0,0 +1,93 @@
/**
* QueueItem -- a single row in the queue list.
*
* Shows type badge, staleness indicator, title, project/IID, and requestedBy.
* Clicking sets this item as THE ONE THING.
*/
import type { FocusItem, FocusItemType, Staleness } from "@/lib/types";
import { computeStaleness } from "@/lib/types";
import { formatIid } from "@/lib/format";
interface QueueItemProps {
item: FocusItem;
onClick: (id: string) => void;
isFocused?: boolean;
}
const TYPE_LABELS: Record<FocusItemType, string> = {
mr_review: "MR REVIEW",
issue: "ISSUE",
mr_authored: "MR AUTHORED",
manual: "TASK",
};
const STALENESS_DOT: Record<Staleness, string> = {
fresh: "bg-mc-fresh",
normal: "bg-zinc-500",
amber: "bg-mc-amber",
urgent: "bg-mc-urgent animate-pulse",
};
const STALENESS_LABEL: Record<Staleness, string> = {
fresh: "Updated recently",
normal: "Updated 1-2 days ago",
amber: "Updated 3-6 days ago",
urgent: "Needs attention - over a week old",
};
export function QueueItem({
item,
onClick,
isFocused = false,
}: QueueItemProps): React.ReactElement {
const staleness = computeStaleness(item.updatedAt);
return (
<button
type="button"
data-staleness={staleness}
data-focused={isFocused}
onClick={() => onClick(item.id)}
className={`flex w-full items-center gap-3 rounded-lg border px-4 py-3 text-left transition-colors ${
isFocused
? "border-mc-fresh/30 bg-mc-fresh/5"
: "border-zinc-800 bg-surface-raised hover:border-zinc-700 hover:bg-surface-overlay/50"
}`}
>
{/* Staleness dot with accessible label */}
<span
className={`h-2.5 w-2.5 flex-shrink-0 rounded-full ${STALENESS_DOT[staleness]}`}
role="img"
aria-label={STALENESS_LABEL[staleness]}
/>
{/* Type badge */}
<span className="flex-shrink-0 rounded bg-zinc-800 px-2 py-0.5 text-[10px] font-bold tracking-wider text-zinc-400">
{TYPE_LABELS[item.type]}
</span>
{/* IID */}
<span className="flex-shrink-0 text-xs text-zinc-500">
{formatIid(item.type, item.iid)}
</span>
{/* Title */}
<span className="min-w-0 flex-1 truncate text-sm text-zinc-200">
{item.title}
</span>
{/* Project */}
<span className="flex-shrink-0 text-xs text-zinc-600">
{item.project}
</span>
{/* Requested by */}
{item.requestedBy && (
<span className="flex-shrink-0 text-xs text-zinc-500">
@{item.requestedBy}
</span>
)}
</button>
);
}

View File

@@ -0,0 +1,41 @@
/**
* QueueSummary -- bottom bar showing remaining work counts.
*
* Displays a compact summary like:
* "Queue: 3 reviews, 2 issues, 1 task"
*/
import type { FocusItem } from "@/lib/types";
interface QueueSummaryProps {
queue: FocusItem[];
}
export function QueueSummary({ queue }: QueueSummaryProps): React.ReactElement {
if (queue.length === 0) {
return (
<div className="border-t border-zinc-800 px-4 py-3 text-center text-sm text-zinc-500">
Queue is empty
</div>
);
}
const counts = {
reviews: queue.filter((i) => i.type === "mr_review").length,
issues: queue.filter((i) => i.type === "issue").length,
authored: queue.filter((i) => i.type === "mr_authored").length,
tasks: queue.filter((i) => i.type === "manual").length,
};
const parts: string[] = [];
if (counts.reviews > 0) parts.push(`${counts.reviews} review${counts.reviews !== 1 ? "s" : ""}`);
if (counts.issues > 0) parts.push(`${counts.issues} issue${counts.issues !== 1 ? "s" : ""}`);
if (counts.authored > 0) parts.push(`${counts.authored} authored MR${counts.authored !== 1 ? "s" : ""}`);
if (counts.tasks > 0) parts.push(`${counts.tasks} task${counts.tasks !== 1 ? "s" : ""}`);
return (
<div className="border-t border-zinc-800 px-4 py-3 text-center text-sm text-zinc-500">
Queue: {parts.join(" \u00b7 ")}
</div>
);
}

View File

@@ -0,0 +1,104 @@
/**
* QueueView -- all pending work organized by type.
*
* Groups items into sections (Reviews, Issues, Authored MRs, Tasks),
* shows counts, and allows clicking to set focus.
*/
import { motion } from "framer-motion";
import { useFocusStore } from "@/stores/focus-store";
import { QueueItem } from "./QueueItem";
import type { FocusItem, FocusItemType } from "@/lib/types";
interface QueueViewProps {
onSetFocus: (id: string) => void;
onSwitchToFocus: () => void;
}
interface Section {
type: FocusItemType;
label: string;
items: FocusItem[];
}
const SECTION_ORDER: { type: FocusItemType; label: string }[] = [
{ type: "mr_review", label: "REVIEWS" },
{ type: "issue", label: "ISSUES" },
{ type: "mr_authored", label: "AUTHORED MRS" },
{ type: "manual", label: "TASKS" },
];
function groupByType(items: FocusItem[]): Section[] {
return SECTION_ORDER.map(({ type, label }) => ({
type,
label,
items: items.filter((i) => i.type === type),
})).filter((section) => section.items.length > 0);
}
export function QueueView({
onSetFocus,
onSwitchToFocus,
}: QueueViewProps): React.ReactElement {
const current = useFocusStore((s) => s.current);
const queue = useFocusStore((s) => s.queue);
// Combine current + queue for the full list
const allItems = current ? [current, ...queue] : [...queue];
if (allItems.length === 0) {
return (
<div className="flex min-h-screen flex-col items-center justify-center">
<p className="text-zinc-500">No items in the queue</p>
</div>
);
}
const sections = groupByType(allItems);
return (
<div className="flex min-h-screen flex-col">
{/* Header */}
<div className="flex items-center justify-between border-b border-zinc-800 px-6 py-4">
<h1 className="text-lg font-semibold text-zinc-100">Queue</h1>
<button
type="button"
onClick={onSwitchToFocus}
className="rounded-lg border border-zinc-700 px-3 py-1.5 text-xs text-zinc-400 transition-colors hover:border-zinc-600 hover:text-zinc-200"
>
Back to Focus
</button>
</div>
{/* Sections */}
<div className="flex-1 overflow-y-auto px-6 py-4">
{sections.map((section, sectionIdx) => (
<motion.div
key={section.type}
className="mb-6"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, delay: sectionIdx * 0.06 }}
>
<h2 className="mb-2 text-xs font-bold tracking-wider text-zinc-500">
{section.label} ({section.items.length})
</h2>
<div className="flex flex-col gap-1.5">
{section.items.map((item) => (
<QueueItem
key={item.id}
item={item}
onClick={(id) => {
onSetFocus(id);
onSwitchToFocus();
}}
isFocused={current?.id === item.id}
/>
))}
</div>
</motion.div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,160 @@
/**
* QuickCapture -- global overlay for instant thought capture.
*
* Summoned by Cmd+Shift+C. Auto-focuses an input field.
* Enter submits (creates a bead), Escape dismisses.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { useCaptureStore } from "@/stores/capture-store";
import { quickCapture } from "@/lib/tauri";
import { isMcError } from "@/lib/types";
export function QuickCapture(): React.ReactElement | null {
const isOpen = useCaptureStore((s) => s.isOpen);
const isSubmitting = useCaptureStore((s) => s.isSubmitting);
const error = useCaptureStore((s) => s.error);
const close = useCaptureStore((s) => s.close);
const setSubmitting = useCaptureStore((s) => s.setSubmitting);
const captureSuccess = useCaptureStore((s) => s.captureSuccess);
const captureError = useCaptureStore((s) => s.captureError);
const [value, setValue] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
// Auto-focus when overlay opens
useEffect(() => {
if (isOpen) {
setValue("");
// Defer focus to next tick so the DOM is ready
requestAnimationFrame(() => {
inputRef.current?.focus();
});
}
}, [isOpen]);
// Escape to close
useEffect(() => {
if (!isOpen) return;
function handleKeyDown(e: KeyboardEvent): void {
if (e.key === "Escape") {
e.preventDefault();
close();
}
}
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen, close]);
const handleSubmit = useCallback(async () => {
const trimmed = value.trim();
if (!trimmed) return;
setSubmitting(true);
try {
const result = await quickCapture(trimmed);
captureSuccess(result.bead_id);
} catch (err: unknown) {
const message = isMcError(err) ? err.message : "Capture failed";
captureError(message);
}
}, [value, setSubmitting, captureSuccess, captureError]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
},
[handleSubmit]
);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setValue(e.target.value);
// Clear any previous error when user types
if (error) {
useCaptureStore.getState().open(); // clears error
}
},
[error]
);
const handleBackdropClick = useCallback(
(e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
close();
}
},
[close]
);
return (
<AnimatePresence>
{isOpen && (
<motion.div
data-testid="capture-backdrop"
className="fixed inset-0 z-50 flex items-start justify-center pt-[20vh]"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
onClick={handleBackdropClick}
>
{/* Backdrop */}
<motion.div
className="absolute inset-0 bg-black/60"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
/>
{/* Card */}
<motion.div
className="relative z-10 w-full max-w-lg rounded-xl border border-zinc-700 bg-zinc-900 p-4 shadow-2xl"
initial={{ opacity: 0, y: -20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -10, scale: 0.98 }}
transition={{ duration: 0.2, type: "spring", damping: 25, stiffness: 300 }}
>
<div className="flex gap-2">
<input
ref={inputRef}
type="text"
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
disabled={isSubmitting}
placeholder="Capture a thought..."
className="flex-1 rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 placeholder-zinc-500 outline-none focus:border-zinc-500 disabled:opacity-50"
autoFocus
/>
<button
type="button"
onClick={handleSubmit}
disabled={isSubmitting || !value.trim()}
className="rounded-lg bg-zinc-700 px-4 py-2 text-sm font-medium text-zinc-200 transition-colors hover:bg-zinc-600 disabled:opacity-50"
>
{isSubmitting ? "..." : "Capture"}
</button>
</div>
{error && (
<div role="alert" className="mt-2 text-xs text-red-400">
{error}
</div>
)}
<p className="mt-2 text-[10px] text-zinc-600">
Enter to capture &middot; Esc to dismiss
</p>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}

View File

@@ -1,8 +1,26 @@
/// <reference types="vite/client" />
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./styles.css";
// Expose stores for E2E test seeding (development only)
if (import.meta.env.DEV) {
const w = window as unknown as Record<string, unknown>;
import("@/stores/focus-store").then(({ useFocusStore }) => {
w.__MC_FOCUS_STORE__ = useFocusStore;
});
import("@/stores/nav-store").then(({ useNavStore }) => {
w.__MC_NAV_STORE__ = useNavStore;
});
import("@/stores/batch-store").then(({ useBatchStore }) => {
w.__MC_BATCH_STORE__ = useBatchStore;
});
import("@/stores/capture-store").then(({ useCaptureStore }) => {
w.__MC_CAPTURE_STORE__ = useCaptureStore;
});
}
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />

View File

@@ -11,6 +11,10 @@ export default {
raised: "#27272a", // zinc-800
overlay: "#3f3f46", // zinc-700
},
// Staleness indicator colors
"mc-fresh": "#22c55e", // green-500 -- updated < 1 day
"mc-amber": "#f59e0b", // amber-500 -- updated 3-6 days
"mc-urgent": "#ef4444", // red-500 -- updated 7+ days
},
},
},