diff --git a/examples/cards/templates/todo-item-live/assets/dosis-400.ttf b/examples/cards/templates/todo-item-live/assets/dosis-400.ttf new file mode 100644 index 0000000..be2253b Binary files /dev/null and b/examples/cards/templates/todo-item-live/assets/dosis-400.ttf differ diff --git a/examples/cards/templates/todo-item-live/assets/dosis-600.ttf b/examples/cards/templates/todo-item-live/assets/dosis-600.ttf new file mode 100644 index 0000000..b254503 Binary files /dev/null and b/examples/cards/templates/todo-item-live/assets/dosis-600.ttf differ diff --git a/examples/cards/templates/todo-item-live/assets/dosis-700.ttf b/examples/cards/templates/todo-item-live/assets/dosis-700.ttf new file mode 100644 index 0000000..f3326c6 Binary files /dev/null and b/examples/cards/templates/todo-item-live/assets/dosis-700.ttf differ diff --git a/examples/cards/templates/todo-item-live/assets/ibm-plex-sans-condensed-400.ttf b/examples/cards/templates/todo-item-live/assets/ibm-plex-sans-condensed-400.ttf new file mode 100644 index 0000000..e85d230 Binary files /dev/null and b/examples/cards/templates/todo-item-live/assets/ibm-plex-sans-condensed-400.ttf differ diff --git a/examples/cards/templates/todo-item-live/assets/ibm-plex-sans-condensed-600.ttf b/examples/cards/templates/todo-item-live/assets/ibm-plex-sans-condensed-600.ttf new file mode 100644 index 0000000..660d25f Binary files /dev/null and b/examples/cards/templates/todo-item-live/assets/ibm-plex-sans-condensed-600.ttf differ diff --git a/examples/cards/templates/todo-item-live/assets/ibm-plex-sans-condensed-700.ttf b/examples/cards/templates/todo-item-live/assets/ibm-plex-sans-condensed-700.ttf new file mode 100644 index 0000000..381489f Binary files /dev/null and b/examples/cards/templates/todo-item-live/assets/ibm-plex-sans-condensed-700.ttf differ diff --git a/examples/cards/templates/todo-item-live/assets/mplus-1m-bold-sub.ttf b/examples/cards/templates/todo-item-live/assets/mplus-1m-bold-sub.ttf new file mode 100644 index 0000000..d75297f Binary files /dev/null and b/examples/cards/templates/todo-item-live/assets/mplus-1m-bold-sub.ttf differ diff --git a/examples/cards/templates/todo-item-live/assets/mplus-1m-regular-sub.ttf b/examples/cards/templates/todo-item-live/assets/mplus-1m-regular-sub.ttf new file mode 100644 index 0000000..110a990 Binary files /dev/null and b/examples/cards/templates/todo-item-live/assets/mplus-1m-regular-sub.ttf differ diff --git a/examples/cards/templates/todo-item-live/manifest.json b/examples/cards/templates/todo-item-live/manifest.json index f92c97e..0e5b62d 100644 --- a/examples/cards/templates/todo-item-live/manifest.json +++ b/examples/cards/templates/todo-item-live/manifest.json @@ -1,23 +1,29 @@ { "key": "todo-item-live", - "title": "Todo Item", - "notes": "Source-generated card for a single Home Assistant todo item. Do not use a live fetch URL. A card source script writes one card instance per todo uid and fills template_state with the current item fields plus source_id for refresh. The card completes the task by calling the Home Assistant HassListCompleteItem MCP tool with list_name and the task summary.", + "title": "Task Item", + "notes": "File-backed kanban task card. The template_state is written from ~/.nanobot/workspace/tasks via scripts/task_cards.py. The card shows one primary action plus a compact move menu and persists lane transitions by calling the exec tool to run scripts/task_cards.py move.", "example_state": { - "source_id": "ha-todo-kacpers-to-do", - "entity_id": "todo.kacpers_to_do", - "list_name": "Kacper's To-Do", - "uid": "55be123e-1ef3-11f1-b5e6-001e06480aef", - "summary": "Get sneakers", - "complete_tool_name": "mcp_home_assistant_HassListCompleteItem", - "complete_item": "Get sneakers", - "status": "needs_action", - "completed": false, + "kind": "file_task", + "task_path": "/home/kacper/.nanobot/workspace/tasks/backlog/20260316-pack-for-japan.md", + "task_key": "b9b79f12-1ef3-11f1-b5e6-001e06480aef", + "title": "Pack for Japan", + "lane": "backlog", + "created": "2026-03-16T18:10:00-04:00", + "updated": "2026-03-16T18:10:00-04:00", "due": null, - "due_datetime": null, - "description": null, - "generated_at": "2026-03-13T16:03:26+00:00", - "can_complete": true + "tags": [ + "home-assistant", + "imported" + ], + "body": "## Imported\n\n- Source: Home Assistant\n- List: Kacper's To-Do\n- UID: b9b79f12-1ef3-11f1-b5e6-001e06480aef", + "metadata": { + "source": "home_assistant", + "source_entity_id": "todo.kacpers_to_do", + "source_list": "Kacper's To-Do", + "source_uid": "b9b79f12-1ef3-11f1-b5e6-001e06480aef", + "imported_at": "2026-03-16T22:10:00+00:00" + } }, "created_at": "2026-03-13T00:00:00+00:00", - "updated_at": "2026-03-13T00:00:00+00:00" + "updated_at": "2026-03-16T00:00:00+00:00" } diff --git a/examples/cards/templates/todo-item-live/template.html b/examples/cards/templates/todo-item-live/template.html index cb4fb99..2859dcd 100644 --- a/examples/cards/templates/todo-item-live/template.html +++ b/examples/cards/templates/todo-item-live/template.html @@ -1,19 +1,260 @@ -
-
-
-
Todo
-
Loading...
+ + +
+
+
+
Task
+
- -
-
-
+
Loading...
+
+
-
- +
+
+
+
+ +
+ + +
+ +
+ diff --git a/frontend/src/components/CardFeed.tsx b/frontend/src/components/CardFeed.tsx index 3520de9..adaa9c6 100644 --- a/frontend/src/components/CardFeed.tsx +++ b/frontend/src/components/CardFeed.tsx @@ -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(); const cardRefreshHandlers = new Map 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 = { history: "History", }; const LANE_ORDER: CardLane[] = ["attention", "work", "context", "history"]; +const LANE_RANK: Record = { + 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).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], ); diff --git a/frontend/src/hooks/useWebRTC.ts b/frontend/src/hooks/useWebRTC.ts index ba64922..e4f4836 100644 --- a/frontend/src/hooks/useWebRTC.ts +++ b/frontend/src/hooks/useWebRTC.ts @@ -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): 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).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 }; } diff --git a/frontend/src/index.css b/frontend/src/index.css index 15fcd9c..e252fce 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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);