400 lines
12 KiB
TypeScript
400 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>
|
||
|
|
);
|
||
|
|
}
|