nanobot-voice-interface/frontend/src/components/cardFeed/inbox.tsx
kacper 4dfb7ca3cc
Some checks failed
CI / Backend Checks (push) Failing after 36s
CI / Frontend Checks (push) Failing after 40s
feat: unify card runtime and event-driven web ui
2026-04-06 15:42:53 -04:00

399 lines
12 KiB
TypeScript

import { useEffect, useState } from "preact/hooks";
import { decodeJsonError } from "../../cardRuntime/api";
import { requestCardFeedRefresh } from "../../cardRuntime/store";
const INBOX_REFRESH_EVENT = "nanobot:inbox-refresh";
interface InboxItem {
path: string;
title: string;
kind: string;
status: string;
source: string;
confidence: number | null;
captured: string;
updated: string;
suggestedDue: string;
tags: string[];
body: string;
}
function normalizeTaskTag(raw: string): string {
const trimmed = raw.trim().replace(/^#+/, "").replace(/\s+/g, "-");
return trimmed ? `#${trimmed}` : "";
}
function normalizeTaskTags(raw: unknown): string[] {
if (!Array.isArray(raw)) return [];
const seen = new Set<string>();
const tags: string[] = [];
for (const value of raw) {
const tag = normalizeTaskTag(String(value || ""));
const key = tag.toLowerCase();
if (!tag || seen.has(key)) continue;
seen.add(key);
tags.push(tag);
}
return tags;
}
function normalizeInboxItem(raw: unknown): InboxItem | null {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
const record = raw as Record<string, unknown>;
const path = typeof record.path === "string" ? record.path.trim() : "";
if (!path) return null;
return {
path,
title:
typeof record.title === "string" && record.title.trim()
? record.title.trim()
: "Untitled capture",
kind: typeof record.kind === "string" ? record.kind.trim() : "unknown",
status: typeof record.status === "string" ? record.status.trim() : "new",
source: typeof record.source === "string" ? record.source.trim() : "unknown",
confidence:
typeof record.confidence === "number" && Number.isFinite(record.confidence)
? record.confidence
: null,
captured: typeof record.captured === "string" ? record.captured.trim() : "",
updated: typeof record.updated === "string" ? record.updated.trim() : "",
suggestedDue: typeof record.suggested_due === "string" ? record.suggested_due.trim() : "",
tags: normalizeTaskTags(record.tags),
body: typeof record.body === "string" ? record.body : "",
};
}
async function fetchInboxItems(limit = 4): Promise<InboxItem[]> {
const params = new URLSearchParams({ limit: String(limit) });
const resp = await fetch(`/inbox?${params.toString()}`, { cache: "no-store" });
if (!resp.ok) throw new Error(await decodeJsonError(resp));
const payload = (await resp.json()) as { items?: unknown };
const items = Array.isArray(payload.items) ? payload.items : [];
return items.map(normalizeInboxItem).filter((item): item is InboxItem => item !== null);
}
function requestInboxRefresh(): void {
window.dispatchEvent(new Event(INBOX_REFRESH_EVENT));
}
function extractCaptureTags(text: string): string[] {
const matches = text.match(/(^|\s)#([A-Za-z0-9_-]+)/g) ?? [];
return Array.from(
new Set(
matches
.map((match) => match.trim())
.filter(Boolean)
.map((tag) => normalizeTaskTag(tag)),
),
);
}
export async function captureInboxItem(text: string): Promise<InboxItem> {
const trimmed = text.trim();
if (!trimmed) throw new Error("capture text is required");
const resp = await fetch("/inbox/capture", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
text: trimmed,
tags: extractCaptureTags(trimmed),
source: "web-ui",
}),
});
if (!resp.ok) throw new Error(await decodeJsonError(resp));
const payload = (await resp.json()) as { item?: unknown };
const item = normalizeInboxItem(payload.item);
if (!item) throw new Error("invalid inbox response");
requestInboxRefresh();
return item;
}
async function dismissInboxItem(itemPath: string): Promise<void> {
const resp = await fetch("/inbox/dismiss", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ item: itemPath }),
});
if (!resp.ok) throw new Error(await decodeJsonError(resp));
requestInboxRefresh();
}
async function acceptInboxItemAsTask(itemPath: string): Promise<void> {
const resp = await fetch("/inbox/accept-task", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ item: itemPath, lane: "backlog" }),
});
if (!resp.ok) throw new Error(await decodeJsonError(resp));
requestInboxRefresh();
requestCardFeedRefresh();
}
function formatInboxUpdatedAt(timestamp: string): string {
const parsed = new Date(timestamp);
if (Number.isNaN(parsed.getTime())) return "";
return parsed.toLocaleString([], {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
}
function summarizeInboxBody(body: string): string {
return body.replace(/\r\n?/g, "\n").split("\n## Raw Capture")[0].replace(/\s+/g, " ").trim();
}
function computeInboxScore(items: InboxItem[]): number {
if (!items.length) return 0;
const newest = items[0];
const newestMs = newest.updated ? new Date(newest.updated).getTime() : Number.NaN;
const ageHours = Number.isFinite(newestMs) ? (Date.now() - newestMs) / (60 * 60 * 1000) : 999;
if (ageHours <= 1) return 91;
if (ageHours <= 6) return 86;
if (ageHours <= 24) return 82;
return 78;
}
export function InboxQuickAdd({
onSubmit,
visible,
}: {
onSubmit(text: string): Promise<void>;
visible: boolean;
}) {
const [open, setOpen] = useState(false);
const [value, setValue] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (!visible) {
setOpen(false);
setError("");
}
}, [visible]);
const close = () => {
if (busy) return;
setOpen(false);
setError("");
};
const submit = async () => {
const trimmed = value.trim();
if (!trimmed) return;
setBusy(true);
setError("");
try {
await onSubmit(trimmed);
setValue("");
setOpen(false);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
};
if (!visible) return null;
return (
<>
<button
id="inbox-quick-add-btn"
type="button"
data-no-swipe="1"
aria-label="Quick add to inbox"
onClick={() => setOpen(true)}
>
+
</button>
{open ? (
<>
<button
id="inbox-quick-add-backdrop"
type="button"
data-no-swipe="1"
aria-label="Close inbox quick add"
onClick={close}
/>
<div id="inbox-quick-add-sheet" data-no-swipe="1">
<div class="inbox-quick-add-sheet__title">Quick Add</div>
<textarea
class="inbox-quick-add-sheet__input"
rows={3}
placeholder="Something to remember..."
value={value}
disabled={busy}
onInput={(event) => setValue(event.currentTarget.value)}
/>
{error ? <div class="inbox-quick-add-sheet__error">{error}</div> : null}
<div class="inbox-quick-add-sheet__actions">
<button
class="inbox-quick-add-sheet__btn"
type="button"
disabled={busy}
onClick={close}
>
Cancel
</button>
<button
class="inbox-quick-add-sheet__btn primary"
type="button"
disabled={busy || !value.trim()}
onClick={() => {
void submit();
}}
>
Add
</button>
</div>
</div>
</>
) : null}
</>
);
}
export function InboxReviewCard() {
const [items, setItems] = useState<InboxItem[]>([]);
const [loading, setLoading] = useState(true);
const [busyItem, setBusyItem] = useState<{ path: string; action: "accept" | "dismiss" } | null>(
null,
);
const [errorText, setErrorText] = useState("");
const loadInbox = async () => {
setLoading(true);
try {
const nextItems = await fetchInboxItems(4);
setItems(nextItems);
setErrorText("");
} catch (error) {
console.error("Inbox load failed", error);
setErrorText(error instanceof Error ? error.message : String(error));
} finally {
setLoading(false);
}
};
useEffect(() => {
void loadInbox();
const onRefresh = (event?: Event) => {
const customEvent = event as CustomEvent<{ items?: unknown[] }> | undefined;
const nextItems = customEvent?.detail?.items;
if (Array.isArray(nextItems)) {
setItems(
nextItems.map(normalizeInboxItem).filter((item): item is InboxItem => item !== null),
);
setLoading(false);
setErrorText("");
return;
}
void loadInbox();
};
window.addEventListener(INBOX_REFRESH_EVENT, onRefresh);
window.addEventListener("focus", onRefresh);
return () => {
window.removeEventListener(INBOX_REFRESH_EVENT, onRefresh);
window.removeEventListener("focus", onRefresh);
};
}, []);
const withBusyItem = async (
itemPath: string,
actionKind: "accept" | "dismiss",
action: () => Promise<void>,
) => {
setBusyItem({ path: itemPath, action: actionKind });
setErrorText("");
try {
await action();
await loadInbox();
} catch (error) {
console.error("Inbox action failed", error);
setErrorText(error instanceof Error ? error.message : String(error));
} finally {
setBusyItem(null);
}
};
const score = computeInboxScore(items);
if (!loading && !errorText && items.length === 0) {
return null;
}
return (
<article class="card kind-text state-active inbox-review-card">
<header class="card-header">
<div class="card-title-wrap">
<div class="card-title-line">
<span class="card-title">Inbox</span>
</div>
<div class="card-meta">
<span class="card-state state-active">
{loading ? "Loading" : `${items.length} open`}
</span>
</div>
</div>
</header>
<div class="inbox-review-card__body">
<div class="inbox-review-card__summary">
Capture first, organize later.
{score ? <span class="inbox-review-card__score">Priority {score}</span> : null}
</div>
{errorText ? <div class="inbox-review-card__error">{errorText}</div> : null}
{items.map((item) => {
const preview = summarizeInboxBody(item.body);
const itemBusy = busyItem?.path === item.path;
const accepting = busyItem?.path === item.path && busyItem.action === "accept";
return (
<div key={item.path} class="inbox-review-card__item">
<div class="inbox-review-card__item-topline">
<span class="inbox-review-card__item-kind">{item.kind}</span>
<span class={`inbox-review-card__item-updated${accepting ? " is-active" : ""}`}>
{accepting ? "Nanobot..." : formatInboxUpdatedAt(item.updated || item.captured)}
</span>
</div>
<div class="inbox-review-card__item-title">{item.title}</div>
{preview ? <div class="inbox-review-card__item-preview">{preview}</div> : null}
{item.tags.length ? (
<div class="inbox-review-card__tags">
{item.tags.map((tag) => (
<span key={tag} class="inbox-review-card__tag">
{tag}
</span>
))}
</div>
) : null}
<div class="inbox-review-card__actions">
<button
class="inbox-review-card__action"
type="button"
disabled={itemBusy}
onClick={() => {
void withBusyItem(item.path, "accept", () => acceptInboxItemAsTask(item.path));
}}
>
{accepting ? "Nanobot..." : "Ask Nanobot"}
</button>
<button
class="inbox-review-card__action ghost"
type="button"
disabled={itemBusy}
onClick={() => {
void withBusyItem(item.path, "dismiss", () => dismissInboxItem(item.path));
}}
>
Dismiss
</button>
</div>
</div>
);
})}
</div>
</article>
);
}