feat: unify card runtime and event-driven web ui
This commit is contained in:
parent
0edf8c3fef
commit
4dfb7ca3cc
105 changed files with 17382 additions and 8505 deletions
399
frontend/src/components/cardFeed/inbox.tsx
Normal file
399
frontend/src/components/cardFeed/inbox.tsx
Normal file
|
|
@ -0,0 +1,399 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue