feat: polish life os card stack
Some checks failed
CI / Backend Checks (push) Failing after 38s
CI / Frontend Checks (push) Failing after 40s

This commit is contained in:
kacper 2026-03-17 11:38:00 -04:00
parent 22b4a2be4f
commit 980dfb9e0e
13 changed files with 692 additions and 151 deletions

View file

@ -8,6 +8,7 @@ const EXECUTABLE_SCRIPT_TYPES = new Set([
"application/javascript",
"module",
]);
const CARD_LIVE_CONTENT_EVENT = "nanobot:card-live-content-change";
const CARD_SELECTION_EVENT = "nanobot:card-selection-change";
const cardLiveContentStore = new Map<string, JsonValue>();
const cardRefreshHandlers = new Map<string, () => void>();
@ -74,6 +75,10 @@ function resolveCardRoot(target: HTMLScriptElement | HTMLElement | null): HTMLEl
return target.closest("[data-nanobot-card-root]");
}
function dispatchCardLiveContentChange(cardId: string): void {
window.dispatchEvent(new CustomEvent(CARD_LIVE_CONTENT_EVENT, { detail: { cardId } }));
}
function setCardLiveContent(
target: HTMLScriptElement | HTMLElement | null,
snapshot: JsonValue | null | undefined,
@ -84,9 +89,11 @@ function setCardLiveContent(
const cloned = cloneJsonValue(snapshot ?? undefined);
if (cloned === undefined) {
cardLiveContentStore.delete(cardId);
dispatchCardLiveContentChange(cardId);
return;
}
cardLiveContentStore.set(cardId, cloned);
dispatchCardLiveContentChange(cardId);
}
function getCardLiveContent(cardId: string | null | undefined): JsonValue | undefined {
@ -437,6 +444,22 @@ const LANE_TITLES: Record<CardLane, string> = {
history: "History",
};
const LANE_ORDER: CardLane[] = ["attention", "work", "context", "history"];
const LANE_RANK: Record<CardLane, number> = {
attention: 0,
work: 1,
context: 2,
history: 3,
};
function readCardScore(card: CardItem): number {
if (!card.serverId) return card.priority;
const liveContent = window.__nanobotGetCardLiveContent?.(card.serverId);
if (!liveContent || typeof liveContent !== "object" || Array.isArray(liveContent)) {
return card.priority;
}
const score = (liveContent as Record<string, JsonValue>).score;
return typeof score === "number" && Number.isFinite(score) ? score : card.priority;
}
interface CardProps {
card: CardItem;
@ -690,7 +713,13 @@ export function CardFeed({ cards, viewActive, onDismiss, onChoice, onAskCard }:
lane,
title: LANE_TITLES[lane],
cards: cards.filter((card) => card.lane === lane),
})).filter((group) => group.cards.length > 0),
orderScore: Math.max(...cards.filter((card) => card.lane === lane).map(readCardScore)),
}))
.filter((group) => group.cards.length > 0)
.sort((left, right) => {
if (left.orderScore !== right.orderScore) return right.orderScore - left.orderScore;
return LANE_RANK[left.lane] - LANE_RANK[right.lane];
}),
[cards],
);

View file

@ -13,6 +13,7 @@ import type {
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL ?? "";
const WEBRTC_STUN_URL = import.meta.env.VITE_WEBRTC_STUN_URL?.trim() ?? "";
const LOCAL_ICE_GATHER_TIMEOUT_MS = 350;
const CARD_LIVE_CONTENT_EVENT = "nanobot:card-live-content-change";
let cardIdCounter = 0;
let logIdCounter = 0;
@ -80,11 +81,23 @@ interface RTCCallbacks {
closePC: () => void;
}
function readCardScore(card: Pick<CardItem, "priority" | "serverId">): number {
if (!card.serverId) return card.priority;
const liveContent = window.__nanobotGetCardLiveContent?.(card.serverId);
if (!liveContent || typeof liveContent !== "object" || Array.isArray(liveContent)) {
return card.priority;
}
const score = (liveContent as Record<string, unknown>).score;
return typeof score === "number" && Number.isFinite(score) ? score : card.priority;
}
function compareCards(a: CardItem, b: CardItem): number {
const laneDiff = LANE_RANK[a.lane] - LANE_RANK[b.lane];
if (laneDiff !== 0) return laneDiff;
const stateDiff = STATE_RANK[a.state] - STATE_RANK[b.state];
if (stateDiff !== 0) return stateDiff;
const scoreDiff = readCardScore(b) - readCardScore(a);
if (scoreDiff !== 0) return scoreDiff;
const laneDiff = LANE_RANK[a.lane] - LANE_RANK[b.lane];
if (laneDiff !== 0) return laneDiff;
if (a.priority !== b.priority) return b.priority - a.priority;
const updatedDiff = b.updatedAt.localeCompare(a.updatedAt);
if (updatedDiff !== 0) return updatedDiff;
@ -182,7 +195,7 @@ function handleTypedMessage(
): void {
if (msg.type === "agent_state") {
idleFallback.clear();
setAgentState((prev) => (prev === "listening" ? prev : msg.state));
setAgentState(() => msg.state);
return;
}
if (msg.type === "message") {
@ -519,6 +532,26 @@ function useCardsState() {
}
}, []);
useEffect(() => {
const onCardLiveContentChange = (event: Event) => {
const customEvent = event as CustomEvent<{ cardId?: unknown }>;
const cardId =
customEvent.detail && typeof customEvent.detail.cardId === "string"
? customEvent.detail.cardId
: "";
setCards((prev) => {
if (prev.length === 0) return prev;
if (cardId && !prev.some((card) => card.serverId === cardId)) return prev;
return sortCards([...prev]);
});
};
window.addEventListener(CARD_LIVE_CONTENT_EVENT, onCardLiveContentChange);
return () => {
window.removeEventListener(CARD_LIVE_CONTENT_EVENT, onCardLiveContentChange);
};
}, []);
return { cards, upsertCard, dismissCard, loadPersistedCards };
}

View file

@ -638,7 +638,8 @@ body {
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
scrollbar-color: rgba(255, 200, 140, 0.25) transparent;
background: #ffffff;
background: #ece8e1;
box-shadow: inset 0 1px 0 rgba(52, 40, 31, 0.24);
}
#card-feed::-webkit-scrollbar {
width: 4px;
@ -688,7 +689,7 @@ body {
background: transparent;
border: none;
border-radius: 0;
padding: 0 8px;
padding: 0;
box-shadow: none;
}
.card.dismissing {
@ -878,12 +879,9 @@ body {
color: inherit;
}
.card.kind-text .card-body > [data-nanobot-card-root] > :not(script) {
border-radius: 16px;
border-radius: 0;
overflow: hidden;
box-shadow:
0 6px 14px rgba(0, 0, 0, 0.1),
0 14px 26px rgba(0, 0, 0, 0.25),
inset 0 1px 0 rgba(255, 255, 255, 0.25);
box-shadow: none;
}
.card-question {
color: rgba(255, 245, 235, 0.95);