diff --git a/ARCHITECTURE_TODO.md b/ARCHITECTURE_TODO.md new file mode 100644 index 0000000..c0881f2 --- /dev/null +++ b/ARCHITECTURE_TODO.md @@ -0,0 +1,43 @@ +# Architecture TODO + +This file tracks the current architecture cleanup work for the Nanobot web app. + +## Card Platform + +- [x] Move all template-backed cards to the dynamic `card.js` runtime. +- [x] Remove inline-script fallback execution from the frontend shell. +- [x] Keep the card runtime contract documented and stable. +- [x] Validate every template card automatically in CI/local checks. +- [x] Add lightweight fixture coverage for mount/update/destroy behavior. + +## Template Source Of Truth + +- [x] Treat `~/.nanobot/cards/templates` as the live source of truth. +- [x] Sync repo examples from the live template tree with a script instead of manual copying. +- [x] Make template drift a failing quality check. + +## Backend Structure + +- [x] Split card persistence/materialization out of `app.py`. +- [x] Split session helpers out of `app.py`. +- [x] Split Nanobot transport/client code out of `app.py`. +- [x] Keep HTTP routes thin and push domain logic into service modules. + +## Frontend Structure + +- [x] Split `useWebRTC.ts` into transport, sessions, cards/feed, and workbench modules. +- [x] Reduce `App.tsx` to layout/navigation concerns only. +- [x] Keep `CardFeed.tsx` focused on rendering, not domain mutation orchestration. + +## Updates And Reactivity + +- [x] Replace polling-heavy paths with evented updates where possible. +- [x] Keep card updates localized so a live sensor refresh does not churn the whole feed. + +## Remaining Cleanup + +- [x] Move backend startup state into FastAPI lifespan/app state. +- [x] Stop importing private underscore helpers across app boundaries. +- [x] Split the backend route surface into router modules. +- [x] Split the card runtime renderer into loader/host/renderer modules. +- [x] Replace async tool-job polling with an evented stream. diff --git a/CARD_RUNTIME.md b/CARD_RUNTIME.md new file mode 100644 index 0000000..c6534d6 --- /dev/null +++ b/CARD_RUNTIME.md @@ -0,0 +1,126 @@ +# Card Runtime + +The app shell is responsible for layout, navigation, sessions, feed ordering, and workbench placement. +Cards are responsible for their own UI and behavior through a small dynamic runtime contract. + +## Source Of Truth + +- Live card templates live under `~/.nanobot/cards/templates`. +- Repo examples under `examples/cards/templates` are mirrors for development/reference. +- New cards must be added as `manifest.json + template.html + card.js`. +- `template.html` is markup and styles only. Do not put executable `' - f"{template_html}" - "" + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], ) + @app.get("/health") + async def health() -> JSONResponse: + return JSONResponse({"status": "ok"}) -def _load_card(card_id: str) -> dict[str, Any] | None: - meta_path = _card_meta_path(card_id) - if meta_path is None or not meta_path.exists(): - return None - try: - raw = json.loads(meta_path.read_text(encoding="utf-8")) - except Exception: - return None - if not isinstance(raw, dict): - return None + app.include_router(tools_router) + app.include_router(sessions_router) + app.include_router(cards_router) + app.include_router(workbench_router) + app.include_router(inbox_router) + app.include_router(templates_router) + app.include_router(messages_router) + app.include_router(rtc_router) - state_path = _card_state_path(card_id) - if state_path is not None and state_path.exists(): - try: - raw_state = json.loads(state_path.read_text(encoding="utf-8")) - except Exception: - raw_state = {} - if isinstance(raw_state, dict): - raw["template_state"] = raw_state - - card = _coerce_card_record(raw) - if card is None: - return None - - card["content"] = _materialize_card_content(card) - return card - - -def _write_card(card: dict[str, Any]) -> dict[str, Any] | None: - normalized = _coerce_card_record(card) - if normalized is None: - return None - - now = datetime.now(timezone.utc).isoformat() - existing = _load_card(normalized["id"]) - if existing is not None: - normalized["created_at"] = existing.get("created_at") or normalized.get("created_at") or now - else: - normalized["created_at"] = normalized.get("created_at") or now - normalized["updated_at"] = normalized.get("updated_at") or now - - instance_dir = _card_instance_dir(normalized["id"]) - meta_path = _card_meta_path(normalized["id"]) - state_path = _card_state_path(normalized["id"]) - if instance_dir is None or meta_path is None or state_path is None: - return None - - instance_dir.mkdir(parents=True, exist_ok=True) - - template_state = normalized.pop("template_state", {}) - meta_path.write_text( - json.dumps(normalized, indent=2, ensure_ascii=False) + "\n", - encoding="utf-8", - ) - if normalized["kind"] == "text": - state_path.write_text( - json.dumps( - template_state if isinstance(template_state, dict) else {}, - indent=2, - ensure_ascii=False, + if DIST_DIR.exists(): + assets_dir = DIST_DIR / "assets" + if assets_dir.exists(): + app.mount("/assets", StaticFiles(directory=str(assets_dir)), name="assets") + if CARD_TEMPLATES_DIR.exists(): + app.mount( + "/card-templates", + StaticFiles(directory=str(CARD_TEMPLATES_DIR)), + name="card-templates", ) - + "\n", - encoding="utf-8", - ) - else: - state_path.unlink(missing_ok=True) - return _load_card(normalized["id"]) + @app.get("/{full_path:path}") + async def spa_fallback(full_path: str) -> FileResponse: + candidate = DIST_DIR / full_path + if candidate.is_file(): + return FileResponse(str(candidate)) + response = FileResponse(str(DIST_DIR / "index.html")) + response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate" + response.headers["Pragma"] = "no-cache" + response.headers["Expires"] = "0" + return response + return app -def _sort_cards(cards: list[dict[str, Any]]) -> list[dict[str, Any]]: - return sorted( - cards, - key=lambda item: ( - _CARD_LANE_ORDER.get(str(item.get("lane", "context")), 99), - _CARD_STATE_ORDER.get(str(item.get("state", "active")), 99), - -int(item.get("priority", 0) or 0), - str(item.get("updated_at", "")), - str(item.get("created_at", "")), - ), - reverse=False, - ) - -def _load_cards() -> list[dict[str, Any]]: - CARD_INSTANCES_DIR.mkdir(parents=True, exist_ok=True) - cards: list[dict[str, Any]] = [] - now = datetime.now(timezone.utc) - for instance_dir in CARD_INSTANCES_DIR.iterdir(): - if not instance_dir.is_dir(): - continue - card = _load_card(instance_dir.name) - if card is None: - continue - if card.get("state") == "archived": - continue - snooze_until = _parse_iso_datetime(str(card.get("snooze_until", "") or "")) - if snooze_until is not None and snooze_until > now: - continue - cards.append(card) - return _sort_cards(cards) - - -def _find_card_by_slot(slot: str, *, chat_id: str) -> dict[str, Any] | None: - target_slot = slot.strip() - if not target_slot: - return None - for card in _load_cards(): - if str(card.get("chat_id", "")).strip() != chat_id: - continue - if str(card.get("slot", "")).strip() == target_slot: - return card - return None - - -def _persist_card(card: dict[str, Any]) -> dict[str, Any] | None: - normalized = _coerce_card_record(card) - if normalized is None: - return None - - existing_same_slot = None - slot = str(normalized.get("slot", "")).strip() - chat_id = str(normalized.get("chat_id", "web") or "web") - if slot: - existing_same_slot = _find_card_by_slot(slot, chat_id=chat_id) - - if existing_same_slot is not None and existing_same_slot["id"] != normalized["id"]: - superseded = dict(existing_same_slot) - superseded["state"] = "superseded" - superseded["updated_at"] = datetime.now(timezone.utc).isoformat() - _write_card(superseded) - - return _write_card(normalized) - - -def _delete_card(card_id: str) -> bool: - instance_dir = _card_instance_dir(card_id) - if instance_dir is None or not instance_dir.exists(): - return False - shutil.rmtree(instance_dir, ignore_errors=True) - return True - - -def _template_dir(template_key: str) -> Path: - return CARD_TEMPLATES_DIR / template_key - - -def _template_html_path(template_key: str) -> Path: - return _template_dir(template_key) / "template.html" - - -def _template_meta_path(template_key: str) -> Path: - return _template_dir(template_key) / "manifest.json" - - -def _read_template_meta(template_key: str) -> dict[str, Any]: - meta_path = _template_meta_path(template_key) - try: - meta = json.loads(meta_path.read_text(encoding="utf-8")) - return meta if isinstance(meta, dict) else {} - except Exception: - return {} - - -def _list_templates(limit: int | None = None) -> list[dict[str, Any]]: - CARD_TEMPLATES_DIR.mkdir(parents=True, exist_ok=True) - templates: list[dict[str, Any]] = [] - - for template_dir in CARD_TEMPLATES_DIR.iterdir(): - if not template_dir.is_dir(): - continue - key = _normalize_template_key(template_dir.name) - if not key: - continue - - html_path = _template_html_path(key) - if not html_path.exists(): - continue - try: - content = html_path.read_text(encoding="utf-8") - except Exception: - continue - - stat = html_path.stat() - meta = _read_template_meta(key) - if bool(meta.get("deprecated")): - continue - created_at = str( - meta.get("created_at") - or datetime.fromtimestamp(stat.st_ctime, timezone.utc).isoformat() - ) - updated_at = str( - meta.get("updated_at") - or datetime.fromtimestamp(stat.st_mtime, timezone.utc).isoformat() - ) - templates.append( - { - "id": key, - "key": key, - "title": str(meta.get("title", "")), - "content": content, - "notes": str(meta.get("notes", "")), - "example_state": meta.get("example_state", {}), - "created_at": created_at, - "updated_at": updated_at, - "file_url": f"/card-templates/{key}/template.html", - } - ) - - templates.sort(key=lambda item: item["updated_at"], reverse=True) - if limit is not None: - return templates[: max(0, limit)] - return templates - - -def _render_templates_markdown(rows: list[dict[str, Any]]) -> str: - lines = [ - "# Card Templates", - "", - "These are user-approved template layouts for `mcp_display_render_card` cards.", - "Each card instance should provide a `template_key` and a `template_state` JSON object.", - "Use a matching template when the request intent fits.", - "Do not rewrite the HTML layout when an existing template already fits; fill the template_state instead.", - "", - ] - for row in rows: - key = str(row.get("key", "")).strip() or "unnamed" - title = str(row.get("title", "")).strip() or "(untitled)" - notes = str(row.get("notes", "")).strip() or "(no usage notes)" - content = str(row.get("content", "")).strip() - example_state = row.get("example_state", {}) - if len(content) > MAX_TEMPLATE_HTML_CHARS: - content = content[:MAX_TEMPLATE_HTML_CHARS] + "\n" - html_lines = [f" {line}" for line in content.splitlines()] if content else [" "] - state_text = ( - json.dumps(example_state, indent=2, ensure_ascii=False) - if isinstance(example_state, dict) - else "{}" - ) - state_lines = [f" {line}" for line in state_text.splitlines()] - lines.extend( - [ - f"## {key}", - f"- Title: {title}", - f"- Usage: {notes}", - "- Example State:", - *state_lines, - "- HTML:", - *html_lines, - "", - ] - ) - return "\n".join(lines).rstrip() + "\n" - - -def _sync_templates_context_file() -> None: - try: - rows = _list_templates(limit=MAX_TEMPLATES_IN_PROMPT) - if not rows: - TEMPLATES_CONTEXT_PATH.unlink(missing_ok=True) - return - TEMPLATES_CONTEXT_PATH.parent.mkdir(parents=True, exist_ok=True) - TEMPLATES_CONTEXT_PATH.write_text(_render_templates_markdown(rows), encoding="utf-8") - except Exception: - return - - -def _to_typed_message(event_dict: dict[str, Any]) -> dict[str, Any] | None: - role = str(event_dict.get("role", "")).strip() - text = str(event_dict.get("text", "")) - timestamp = str(event_dict.get("timestamp", "")) - - if role == "agent-state": - return {"type": "agent_state", "state": text} - - if role in {"nanobot", "nanobot-progress", "nanobot-tool", "system", "user"}: - return { - "type": "message", - "role": role, - "content": text, - "is_progress": role in {"nanobot-progress", "nanobot-tool"}, - "is_tool_hint": role == "nanobot-tool", - "timestamp": timestamp, - } - - if role == "card": - payload = _decode_object(text) - if payload is None: - return None - card = _coerce_card_record(payload) - if card is None: - return None - card["type"] = "card" - return card - - return None - - -# --------------------------------------------------------------------------- -# Nanobot API helpers -# --------------------------------------------------------------------------- - - -class _NanobotApiError(RuntimeError): - def __init__(self, code: int, message: str) -> None: - super().__init__(message) - self.code = code - - -def _jsonrpc_request(request_id: str, method: str, params: dict[str, Any]) -> dict[str, Any]: - return { - "jsonrpc": _JSONRPC_VERSION, - "id": request_id, - "method": method, - "params": params, - } - - -async def _open_nanobot_api_socket() -> tuple[asyncio.StreamReader, asyncio.StreamWriter]: - if not NANOBOT_API_SOCKET.exists(): - raise RuntimeError( - f"Nanobot API socket not found at {NANOBOT_API_SOCKET}. " - "Enable channels.api and start `nanobot gateway`." - ) - try: - return await asyncio.open_unix_connection( - path=str(NANOBOT_API_SOCKET), - limit=_NANOBOT_API_STREAM_LIMIT, - ) - except OSError as exc: - raise RuntimeError(f"failed to connect to Nanobot API socket: {exc}") from exc - - -async def _send_nanobot_api_request( - method: str, - params: dict[str, Any], - *, - timeout_seconds: float, -) -> Any: - request_id = str(uuid.uuid4()) - reader, writer = await _open_nanobot_api_socket() - try: - writer.write( - ( - json.dumps(_jsonrpc_request(request_id, method, params), ensure_ascii=False) + "\n" - ).encode("utf-8") - ) - await writer.drain() - - loop = asyncio.get_running_loop() - deadline = loop.time() + timeout_seconds - - while True: - remaining = deadline - loop.time() - if remaining <= 0: - raise RuntimeError(f"timed out waiting for Nanobot API response to {method}") - - try: - line = await asyncio.wait_for(reader.readline(), timeout=remaining) - except ValueError as exc: - raise RuntimeError( - "Nanobot API response exceeded the configured stream limit" - ) from exc - if not line: - raise RuntimeError("Nanobot API socket closed before responding") - - try: - message = json.loads(line.decode("utf-8", errors="replace")) - except json.JSONDecodeError: - continue - if not isinstance(message, dict): - continue - if message.get("jsonrpc") != _JSONRPC_VERSION: - continue - if "method" in message: - continue - if str(message.get("id", "")).strip() != request_id: - continue - if "error" in message: - error = message.get("error", {}) - if isinstance(error, dict): - raise _NanobotApiError( - int(error.get("code", -32000)), - str(error.get("message", "unknown Nanobot API error")), - ) - raise _NanobotApiError(-32000, str(error)) - return message.get("result") - finally: - writer.close() - await writer.wait_closed() - - -def _utc_now_iso() -> str: - return datetime.now(timezone.utc).isoformat() - - -async def _prune_tool_jobs_locked() -> None: - cutoff = datetime.now(timezone.utc).timestamp() - _TOOL_JOB_RETENTION_SECONDS - expired_job_ids: list[str] = [] - - for job_id, payload in _tool_jobs.items(): - finished_at = str(payload.get("finished_at", "") or "") - if not finished_at: - continue - try: - finished_ts = datetime.fromisoformat(finished_at).timestamp() - except ValueError: - finished_ts = 0.0 - if finished_ts <= cutoff: - expired_job_ids.append(job_id) - - for job_id in expired_job_ids: - task = _tool_job_tasks.get(job_id) - if task is not None and not task.done(): - continue - _tool_jobs.pop(job_id, None) - _tool_job_tasks.pop(job_id, None) - - -def _serialize_tool_job(payload: dict[str, Any]) -> dict[str, Any]: - result = payload.get("result") - if not isinstance(result, dict): - result = None - return { - "job_id": str(payload.get("job_id", "")), - "tool_name": str(payload.get("tool_name", "")), - "status": str(payload.get("status", "queued") or "queued"), - "created_at": str(payload.get("created_at", "")), - "started_at": payload.get("started_at"), - "finished_at": payload.get("finished_at"), - "result": result, - "error": payload.get("error"), - "error_code": payload.get("error_code"), - } - - -async def _start_tool_job(tool_name: str, arguments: dict[str, Any]) -> dict[str, Any]: - job_id = uuid.uuid4().hex - payload = { - "job_id": job_id, - "tool_name": tool_name, - "status": "queued", - "created_at": _utc_now_iso(), - "started_at": None, - "finished_at": None, - "result": None, - "error": None, - "error_code": None, - } - - async with _tool_job_lock: - await _prune_tool_jobs_locked() - _tool_jobs[job_id] = payload - _tool_job_tasks[job_id] = asyncio.create_task( - _run_tool_job(job_id, tool_name, dict(arguments)), - name=f"manual-tool-{job_id}", - ) - return _serialize_tool_job(payload) - - -async def _run_tool_job(job_id: str, tool_name: str, arguments: dict[str, Any]) -> None: - async with _tool_job_lock: - payload = _tool_jobs.get(job_id) - if payload is None: - return - payload["status"] = "running" - payload["started_at"] = _utc_now_iso() - - try: - result = await _send_nanobot_api_request( - "tool.call", - {"name": tool_name, "arguments": arguments}, - timeout_seconds=_TOOL_JOB_TIMEOUT_SECONDS, - ) - if not isinstance(result, dict): - raise RuntimeError("Nanobot API returned an invalid tool response") - async with _tool_job_lock: - payload = _tool_jobs.get(job_id) - if payload is None: - return - payload["status"] = "completed" - payload["result"] = result - payload["finished_at"] = _utc_now_iso() - except asyncio.CancelledError: - async with _tool_job_lock: - payload = _tool_jobs.get(job_id) - if payload is not None: - payload["status"] = "failed" - payload["error"] = "tool job cancelled" - payload["finished_at"] = _utc_now_iso() - raise - except _NanobotApiError as exc: - async with _tool_job_lock: - payload = _tool_jobs.get(job_id) - if payload is not None: - payload["status"] = "failed" - payload["error"] = str(exc) - payload["error_code"] = exc.code - payload["finished_at"] = _utc_now_iso() - except RuntimeError as exc: - async with _tool_job_lock: - payload = _tool_jobs.get(job_id) - if payload is not None: - payload["status"] = "failed" - payload["error"] = str(exc) - payload["finished_at"] = _utc_now_iso() - except Exception as exc: - async with _tool_job_lock: - payload = _tool_jobs.get(job_id) - if payload is not None: - payload["status"] = "failed" - payload["error"] = f"unexpected tool job error: {exc}" - payload["finished_at"] = _utc_now_iso() - finally: - async with _tool_job_lock: - _tool_job_tasks.pop(job_id, None) - await _prune_tool_jobs_locked() - - -async def _get_tool_job(job_id: str) -> dict[str, Any] | None: - async with _tool_job_lock: - await _prune_tool_jobs_locked() - payload = _tool_jobs.get(job_id) - return _serialize_tool_job(payload) if payload is not None else None - - -# --------------------------------------------------------------------------- -# API routes -# --------------------------------------------------------------------------- - - -@app.get("/health") -async def health() -> JSONResponse: - return JSONResponse({"status": "ok"}) - - -@app.get("/tools") -async def list_tools() -> JSONResponse: - try: - result = await _send_nanobot_api_request("tool.list", {}, timeout_seconds=20.0) - except _NanobotApiError as exc: - status_code = 503 if exc.code == -32000 else 502 - return JSONResponse({"error": str(exc)}, status_code=status_code) - except RuntimeError as exc: - return JSONResponse({"error": str(exc)}, status_code=503) - - if not isinstance(result, dict): - return JSONResponse({"error": "Nanobot API returned an invalid tool list"}, status_code=502) - - tools = result.get("tools", []) - if not isinstance(tools, list): - return JSONResponse({"error": "Nanobot API returned an invalid tool list"}, status_code=502) - return JSONResponse({"tools": tools}) - - -@app.post("/tools/call") -async def call_tool(request: Request) -> JSONResponse: - try: - payload = await _read_json_request(request) - except ValueError as exc: - return JSONResponse({"error": str(exc)}, status_code=400) - - tool_name = str(payload.get("tool_name", payload.get("name", ""))).strip() - if not tool_name: - return JSONResponse({"error": "tool_name is required"}, status_code=400) - - arguments = payload.get("arguments", payload.get("params", {})) - if arguments is None: - arguments = {} - if not isinstance(arguments, dict): - return JSONResponse({"error": "arguments must be a JSON object"}, status_code=400) - async_requested = payload.get("async") is True - - if async_requested: - job_payload = await _start_tool_job(tool_name, arguments) - return JSONResponse(job_payload, status_code=202) - - try: - result = await _send_nanobot_api_request( - "tool.call", - {"name": tool_name, "arguments": arguments}, - timeout_seconds=60.0, - ) - except _NanobotApiError as exc: - status_code = 400 if exc.code == -32602 else 503 if exc.code == -32000 else 502 - return JSONResponse({"error": str(exc)}, status_code=status_code) - except RuntimeError as exc: - return JSONResponse({"error": str(exc)}, status_code=503) - - if not isinstance(result, dict): - return JSONResponse( - {"error": "Nanobot API returned an invalid tool response"}, status_code=502 - ) - return JSONResponse(result) - - -@app.get("/tools/jobs/{job_id}") -async def get_tool_job(job_id: str) -> JSONResponse: - safe_job_id = job_id.strip() - if not safe_job_id: - return JSONResponse({"error": "job id is required"}, status_code=400) - - payload = await _get_tool_job(safe_job_id) - if payload is None: - return JSONResponse({"error": "tool job not found"}, status_code=404) - return JSONResponse(payload) - - -@app.get("/cards") -async def get_cards() -> JSONResponse: - return JSONResponse(_load_cards()) - - -@app.delete("/cards/{card_id}") -async def delete_card(card_id: str) -> JSONResponse: - if not _normalize_card_id(card_id): - return JSONResponse({"error": "invalid card id"}, status_code=400) - _delete_card(card_id) - return JSONResponse({"status": "ok"}) - - -@app.post("/cards/{card_id}/snooze") -async def snooze_card(card_id: str, request: Request) -> JSONResponse: - if not _normalize_card_id(card_id): - return JSONResponse({"error": "invalid card id"}, status_code=400) - - try: - payload = await _read_json_request(request) - except ValueError as exc: - return JSONResponse({"error": str(exc)}, status_code=400) - - until_raw = str(payload.get("until", "")).strip() - until_dt = _parse_iso_datetime(until_raw) - if until_dt is None: - return JSONResponse({"error": "until must be a valid ISO datetime"}, status_code=400) - - card = _load_card(card_id) - if card is None: - return JSONResponse({"error": "card not found"}, status_code=404) - - card["snooze_until"] = until_dt.isoformat() - card["updated_at"] = datetime.now(timezone.utc).isoformat() - persisted = _write_card(card) - if persisted is None: - return JSONResponse({"error": "failed to snooze card"}, status_code=500) - return JSONResponse({"status": "ok", "card": persisted}) - - -@app.post("/cards/{card_id}/state") -async def update_card_state(card_id: str, request: Request) -> JSONResponse: - if not _normalize_card_id(card_id): - return JSONResponse({"error": "invalid card id"}, status_code=400) - - try: - payload = await _read_json_request(request) - except ValueError as exc: - return JSONResponse({"error": str(exc)}, status_code=400) - - template_state = payload.get("template_state") - if not isinstance(template_state, dict): - return JSONResponse({"error": "template_state must be an object"}, status_code=400) - - card = _load_card(card_id) - if card is None: - return JSONResponse({"error": "card not found"}, status_code=404) - - if str(card.get("kind", "")) != "text": - return JSONResponse({"error": "only text cards support template_state"}, status_code=400) - - card["template_state"] = template_state - card["updated_at"] = datetime.now(timezone.utc).isoformat() - persisted = _write_card(card) - if persisted is None: - return JSONResponse({"error": "failed to update card state"}, status_code=500) - return JSONResponse({"status": "ok", "card": persisted}) - - -@app.get("/templates") -async def get_templates() -> JSONResponse: - return JSONResponse(_list_templates()) - - -@app.post("/templates") -async def save_template(request: Request) -> JSONResponse: - try: - payload = await _read_json_request(request) - except ValueError as exc: - return JSONResponse({"error": str(exc)}, status_code=400) - key = _normalize_template_key(str(payload.get("key", ""))) - title = str(payload.get("title", "")).strip() - content = str(payload.get("content", "")).strip() - notes = str(payload.get("notes", "")).strip() - example_state = payload.get("example_state", {}) - if not isinstance(example_state, dict): - example_state = {} - - if not key: - return JSONResponse({"error": "template key is required"}, status_code=400) - if not content: - return JSONResponse({"error": "template content is required"}, status_code=400) - - template_dir = _template_dir(key) - template_dir.mkdir(parents=True, exist_ok=True) - now = datetime.now(timezone.utc).isoformat() - existing_meta = _read_template_meta(key) - created_at = str(existing_meta.get("created_at") or now) - - _template_html_path(key).write_text(content, encoding="utf-8") - _template_meta_path(key).write_text( - json.dumps( - { - "key": key, - "title": title, - "notes": notes, - "example_state": example_state, - "created_at": created_at, - "updated_at": now, - }, - indent=2, - ensure_ascii=False, - ) - + "\n", - encoding="utf-8", - ) - - _sync_templates_context_file() - return JSONResponse( - { - "status": "ok", - "id": key, - "key": key, - "example_state": example_state, - "file_url": f"/card-templates/{key}/template.html", - } - ) - - -@app.delete("/templates/{template_key}") -async def delete_template(template_key: str) -> JSONResponse: - key = _normalize_template_key(template_key) - if not key: - return JSONResponse({"error": "invalid template key"}, status_code=400) - shutil.rmtree(_template_dir(key), ignore_errors=True) - _sync_templates_context_file() - return JSONResponse({"status": "ok", "key": key}) - - -@app.post("/message") -async def post_message(request: Request) -> JSONResponse: - try: - payload = await _read_json_request(request) - except ValueError as exc: - return JSONResponse({"error": str(exc)}, status_code=400) - text = str(payload.get("text", "")).strip() - metadata = payload.get("metadata", {}) - if not text: - return JSONResponse({"error": "empty message"}, status_code=400) - if not isinstance(metadata, dict): - metadata = {} - try: - await gateway.send_user_message(text, metadata=metadata) - except RuntimeError as exc: - return JSONResponse({"error": str(exc)}, status_code=503) - return JSONResponse({"status": "ok"}) - - -@app.post("/rtc/offer") -async def rtc_offer(request: Request) -> JSONResponse: - global _active_session, _active_queue, _sender_task - - try: - payload = await _read_json_request(request) - except ValueError as exc: - return JSONResponse({"error": str(exc)}, status_code=400) - - if _active_session is not None: - await _active_session.close() - _active_session = None - if _active_queue is not None: - await gateway.unsubscribe(_active_queue) - _active_queue = None - if _sender_task is not None: - _sender_task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await _sender_task - _sender_task = None - - queue = await gateway.subscribe() - _active_queue = queue - - voice_session = WebRTCVoiceSession(gateway=gateway) - _active_session = voice_session - - _sender_task = asyncio.create_task( - _sender_loop(queue, voice_session), - name="rtc-sender", - ) - - answer = await voice_session.handle_offer(payload) - if answer is None: - return JSONResponse( - {"error": "WebRTC backend unavailable on host (aiortc is not installed)."}, - status_code=503, - ) - - await gateway.connect_nanobot() - return JSONResponse(answer) - - -@app.on_event("shutdown") -async def on_shutdown() -> None: - global _active_session, _active_queue, _sender_task - tool_tasks = list(_tool_job_tasks.values()) - for task in tool_tasks: - task.cancel() - if tool_tasks: - with contextlib.suppress(asyncio.CancelledError): - await asyncio.gather(*tool_tasks, return_exceptions=True) - if _sender_task is not None: - _sender_task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await _sender_task - if _active_session is not None: - await _active_session.close() - if _active_queue is not None: - await gateway.unsubscribe(_active_queue) - await gateway.shutdown() - - -async def _sender_loop( - queue: asyncio.Queue, - voice_session: "WebRTCVoiceSession", -) -> None: - while True: - event = await queue.get() - if event.role == "nanobot-tts-partial": - await voice_session.queue_output_text(event.text, partial=True) - continue - if event.role == "nanobot-tts-flush": - await voice_session.flush_partial_output_text() - continue - if event.role == "nanobot-tts": - for segment in _chunk_tts_text(event.text): - await voice_session.queue_output_text(segment) - continue - typed_event = _to_typed_message(event.to_dict()) - if typed_event is None: - continue - if typed_event.get("type") == "card": - persisted = _persist_card(typed_event) - if persisted is None: - continue - payload = dict(persisted) - payload["type"] = "card" - voice_session.send_to_datachannel(payload) - continue - voice_session.send_to_datachannel(typed_event) - - -@app.on_event("startup") -async def on_startup() -> None: - CARD_INSTANCES_DIR.mkdir(parents=True, exist_ok=True) - CARD_TEMPLATES_DIR.mkdir(parents=True, exist_ok=True) - _sync_templates_context_file() - - -if DIST_DIR.exists(): - assets_dir = DIST_DIR / "assets" - if assets_dir.exists(): - app.mount("/assets", StaticFiles(directory=str(assets_dir)), name="assets") - if CARD_TEMPLATES_DIR.exists(): - app.mount( - "/card-templates", - StaticFiles(directory=str(CARD_TEMPLATES_DIR)), - name="card-templates", - ) - - @app.get("/{full_path:path}") - async def spa_fallback(full_path: str) -> FileResponse: - candidate = DIST_DIR / full_path - if candidate.is_file(): - return FileResponse(str(candidate)) - response = FileResponse(str(DIST_DIR / "index.html")) - response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate" - response.headers["Pragma"] = "no-cache" - response.headers["Expires"] = "0" - return response +app = create_app() diff --git a/app_dependencies.py b/app_dependencies.py new file mode 100644 index 0000000..0fa9822 --- /dev/null +++ b/app_dependencies.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +from fastapi import Request + +from web_runtime import WebAppRuntime + + +def get_runtime(request: Request) -> WebAppRuntime: + return request.app.state.runtime diff --git a/card_store.py b/card_store.py new file mode 100644 index 0000000..57d1f44 --- /dev/null +++ b/card_store.py @@ -0,0 +1,488 @@ +from __future__ import annotations + +import html +import json +import os +import re +import shutil +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +NANOBOT_WORKSPACE = Path(os.getenv("NANOBOT_WORKSPACE", str(Path.home() / ".nanobot"))).expanduser() +CARDS_ROOT = NANOBOT_WORKSPACE / "cards" +CARD_INSTANCES_DIR = CARDS_ROOT / "instances" +CARD_TEMPLATES_DIR = CARDS_ROOT / "templates" +TEMPLATES_CONTEXT_PATH = NANOBOT_WORKSPACE / "CARD_TEMPLATES.md" +MAX_TEMPLATES_IN_PROMPT = 12 +MAX_TEMPLATE_HTML_CHARS = 4000 +_INVALID_TEMPLATE_KEY_CHARS = re.compile(r"[^a-z0-9_-]+") +_CARD_ID_PATTERN = re.compile(r"^[a-zA-Z0-9_-]{1,128}$") +_CARD_LANE_ORDER = {"attention": 0, "work": 1, "context": 2, "history": 3} +_CARD_STATE_ORDER = {"active": 0, "stale": 1, "resolved": 2, "superseded": 3, "archived": 4} + + +def ensure_card_store_dirs() -> None: + CARD_INSTANCES_DIR.mkdir(parents=True, exist_ok=True) + CARD_TEMPLATES_DIR.mkdir(parents=True, exist_ok=True) + + +def _normalize_template_key(raw: str) -> str: + key = _INVALID_TEMPLATE_KEY_CHARS.sub("-", raw.strip().lower()).strip("-") + return key[:64] + + +def _normalize_card_id(raw: str) -> str: + card_id = raw.strip() + return card_id if _CARD_ID_PATTERN.fullmatch(card_id) else "" + + +def _card_instance_dir(card_id: str) -> Path | None: + card_id_clean = _normalize_card_id(card_id) + if not card_id_clean: + return None + return CARD_INSTANCES_DIR / card_id_clean + + +def _card_meta_path(card_id: str) -> Path | None: + instance_dir = _card_instance_dir(card_id) + if instance_dir is None: + return None + return instance_dir / "card.json" + + +def _card_state_path(card_id: str) -> Path | None: + instance_dir = _card_instance_dir(card_id) + if instance_dir is None: + return None + return instance_dir / "state.json" + + +def _parse_iso_datetime(raw: str) -> datetime | None: + value = raw.strip() + if not value: + return None + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + +def _coerce_card_record(raw: dict[str, Any]) -> dict[str, Any] | None: + card_id = _normalize_card_id(str(raw.get("id", ""))) + if not card_id: + return None + + kind = str(raw.get("kind", "text") or "text").strip().lower() + if kind not in {"text", "question"}: + kind = "text" + + lane = str(raw.get("lane", "context") or "context").strip().lower() + if lane not in _CARD_LANE_ORDER: + lane = "context" + + state = str(raw.get("state", "active") or "active").strip().lower() + if state not in _CARD_STATE_ORDER: + state = "active" + + try: + priority = int(raw.get("priority", 50)) + except (TypeError, ValueError): + priority = 50 + priority = max(0, min(priority, 100)) + + raw_choices = raw.get("choices", []) + choices = [str(choice) for choice in raw_choices] if isinstance(raw_choices, list) else [] + raw_template_state = raw.get("template_state", {}) + template_state = raw_template_state if isinstance(raw_template_state, dict) else {} + + return { + "id": card_id, + "kind": kind, + "title": str(raw.get("title", "")), + "content": str(raw.get("content", "")), + "question": str(raw.get("question", "")), + "choices": choices, + "response_value": str(raw.get("response_value", "")), + "slot": str(raw.get("slot", "")), + "lane": lane, + "priority": priority, + "state": state, + "template_key": str(raw.get("template_key", "")), + "template_state": template_state, + "context_summary": str(raw.get("context_summary", "")), + "chat_id": str(raw.get("chat_id", "web") or "web"), + "snooze_until": str(raw.get("snooze_until", "") or ""), + "created_at": str(raw.get("created_at", "")), + "updated_at": str(raw.get("updated_at", "")), + } + + +def _json_script_text(payload: dict[str, Any]) -> str: + return json.dumps(payload, ensure_ascii=False).replace(" Path: + return CARD_TEMPLATES_DIR / template_key + + +def _template_html_path(template_key: str) -> Path: + return _template_dir(template_key) / "template.html" + + +def _template_meta_path(template_key: str) -> Path: + return _template_dir(template_key) / "manifest.json" + + +def _read_template_meta(template_key: str) -> dict[str, Any]: + meta_path = _template_meta_path(template_key) + try: + meta = json.loads(meta_path.read_text(encoding="utf-8")) + return meta if isinstance(meta, dict) else {} + except Exception: + return {} + + +def _materialize_card_content(card: dict[str, Any]) -> str: + if card.get("kind") != "text": + return str(card.get("content", "")) + + template_key = str(card.get("template_key", "")).strip() + if not template_key: + return str(card.get("content", "")) + + html_path = _template_html_path(template_key) + try: + template_html = html_path.read_text(encoding="utf-8") + except Exception: + return ( + '
' + f"Missing template: {html.escape(template_key)}" + "
" + ) + + state_payload = card.get("template_state", {}) + if not isinstance(state_payload, dict): + state_payload = {} + + card_id = html.escape(str(card.get("id", ""))) + safe_template_key = html.escape(template_key) + return ( + f'
' + f'' + f"{template_html}" + "
" + ) + + +def _load_card(card_id: str) -> dict[str, Any] | None: + meta_path = _card_meta_path(card_id) + if meta_path is None or not meta_path.exists(): + return None + try: + raw = json.loads(meta_path.read_text(encoding="utf-8")) + except Exception: + return None + if not isinstance(raw, dict): + return None + + state_path = _card_state_path(card_id) + if state_path is not None and state_path.exists(): + try: + raw_state = json.loads(state_path.read_text(encoding="utf-8")) + except Exception: + raw_state = {} + if isinstance(raw_state, dict): + raw["template_state"] = raw_state + + card = _coerce_card_record(raw) + if card is None: + return None + + card["content"] = _materialize_card_content(card) + return card + + +def _write_card(card: dict[str, Any]) -> dict[str, Any] | None: + normalized = _coerce_card_record(card) + if normalized is None: + return None + + now = datetime.now(timezone.utc).isoformat() + existing = _load_card(normalized["id"]) + if existing is not None: + normalized["created_at"] = existing.get("created_at") or normalized.get("created_at") or now + else: + normalized["created_at"] = normalized.get("created_at") or now + normalized["updated_at"] = normalized.get("updated_at") or now + + instance_dir = _card_instance_dir(normalized["id"]) + meta_path = _card_meta_path(normalized["id"]) + state_path = _card_state_path(normalized["id"]) + if instance_dir is None or meta_path is None or state_path is None: + return None + + instance_dir.mkdir(parents=True, exist_ok=True) + + template_state = normalized.pop("template_state", {}) + meta_path.write_text( + json.dumps(normalized, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + if normalized["kind"] == "text": + state_path.write_text( + json.dumps( + template_state if isinstance(template_state, dict) else {}, + indent=2, + ensure_ascii=False, + ) + + "\n", + encoding="utf-8", + ) + else: + state_path.unlink(missing_ok=True) + + return _load_card(normalized["id"]) + + +def _sort_cards(cards: list[dict[str, Any]]) -> list[dict[str, Any]]: + return sorted( + cards, + key=lambda item: ( + _CARD_LANE_ORDER.get(str(item.get("lane", "context")), 99), + _CARD_STATE_ORDER.get(str(item.get("state", "active")), 99), + -int(item.get("priority", 0) or 0), + str(item.get("updated_at", "")), + str(item.get("created_at", "")), + ), + reverse=False, + ) + + +def _load_cards(chat_id: str | None = None) -> list[dict[str, Any]]: + ensure_card_store_dirs() + cards: list[dict[str, Any]] = [] + now = datetime.now(timezone.utc) + for instance_dir in CARD_INSTANCES_DIR.iterdir(): + if not instance_dir.is_dir(): + continue + card = _load_card(instance_dir.name) + if card is None: + continue + if chat_id is not None and str(card.get("chat_id", "web") or "web") != chat_id: + continue + if card.get("state") == "archived": + continue + snooze_until = _parse_iso_datetime(str(card.get("snooze_until", "") or "")) + if snooze_until is not None and snooze_until > now: + continue + cards.append(card) + return _sort_cards(cards) + + +def _find_card_by_slot(slot: str, *, chat_id: str) -> dict[str, Any] | None: + target_slot = slot.strip() + if not target_slot: + return None + for card in _load_cards(): + if str(card.get("chat_id", "")).strip() != chat_id: + continue + if str(card.get("slot", "")).strip() == target_slot: + return card + return None + + +def _persist_card(card: dict[str, Any]) -> dict[str, Any] | None: + normalized = _coerce_card_record(card) + if normalized is None: + return None + + existing_same_slot = None + slot = str(normalized.get("slot", "")).strip() + chat_id = str(normalized.get("chat_id", "web") or "web") + if slot: + existing_same_slot = _find_card_by_slot(slot, chat_id=chat_id) + + if existing_same_slot is not None and existing_same_slot["id"] != normalized["id"]: + superseded = dict(existing_same_slot) + superseded["state"] = "superseded" + superseded["updated_at"] = datetime.now(timezone.utc).isoformat() + _write_card(superseded) + + return _write_card(normalized) + + +def _delete_card(card_id: str) -> bool: + instance_dir = _card_instance_dir(card_id) + if instance_dir is None or not instance_dir.exists(): + return False + shutil.rmtree(instance_dir, ignore_errors=True) + return True + + +def _delete_cards_for_chat(chat_id: str) -> int: + ensure_card_store_dirs() + removed = 0 + for instance_dir in CARD_INSTANCES_DIR.iterdir(): + if not instance_dir.is_dir(): + continue + card = _load_card(instance_dir.name) + if card is None: + continue + if str(card.get("chat_id", "web") or "web") != chat_id: + continue + if _delete_card(instance_dir.name): + removed += 1 + return removed + + +def _list_templates(limit: int | None = None) -> list[dict[str, Any]]: + ensure_card_store_dirs() + templates: list[dict[str, Any]] = [] + + for template_dir in CARD_TEMPLATES_DIR.iterdir(): + if not template_dir.is_dir(): + continue + key = _normalize_template_key(template_dir.name) + if not key: + continue + + html_path = _template_html_path(key) + if not html_path.exists(): + continue + try: + content = html_path.read_text(encoding="utf-8") + except Exception: + continue + + stat = html_path.stat() + meta = _read_template_meta(key) + if bool(meta.get("deprecated")): + continue + created_at = str( + meta.get("created_at") + or datetime.fromtimestamp(stat.st_ctime, timezone.utc).isoformat() + ) + updated_at = str( + meta.get("updated_at") + or datetime.fromtimestamp(stat.st_mtime, timezone.utc).isoformat() + ) + templates.append( + { + "id": key, + "key": key, + "title": str(meta.get("title", "")), + "content": content, + "notes": str(meta.get("notes", "")), + "example_state": meta.get("example_state", {}), + "created_at": created_at, + "updated_at": updated_at, + "file_url": f"/card-templates/{key}/template.html", + } + ) + + templates.sort(key=lambda item: item["updated_at"], reverse=True) + if limit is not None: + return templates[: max(0, limit)] + return templates + + +def _render_templates_markdown(rows: list[dict[str, Any]]) -> str: + lines = [ + "# Card Templates", + "", + "These are user-approved template layouts for `mcp_display_render_card` cards.", + "Each card instance should provide a `template_key` and a `template_state` JSON object.", + "Use a matching template when the request intent fits.", + "Do not rewrite the HTML layout when an existing template already fits; fill the template_state instead.", + "", + ] + for row in rows: + key = str(row.get("key", "")).strip() or "unnamed" + title = str(row.get("title", "")).strip() or "(untitled)" + notes = str(row.get("notes", "")).strip() or "(no usage notes)" + content = str(row.get("content", "")).strip() + example_state = row.get("example_state", {}) + if len(content) > MAX_TEMPLATE_HTML_CHARS: + content = content[:MAX_TEMPLATE_HTML_CHARS] + "\n" + html_lines = [f" {line}" for line in content.splitlines()] if content else [" "] + state_text = ( + json.dumps(example_state, indent=2, ensure_ascii=False) + if isinstance(example_state, dict) + else "{}" + ) + state_lines = [f" {line}" for line in state_text.splitlines()] + lines.extend( + [ + f"## {key}", + f"- Title: {title}", + f"- Usage: {notes}", + "- Example State:", + *state_lines, + "- HTML:", + *html_lines, + "", + ] + ) + return "\n".join(lines).rstrip() + "\n" + + +def _sync_templates_context_file() -> None: + try: + ensure_card_store_dirs() + rows = _list_templates(limit=MAX_TEMPLATES_IN_PROMPT) + if not rows: + TEMPLATES_CONTEXT_PATH.unlink(missing_ok=True) + return + TEMPLATES_CONTEXT_PATH.parent.mkdir(parents=True, exist_ok=True) + TEMPLATES_CONTEXT_PATH.write_text(_render_templates_markdown(rows), encoding="utf-8") + except Exception: + return + + +normalize_template_key = _normalize_template_key +normalize_card_id = _normalize_card_id +parse_iso_datetime = _parse_iso_datetime +coerce_card_record = _coerce_card_record +template_dir = _template_dir +template_html_path = _template_html_path +template_meta_path = _template_meta_path +read_template_meta = _read_template_meta +load_card = _load_card +load_cards = _load_cards +write_card = _write_card +persist_card = _persist_card +delete_card = _delete_card +delete_cards_for_chat = _delete_cards_for_chat +list_templates = _list_templates +sync_templates_context_file = _sync_templates_context_file +json_script_text = _json_script_text + +__all__ = [ + "CARD_INSTANCES_DIR", + "CARD_TEMPLATES_DIR", + "NANOBOT_WORKSPACE", + "TEMPLATES_CONTEXT_PATH", + "coerce_card_record", + "delete_card", + "delete_cards_for_chat", + "ensure_card_store_dirs", + "json_script_text", + "list_templates", + "load_card", + "load_cards", + "normalize_card_id", + "normalize_template_key", + "parse_iso_datetime", + "persist_card", + "read_template_meta", + "sync_templates_context_file", + "template_dir", + "template_html_path", + "template_meta_path", + "write_card", +] diff --git a/examples/cards/instances/live-calendar-timeline-weather/card.json b/examples/cards/instances/live-calendar-timeline-weather/card.json new file mode 100644 index 0000000..e4ab920 --- /dev/null +++ b/examples/cards/instances/live-calendar-timeline-weather/card.json @@ -0,0 +1,19 @@ +{ + "id": "live-calendar-timeline-weather", + "kind": "text", + "title": "Today Calendar Weather", + "content": "", + "question": "", + "choices": [], + "response_value": "", + "slot": "live-calendar-timeline-weather", + "lane": "context", + "priority": 89, + "state": "active", + "template_key": "calendar-timeline-weather-live", + "context_summary": "", + "chat_id": "web", + "snooze_until": "", + "created_at": "2026-04-02T00:00:00+00:00", + "updated_at": "2026-04-02T00:00:00+00:00" +} diff --git a/examples/cards/instances/live-calendar-timeline-weather/state.json b/examples/cards/instances/live-calendar-timeline-weather/state.json new file mode 100644 index 0000000..ebe10f2 --- /dev/null +++ b/examples/cards/instances/live-calendar-timeline-weather/state.json @@ -0,0 +1,16 @@ +{ + "title": "Today Calendar Weather", + "subtitle": "Family Calendar", + "tool_name": "mcp_home_assistant_calendar_get_events", + "calendar_names": [ + "Family Calendar" + ], + "refresh_ms": 900000, + "min_start_hour": 6, + "max_end_hour": 22, + "min_window_hours": 6, + "slot_height": 24, + "empty_text": "No events for today.", + "weather_tool_name": "exec", + "weather_command": "python3 /home/kacper/nanobot/scripts/card_upcoming_conditions.py --nws-entity weather.korh --uv-entity weather.openweathermap_2 --forecast-type hourly --limit 48" +} diff --git a/examples/cards/instances/live-outdoor-aqi/card.json b/examples/cards/instances/live-outdoor-aqi/card.json new file mode 100644 index 0000000..a7c8a7f --- /dev/null +++ b/examples/cards/instances/live-outdoor-aqi/card.json @@ -0,0 +1,17 @@ +{ + "id": "live-outdoor-aqi", + "kind": "text", + "title": "Outdoor AQI", + "question": "", + "choices": [], + "response_value": "", + "slot": "live-outdoor-aqi", + "lane": "context", + "priority": 24, + "state": "active", + "template_key": "sensor-live", + "context_summary": "", + "chat_id": "web", + "created_at": "2026-04-05T20:40:00-04:00", + "updated_at": "2026-04-05T20:40:00-04:00" +} diff --git a/examples/cards/instances/live-outdoor-aqi/state.json b/examples/cards/instances/live-outdoor-aqi/state.json new file mode 100644 index 0000000..a9df4b5 --- /dev/null +++ b/examples/cards/instances/live-outdoor-aqi/state.json @@ -0,0 +1,17 @@ +{ + "title": "Outdoor AQI", + "subtitle": "Outdoor air quality", + "tool_name": "mcp_home_assistant_GetLiveContext", + "match_name": "Worcester Summer St Air quality index", + "device_class": "aqi", + "unit": "AQI", + "refresh_ms": 300000, + "value_decimals": 0, + "alert_only": true, + "alert_score_elevated": 90, + "alert_score_high": 99, + "thresholds": { + "good_max": 100, + "elevated_max": 150 + } +} diff --git a/examples/cards/instances/live-weather-01545/state.json b/examples/cards/instances/live-weather-01545/state.json index 27db94e..0c81eae 100644 --- a/examples/cards/instances/live-weather-01545/state.json +++ b/examples/cards/instances/live-weather-01545/state.json @@ -4,9 +4,14 @@ "tool_name": "mcp_home_assistant_GetLiveContext", "forecast_tool_name": "exec", "forecast_command": "python3 /home/kacper/nanobot/scripts/card_upcoming_conditions.py --nws-entity weather.korh --uv-entity weather.openweathermap_2 --forecast-type hourly --limit 4", - "provider_prefix": "OpenWeatherMap", - "temperature_name": "OpenWeatherMap Temperature", - "humidity_name": "OpenWeatherMap Humidity", + "provider_prefix": "Worcester Summer St", + "temperature_name": "Worcester Summer St Temperature", + "humidity_name": "Worcester Summer St Humidity", + "uv_name": "OpenWeatherMap UV index", "condition_label": "Weather", - "refresh_ms": 86400000 + "morning_start_hour": 6, + "morning_end_hour": 11, + "morning_score": 84, + "default_score": 38, + "refresh_ms": 300000 } diff --git a/examples/cards/templates/calendar-agenda-live/card.js b/examples/cards/templates/calendar-agenda-live/card.js new file mode 100644 index 0000000..fd875c7 --- /dev/null +++ b/examples/cards/templates/calendar-agenda-live/card.js @@ -0,0 +1,263 @@ +export function mount({ root, state, host }) { + state = state || {}; + const __cleanup = []; + const __setInterval = (...args) => { + const id = window.setInterval(...args); + __cleanup.push(() => window.clearInterval(id)); + return id; + }; + const __setTimeout = (...args) => { + const id = window.setTimeout(...args); + __cleanup.push(() => window.clearTimeout(id)); + return id; + }; + if (!(root instanceof HTMLElement)) return; + + const subtitleEl = root.querySelector('[data-calendar-subtitle]'); + const statusEl = root.querySelector('[data-calendar-status]'); + const rangeEl = root.querySelector('[data-calendar-range]'); + const emptyEl = root.querySelector('[data-calendar-empty]'); + const listEl = root.querySelector('[data-calendar-list]'); + const updatedEl = root.querySelector('[data-calendar-updated]'); + if (!(subtitleEl instanceof HTMLElement) || !(rangeEl instanceof HTMLElement) || !(emptyEl instanceof HTMLElement) || !(listEl instanceof HTMLElement) || !(updatedEl instanceof HTMLElement)) return; + + const subtitle = typeof state.subtitle === 'string' ? state.subtitle : ''; + const configuredToolName = typeof state.tool_name === 'string' ? state.tool_name.trim() : ''; + const calendarNames = Array.isArray(state.calendar_names) + ? state.calendar_names.map((value) => String(value || '').trim()).filter(Boolean) + : []; + const rangeDaysRaw = Number(state.range_days); + const rangeDays = Number.isFinite(rangeDaysRaw) && rangeDaysRaw >= 1 ? Math.min(rangeDaysRaw, 7) : 1; + const maxEventsRaw = Number(state.max_events); + const maxEvents = Number.isFinite(maxEventsRaw) && maxEventsRaw >= 1 ? Math.min(maxEventsRaw, 30) : 8; + const refreshMsRaw = Number(state.refresh_ms); + const refreshMs = Number.isFinite(refreshMsRaw) && refreshMsRaw >= 60000 ? refreshMsRaw : 15 * 60 * 1000; + const emptyText = typeof state.empty_text === 'string' && state.empty_text.trim() ? state.empty_text.trim() : 'No events scheduled.'; + + subtitleEl.textContent = subtitle || (calendarNames.length > 0 ? calendarNames.join(', ') : 'Loading calendars'); + emptyEl.textContent = emptyText; + const updateLiveContent = (snapshot) => { + host.setLiveContent(snapshot); + }; + + const setStatus = (label, color) => { + if (!(statusEl instanceof HTMLElement)) return; + statusEl.textContent = label; + statusEl.style.color = color; + }; + + const normalizeDateValue = (value) => { + if (typeof value === 'string') return value; + if (value && typeof value === 'object') { + if (typeof value.dateTime === 'string') return value.dateTime; + if (typeof value.date === 'string') return value.date; + } + return ''; + }; + + const isAllDay = (start, end) => /^\d{4}-\d{2}-\d{2}$/.test(normalizeDateValue(start)) || /^\d{4}-\d{2}-\d{2}$/.test(normalizeDateValue(end)); + const eventSortKey = (event) => { + const raw = normalizeDateValue(event && event.start); + const time = new Date(raw).getTime(); + return Number.isFinite(time) ? time : Number.MAX_SAFE_INTEGER; + }; + const formatTime = (value) => { + const raw = normalizeDateValue(value); + if (!raw) return '--:--'; + if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) return 'All day'; + const date = new Date(raw); + if (Number.isNaN(date.getTime())) return '--:--'; + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + }; + const formatDay = (value) => { + const raw = normalizeDateValue(value); + if (!raw) return '--'; + const date = /^\d{4}-\d{2}-\d{2}$/.test(raw) ? new Date(`${raw}T00:00:00`) : new Date(raw); + if (Number.isNaN(date.getTime())) return '--'; + return date.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' }); + }; + const formatRange = (start, end) => `${start.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' })} to ${end.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' })}`; + + const extractEvents = (toolResult) => { + if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object') { + const parsed = toolResult.parsed; + if (Array.isArray(parsed.result)) return parsed.result; + } + if (typeof toolResult?.content === 'string') { + try { + const parsed = JSON.parse(toolResult.content); + if (parsed && typeof parsed === 'object' && Array.isArray(parsed.result)) { + return parsed.result; + } + } catch { + return []; + } + } + return []; + }; + + const eventTime = (value) => { + const raw = normalizeDateValue(value); + if (!raw) return Number.MAX_SAFE_INTEGER; + const normalized = /^\d{4}-\d{2}-\d{2}$/.test(raw) ? `${raw}T00:00:00` : raw; + const time = new Date(normalized).getTime(); + return Number.isFinite(time) ? time : Number.MAX_SAFE_INTEGER; + }; + + const resolveToolConfig = async () => { + const fallbackName = configuredToolName || 'mcp_home_assistant_calendar_get_events'; + if (!host.listTools) { + return { name: fallbackName, availableCalendars: calendarNames }; + } + try { + const tools = await host.listTools(); + const tool = Array.isArray(tools) + ? tools.find((item) => /(^|_)calendar_get_events$/i.test(String(item?.name || ''))) + : null; + const enumValues = Array.isArray(tool?.parameters?.properties?.calendar?.enum) + ? tool.parameters.properties.calendar.enum.map((value) => String(value || '').trim()).filter(Boolean) + : []; + return { + name: tool?.name || fallbackName, + availableCalendars: enumValues, + }; + } catch { + return { name: fallbackName, availableCalendars: calendarNames }; + } + }; + + const renderEvents = (events) => { + listEl.innerHTML = ''; + if (!Array.isArray(events) || events.length === 0) { + emptyEl.style.display = 'block'; + return; + } + emptyEl.style.display = 'none'; + + for (const [index, event] of events.slice(0, maxEvents).entries()) { + const item = document.createElement('li'); + item.style.padding = index === 0 ? '10px 0 8px' : '10px 0 8px'; + item.style.borderTop = '1px solid var(--theme-card-neutral-border)'; + + const summary = document.createElement('div'); + summary.style.fontSize = '0.98rem'; + summary.style.lineHeight = '1.3'; + summary.style.fontWeight = '700'; + summary.style.color = 'var(--theme-card-neutral-text)'; + summary.textContent = String(event.summary || '(No title)'); + item.appendChild(summary); + + const timing = document.createElement('div'); + timing.style.marginTop = '4px'; + timing.style.fontSize = '0.9rem'; + timing.style.lineHeight = '1.35'; + timing.style.color = 'var(--theme-card-neutral-subtle)'; + const dayLabel = formatDay(event.start); + const timeLabel = isAllDay(event.start, event.end) ? 'All day' : `${formatTime(event.start)} - ${formatTime(event.end)}`; + timing.textContent = dayLabel === '--' ? timeLabel : `${dayLabel} · ${timeLabel}`; + item.appendChild(timing); + + listEl.appendChild(item); + } + }; + + const refresh = async () => { + setStatus('Refreshing', 'var(--theme-status-muted)'); + const start = new Date(); + start.setHours(0, 0, 0, 0); + const end = new Date(start); + end.setDate(end.getDate() + Math.max(rangeDays - 1, 0)); + end.setHours(23, 59, 59, 999); + rangeEl.textContent = formatRange(start, end); + + try { + const toolConfig = await resolveToolConfig(); + const selectedCalendars = calendarNames.length > 0 ? calendarNames : toolConfig.availableCalendars; + if (!toolConfig.name) throw new Error('Calendar tool unavailable'); + if (!Array.isArray(selectedCalendars) || selectedCalendars.length === 0) { + throw new Error('No calendars configured'); + } + + const resolvedSubtitle = subtitle || selectedCalendars.join(', '); + subtitleEl.textContent = resolvedSubtitle; + + const allEvents = []; + const rangeMode = rangeDays > 1 ? 'week' : 'today'; + const endExclusiveTime = end.getTime() + 1; + for (const calendarName of selectedCalendars) { + const toolResult = await host.callTool(toolConfig.name, { + calendar: calendarName, + range: rangeMode, + }); + const events = extractEvents(toolResult); + for (const event of events) { + const startTime = eventTime(event?.start); + if (startTime < start.getTime() || startTime >= endExclusiveTime) continue; + allEvents.push({ ...event, _calendarName: calendarName }); + } + } + + allEvents.sort((left, right) => eventSortKey(left) - eventSortKey(right)); + renderEvents(allEvents); + const updatedText = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + updatedEl.textContent = updatedText; + const statusLabel = rangeDays > 1 ? `${rangeDays}-day` : 'Today'; + setStatus(statusLabel, 'var(--theme-status-live)'); + const snapshotEvents = allEvents.slice(0, maxEvents).map((event) => { + const dayLabel = formatDay(event.start); + const timeLabel = isAllDay(event.start, event.end) ? 'All day' : `${formatTime(event.start)} - ${formatTime(event.end)}`; + return { + summary: String(event.summary || '(No title)'), + start: normalizeDateValue(event.start) || null, + end: normalizeDateValue(event.end) || null, + day_label: dayLabel === '--' ? null : dayLabel, + time_label: timeLabel, + all_day: isAllDay(event.start, event.end), + }; + }); + updateLiveContent({ + kind: 'calendar_agenda', + subtitle: resolvedSubtitle || null, + tool_name: toolConfig.name, + calendar_names: selectedCalendars, + range_label: rangeEl.textContent || null, + status: statusLabel, + updated_at: updatedText, + event_count: snapshotEvents.length, + events: snapshotEvents, + }); + } catch (error) { + const errorText = String(error); + renderEvents([]); + updatedEl.textContent = errorText; + setStatus('Unavailable', 'var(--theme-status-danger)'); + updateLiveContent({ + kind: 'calendar_agenda', + subtitle: subtitleEl.textContent || null, + tool_name: configuredToolName || 'mcp_home_assistant_calendar_get_events', + calendar_names: calendarNames, + range_label: rangeEl.textContent || null, + status: 'Unavailable', + updated_at: errorText, + event_count: 0, + events: [], + error: errorText, + }); + } + }; + + host.setRefreshHandler(() => { + void refresh(); + }); + void refresh(); + __setInterval(() => { void refresh(); }, refreshMs); + + return { + destroy() { + host.setRefreshHandler(null); + host.setLiveContent(null); + host.clearSelection(); + for (const cleanup of __cleanup.splice(0)) cleanup(); + }, + }; +} diff --git a/examples/cards/templates/calendar-agenda-live/template.html b/examples/cards/templates/calendar-agenda-live/template.html index 2a0a86d..1aad4e5 100644 --- a/examples/cards/templates/calendar-agenda-live/template.html +++ b/examples/cards/templates/calendar-agenda-live/template.html @@ -1,257 +1,10 @@ -
+
-
Loading…
+
Loading…
-
--
-
No events scheduled.
+
--
+
No events scheduled.
-
Updated --
+
Updated --
- diff --git a/examples/cards/templates/calendar-timeline-live/card.js b/examples/cards/templates/calendar-timeline-live/card.js new file mode 100644 index 0000000..c0080c3 --- /dev/null +++ b/examples/cards/templates/calendar-timeline-live/card.js @@ -0,0 +1,584 @@ +export function mount({ root, state, host }) { + state = state || {}; + const __cleanup = []; + const __setInterval = (...args) => { + const id = window.setInterval(...args); + __cleanup.push(() => window.clearInterval(id)); + return id; + }; + const __setTimeout = (...args) => { + const id = window.setTimeout(...args); + __cleanup.push(() => window.clearTimeout(id)); + return id; + }; + if (!(root instanceof HTMLElement)) return; + + const headlineEl = root.querySelector('[data-calendar-headline]'); + const detailEl = root.querySelector('[data-calendar-detail]'); + const allDayWrapEl = root.querySelector('[data-calendar-all-day-wrap]'); + const allDayEl = root.querySelector('[data-calendar-all-day]'); + const emptyEl = root.querySelector('[data-calendar-empty]'); + const timelineShellEl = root.querySelector('[data-calendar-timeline-shell]'); + const timelineEl = root.querySelector('[data-calendar-timeline]'); + + if (!(headlineEl instanceof HTMLElement) || + !(detailEl instanceof HTMLElement) || + !(allDayWrapEl instanceof HTMLElement) || + !(allDayEl instanceof HTMLElement) || + !(emptyEl instanceof HTMLElement) || + !(timelineShellEl instanceof HTMLElement) || + !(timelineEl instanceof HTMLElement)) { + return; + } + + const subtitle = typeof state.subtitle === 'string' ? state.subtitle.trim() : ''; + const configuredToolName = typeof state.tool_name === 'string' ? state.tool_name.trim() : ''; + const calendarNames = Array.isArray(state.calendar_names) + ? state.calendar_names.map((value) => String(value || '').trim()).filter(Boolean) + : []; + const refreshMsRaw = Number(state.refresh_ms); + const refreshMs = Number.isFinite(refreshMsRaw) && refreshMsRaw >= 60000 ? refreshMsRaw : 15 * 60 * 1000; + const minStartHourRaw = Number(state.min_start_hour); + const maxEndHourRaw = Number(state.max_end_hour); + const minWindowHoursRaw = Number(state.min_window_hours); + const slotHeightRaw = Number(state.slot_height); + const minStartHour = Number.isFinite(minStartHourRaw) ? Math.max(0, Math.min(23, Math.round(minStartHourRaw))) : 6; + const maxEndHour = Number.isFinite(maxEndHourRaw) ? Math.max(minStartHour + 1, Math.min(24, Math.round(maxEndHourRaw))) : 22; + const minWindowMinutes = (Number.isFinite(minWindowHoursRaw) ? Math.max(3, Math.min(18, minWindowHoursRaw)) : 6) * 60; + const slotHeight = Number.isFinite(slotHeightRaw) ? Math.max(14, Math.min(24, Math.round(slotHeightRaw))) : 18; + const emptyText = typeof state.empty_text === 'string' && state.empty_text.trim() + ? state.empty_text.trim() + : 'No events for today.'; + + const LABEL_WIDTH = 42; + const TRACK_TOP_PAD = 6; + const TOOL_FALLBACK = configuredToolName || 'mcp_home_assistant_calendar_get_events'; + + let latestEvents = []; + let latestSelectedCalendars = []; + let latestUpdatedAt = ''; + let clockIntervalId = null; + + emptyEl.textContent = emptyText; + + const updateLiveContent = (snapshot) => { + host.setLiveContent(snapshot); + }; + + const normalizeDateValue = (value) => { + if (typeof value === 'string') return value; + if (value && typeof value === 'object') { + if (typeof value.dateTime === 'string') return value.dateTime; + if (typeof value.date === 'string') return value.date; + } + return ''; + }; + + const isAllDay = (start, end) => + /^\d{4}-\d{2}-\d{2}$/.test(normalizeDateValue(start)) || + /^\d{4}-\d{2}-\d{2}$/.test(normalizeDateValue(end)); + + const parseDate = (value, allDay, endOfDay = false) => { + const raw = normalizeDateValue(value); + if (!raw) return null; + const normalized = /^\d{4}-\d{2}-\d{2}$/.test(raw) + ? `${raw}T${endOfDay ? '23:59:59' : '00:00:00'}` + : raw; + const date = new Date(normalized); + return Number.isNaN(date.getTime()) ? null : date; + }; + + const eventBounds = (event) => { + const allDay = isAllDay(event?.start, event?.end); + const startDate = parseDate(event?.start, allDay, false); + const endDate = parseDate(event?.end, allDay, allDay); + if (!startDate) return null; + const start = startDate.getTime(); + let end = endDate ? endDate.getTime() : start + 30 * 60 * 1000; + if (!Number.isFinite(end) || end <= start) end = start + 30 * 60 * 1000; + return { start, end, allDay }; + }; + + const formatClock = (date) => { + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + return `${hours}:${minutes}`; + }; + const formatShortDate = (date) => date.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' }); + + const formatDistance = (ms) => { + const totalMinutes = Math.max(0, Math.round(ms / 60000)); + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + if (hours <= 0) return `${minutes}m`; + if (minutes === 0) return `${hours}h`; + return `${hours}h ${minutes}m`; + }; + + const formatTimeRange = (event) => { + const bounds = eventBounds(event); + if (!bounds) return '--'; + if (bounds.allDay) return 'All day'; + return `${formatClock(new Date(bounds.start))}–${formatClock(new Date(bounds.end))}`; + }; + + const hourLabel = (minutes) => String(Math.floor(minutes / 60)).padStart(2, '0'); + + const minutesIntoDay = (time) => { + const date = new Date(time); + return date.getHours() * 60 + date.getMinutes(); + }; + + const roundDownToHalfHour = (minutes) => Math.floor(minutes / 30) * 30; + const roundUpToHalfHour = (minutes) => Math.ceil(minutes / 30) * 30; + + const computeVisibleWindow = (events) => { + const now = new Date(); + const nowMinutes = now.getHours() * 60 + now.getMinutes(); + const timedEvents = events.filter((event) => !event._allDay); + + if (timedEvents.length === 0) { + let start = roundDownToHalfHour(nowMinutes - 120); + let end = start + minWindowMinutes; + const minBound = minStartHour * 60; + const maxBound = maxEndHour * 60; + if (start < minBound) { + start = minBound; + end = start + minWindowMinutes; + } + if (end > maxBound) { + end = maxBound; + start = Math.max(minBound, end - minWindowMinutes); + } + return { start, end }; + } + + const earliest = Math.min(nowMinutes, ...timedEvents.map((event) => minutesIntoDay(event._start))); + const latest = Math.max(nowMinutes + 30, ...timedEvents.map((event) => minutesIntoDay(event._end))); + + let start = roundDownToHalfHour(earliest - 60); + let end = roundUpToHalfHour(latest + 90); + if (end - start < minWindowMinutes) { + const center = roundDownToHalfHour((earliest + latest) / 2); + start = center - Math.floor(minWindowMinutes / 2); + end = start + minWindowMinutes; + } + + const minBound = minStartHour * 60; + const maxBound = maxEndHour * 60; + + if (start < minBound) { + const shift = minBound - start; + start += shift; + end += shift; + } + if (end > maxBound) { + const shift = end - maxBound; + start -= shift; + end -= shift; + } + start = Math.max(minBound, start); + end = Math.min(maxBound, end); + if (end - start < 120) { + end = Math.min(maxBound, start + 120); + } + return { start, end }; + }; + + const assignColumns = (events) => { + const timed = events.filter((event) => !event._allDay).sort((left, right) => left._start - right._start); + let active = []; + let cluster = []; + let clusterEnd = -Infinity; + let clusterMax = 1; + + const finalizeCluster = () => { + for (const item of cluster) item._columns = clusterMax; + }; + + for (const event of timed) { + if (cluster.length > 0 && event._start >= clusterEnd) { + finalizeCluster(); + active = []; + cluster = []; + clusterEnd = -Infinity; + clusterMax = 1; + } + active = active.filter((item) => item.end > event._start); + const used = new Set(active.map((item) => item.column)); + let column = 0; + while (used.has(column)) column += 1; + event._column = column; + active.push({ end: event._end, column }); + cluster.push(event); + clusterEnd = Math.max(clusterEnd, event._end); + clusterMax = Math.max(clusterMax, active.length, column + 1); + } + + if (cluster.length > 0) finalizeCluster(); + return timed; + }; + + const extractEvents = (toolResult) => { + if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object' && Array.isArray(toolResult.parsed.result)) { + return toolResult.parsed.result; + } + if (typeof toolResult?.content === 'string') { + try { + const parsed = JSON.parse(toolResult.content); + if (parsed && typeof parsed === 'object' && Array.isArray(parsed.result)) return parsed.result; + } catch {} + } + return []; + }; + + const resolveToolConfig = async () => { + if (!host.listTools) { + return { name: TOOL_FALLBACK, availableCalendars: calendarNames }; + } + try { + const tools = await host.listTools(); + const tool = Array.isArray(tools) + ? tools.find((item) => /(^|_)calendar_get_events$/i.test(String(item?.name || ''))) + : null; + const enumValues = Array.isArray(tool?.parameters?.properties?.calendar?.enum) + ? tool.parameters.properties.calendar.enum.map((value) => String(value || '').trim()).filter(Boolean) + : []; + return { + name: tool?.name || TOOL_FALLBACK, + availableCalendars: enumValues, + }; + } catch { + return { name: TOOL_FALLBACK, availableCalendars: calendarNames }; + } + }; + + const computeScore = (events, nowTime) => { + const current = events.find((event) => !event._allDay && event._start <= nowTime && event._end > nowTime); + if (current) return 99; + const next = events.find((event) => !event._allDay && event._start > nowTime); + if (!next) { + if (events.some((event) => event._allDay)) return 74; + return 18; + } + const minutesAway = Math.max(0, Math.round((next._start - nowTime) / 60000)); + let score = 70; + if (minutesAway <= 15) score = 98; + else if (minutesAway <= 30) score = 95; + else if (minutesAway <= 60) score = 92; + else if (minutesAway <= 180) score = 88; + else if (minutesAway <= 360) score = 82; + else score = 76; + score += Math.min(events.length, 3); + return Math.min(100, score); + }; + + const createAllDayChip = (event) => { + const chip = document.createElement('div'); + chip.style.padding = '3px 6px'; + chip.style.border = '1px solid rgba(161, 118, 84, 0.28)'; + chip.style.background = 'rgba(255, 249, 241, 0.94)'; + chip.style.color = '#5e412d'; + chip.style.fontSize = '0.62rem'; + chip.style.lineHeight = '1.2'; + chip.style.fontWeight = '700'; + chip.style.minWidth = '0'; + chip.style.maxWidth = '100%'; + chip.style.overflow = 'hidden'; + chip.style.textOverflow = 'ellipsis'; + chip.style.whiteSpace = 'nowrap'; + chip.textContent = String(event.summary || '(No title)'); + return chip; + }; + + const renderState = () => { + const now = new Date(); + const nowTime = now.getTime(); + const todayLabel = formatShortDate(now); + if (!Array.isArray(latestEvents) || latestEvents.length === 0) { + headlineEl.textContent = todayLabel; + detailEl.textContent = emptyText; + allDayWrapEl.style.display = 'none'; + timelineShellEl.style.display = 'none'; + emptyEl.style.display = 'block'; + updateLiveContent({ + kind: 'calendar_timeline', + subtitle: subtitle || null, + tool_name: TOOL_FALLBACK, + calendar_names: latestSelectedCalendars, + updated_at: latestUpdatedAt || null, + now_label: formatClock(now), + headline: headlineEl.textContent || null, + detail: detailEl.textContent || null, + event_count: 0, + all_day_count: 0, + score: 18, + events: [], + }); + return; + } + + const allDayEvents = latestEvents.filter((event) => event._allDay); + const timedEvents = latestEvents.filter((event) => !event._allDay); + const currentEvent = timedEvents.find((event) => event._start <= nowTime && event._end > nowTime) || null; + const nextEvent = timedEvents.find((event) => event._start > nowTime) || null; + + headlineEl.textContent = todayLabel; + + if (currentEvent) { + detailEl.textContent = ''; + } else if (nextEvent) { + detailEl.textContent = ''; + } else if (allDayEvents.length > 0) { + detailEl.textContent = 'All-day events on your calendar.'; + } else { + detailEl.textContent = 'Your calendar is clear for the rest of the day.'; + } + + const windowRange = computeVisibleWindow(latestEvents); + allDayEl.innerHTML = ''; + allDayWrapEl.style.display = allDayEvents.length > 0 ? 'block' : 'none'; + for (const event of allDayEvents) allDayEl.appendChild(createAllDayChip(event)); + + emptyEl.style.display = 'none'; + timelineShellEl.style.display = timedEvents.length > 0 ? 'block' : 'none'; + timelineEl.innerHTML = ''; + + if (timedEvents.length > 0) { + const slotCount = Math.max(1, Math.round((windowRange.end - windowRange.start) / 30)); + const timelineHeight = TRACK_TOP_PAD + slotCount * slotHeight; + timelineEl.style.height = `${timelineHeight}px`; + + const gridLayer = document.createElement('div'); + gridLayer.style.position = 'absolute'; + gridLayer.style.inset = '0'; + timelineEl.appendChild(gridLayer); + + for (let index = 0; index <= slotCount; index += 1) { + const minutes = windowRange.start + index * 30; + const top = TRACK_TOP_PAD + index * slotHeight; + + const line = document.createElement('div'); + line.style.position = 'absolute'; + line.style.left = `${LABEL_WIDTH}px`; + line.style.right = '0'; + line.style.top = `${top}px`; + line.style.borderTop = minutes % 60 === 0 + ? '1px solid rgba(143, 101, 69, 0.24)' + : '1px dashed rgba(181, 145, 116, 0.18)'; + gridLayer.appendChild(line); + + if (minutes % 60 === 0 && minutes < windowRange.end) { + const label = document.createElement('div'); + label.style.position = 'absolute'; + label.style.left = '0'; + label.style.top = `${Math.max(0, top - 7)}px`; + label.style.width = `${LABEL_WIDTH - 8}px`; + label.style.fontFamily = "'M-1m Code', ui-monospace, Menlo, Consolas, monospace"; + label.style.fontSize = '0.54rem'; + label.style.lineHeight = '1'; + label.style.color = '#8a6248'; + label.style.textAlign = 'right'; + label.style.textTransform = 'uppercase'; + label.style.letterSpacing = '0.05em'; + label.textContent = hourLabel(minutes); + gridLayer.appendChild(label); + } + } + + const eventsLayer = document.createElement('div'); + eventsLayer.style.position = 'absolute'; + eventsLayer.style.left = `${LABEL_WIDTH + 6}px`; + eventsLayer.style.right = '0'; + eventsLayer.style.top = `${TRACK_TOP_PAD}px`; + eventsLayer.style.bottom = '0'; + timelineEl.appendChild(eventsLayer); + + const layoutEvents = assignColumns(timedEvents.map((event) => ({ ...event }))); + for (const event of layoutEvents) { + const offsetStart = Math.max(windowRange.start, minutesIntoDay(event._start)) - windowRange.start; + const offsetEnd = Math.min(windowRange.end, minutesIntoDay(event._end)) - windowRange.start; + const top = Math.max(0, (offsetStart / 30) * slotHeight); + const height = Math.max(16, ((offsetEnd - offsetStart) / 30) * slotHeight - 3); + + const block = document.createElement('div'); + block.style.position = 'absolute'; + block.style.top = `${top}px`; + block.style.height = `${height}px`; + block.style.left = `calc(${(100 / event._columns) * event._column}% + ${event._column * 4}px)`; + block.style.width = `calc(${100 / event._columns}% - 4px)`; + block.style.padding = '5px 6px'; + block.style.border = currentEvent && currentEvent._start === event._start && currentEvent._end === event._end + ? '1px solid rgba(169, 39, 29, 0.38)' + : '1px solid rgba(162, 105, 62, 0.26)'; + block.style.background = currentEvent && currentEvent._start === event._start && currentEvent._end === event._end + ? 'linear-gradient(180deg, rgba(255, 228, 224, 0.98) 0%, rgba(248, 205, 198, 0.94) 100%)' + : 'linear-gradient(180deg, rgba(244, 220, 196, 0.98) 0%, rgba(230, 197, 165, 0.98) 100%)'; + block.style.boxShadow = '0 4px 10px rgba(84, 51, 29, 0.08)'; + block.style.overflow = 'hidden'; + + const title = document.createElement('div'); + title.style.fontFamily = "'IBM Plex Sans Condensed', 'Arial Narrow', sans-serif"; + title.style.fontSize = '0.74rem'; + title.style.lineHeight = '0.98'; + title.style.letterSpacing = '-0.01em'; + title.style.color = '#22140c'; + title.style.fontWeight = '700'; + title.style.whiteSpace = 'nowrap'; + title.style.textOverflow = 'ellipsis'; + title.style.overflow = 'hidden'; + title.textContent = String(event.summary || '(No title)'); + block.appendChild(title); + + eventsLayer.appendChild(block); + } + + if (nowTime >= new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, windowRange.start, 0, 0).getTime() && + nowTime <= new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, windowRange.end, 0, 0).getTime()) { + const nowMinutes = now.getHours() * 60 + now.getMinutes(); + const nowTop = TRACK_TOP_PAD + ((nowMinutes - windowRange.start) / 30) * slotHeight; + + const line = document.createElement('div'); + line.style.position = 'absolute'; + line.style.left = `${LABEL_WIDTH + 6}px`; + line.style.right = '0'; + line.style.top = `${nowTop}px`; + line.style.borderTop = '1.5px solid #cf2f21'; + line.style.transition = 'top 28s linear'; + timelineEl.appendChild(line); + + const dot = document.createElement('div'); + dot.style.position = 'absolute'; + dot.style.left = `${LABEL_WIDTH + 1}px`; + dot.style.top = `${nowTop - 4}px`; + dot.style.width = '8px'; + dot.style.height = '8px'; + dot.style.borderRadius = '999px'; + dot.style.background = '#cf2f21'; + dot.style.boxShadow = '0 0 0 2px rgba(255, 255, 255, 0.95)'; + dot.style.transition = 'top 28s linear'; + timelineEl.appendChild(dot); + + } + } + + updateLiveContent({ + kind: 'calendar_timeline', + subtitle: subtitle || null, + tool_name: TOOL_FALLBACK, + calendar_names: latestSelectedCalendars, + updated_at: latestUpdatedAt || null, + now_label: formatClock(now), + headline: headlineEl.textContent || null, + detail: detailEl.textContent || null, + current_event: currentEvent ? { + summary: String(currentEvent.summary || '(No title)'), + start: normalizeDateValue(currentEvent.start) || null, + end: normalizeDateValue(currentEvent.end) || null, + } : null, + next_event: nextEvent ? { + summary: String(nextEvent.summary || '(No title)'), + start: normalizeDateValue(nextEvent.start) || null, + end: normalizeDateValue(nextEvent.end) || null, + starts_in: formatDistance(nextEvent._start - nowTime), + } : null, + event_count: latestEvents.length, + all_day_count: allDayEvents.length, + score: computeScore(latestEvents, nowTime), + events: latestEvents.map((event) => ({ + summary: String(event.summary || '(No title)'), + start: normalizeDateValue(event.start) || null, + end: normalizeDateValue(event.end) || null, + all_day: Boolean(event._allDay), + })), + }); + }; + + const refresh = async () => { + headlineEl.textContent = 'Loading today…'; + detailEl.textContent = 'Checking your calendar.'; + + try { + const toolConfig = await resolveToolConfig(); + const selectedCalendars = calendarNames.length > 0 ? calendarNames : toolConfig.availableCalendars; + if (!toolConfig.name) throw new Error('Calendar tool unavailable'); + if (!Array.isArray(selectedCalendars) || selectedCalendars.length === 0) { + throw new Error('No calendars configured'); + } + + const allEvents = []; + for (const calendarName of selectedCalendars) { + const toolResult = await host.callTool(toolConfig.name, { + calendar: calendarName, + range: 'today', + }); + const events = extractEvents(toolResult); + for (const event of events) { + const bounds = eventBounds(event); + if (!bounds) continue; + allEvents.push({ + ...event, + _calendarName: calendarName, + _start: bounds.start, + _end: bounds.end, + _allDay: bounds.allDay, + }); + } + } + + allEvents.sort((left, right) => left._start - right._start); + latestEvents = allEvents; + latestSelectedCalendars = selectedCalendars; + latestUpdatedAt = new Date().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); + renderState(); + } catch (error) { + const errorText = String(error); + latestEvents = []; + latestSelectedCalendars = calendarNames; + latestUpdatedAt = errorText; + headlineEl.textContent = formatShortDate(new Date()); + detailEl.textContent = errorText; + allDayWrapEl.style.display = 'none'; + timelineShellEl.style.display = 'none'; + emptyEl.style.display = 'block'; + updateLiveContent({ + kind: 'calendar_timeline', + subtitle: subtitle || null, + tool_name: TOOL_FALLBACK, + calendar_names: latestSelectedCalendars, + updated_at: errorText, + now_label: formatClock(new Date()), + headline: headlineEl.textContent || null, + detail: detailEl.textContent || null, + event_count: 0, + all_day_count: 0, + score: 0, + events: [], + error: errorText, + }); + } + }; + + host.setRefreshHandler(() => { + void refresh(); + }); + + if (clockIntervalId) window.clearInterval(clockIntervalId); + clockIntervalId = __setInterval(() => { + renderState(); + }, 30000); + + void refresh(); + __setInterval(() => { + void refresh(); + }, refreshMs); + + return { + destroy() { + host.setRefreshHandler(null); + host.setLiveContent(null); + host.clearSelection(); + for (const cleanup of __cleanup.splice(0)) cleanup(); + }, + }; +} diff --git a/examples/cards/templates/calendar-timeline-live/manifest.json b/examples/cards/templates/calendar-timeline-live/manifest.json new file mode 100644 index 0000000..a28b227 --- /dev/null +++ b/examples/cards/templates/calendar-timeline-live/manifest.json @@ -0,0 +1,20 @@ +{ + "key": "calendar-timeline-live", + "title": "Today Calendar Timeline", + "notes": "Today-only Home Assistant calendar timeline with half-hour grid, current time marker, and next-event distance. Fill template_state with subtitle, tool_name (defaults to calendar_get_events), optional calendar_names, refresh_ms, min_start_hour, max_end_hour, min_window_hours, slot_height, and empty_text.", + "example_state": { + "subtitle": "Family Calendar", + "tool_name": "mcp_home_assistant_calendar_get_events", + "calendar_names": [ + "Family Calendar" + ], + "refresh_ms": 900000, + "min_start_hour": 6, + "max_end_hour": 22, + "min_window_hours": 6, + "slot_height": 24, + "empty_text": "No events for today." + }, + "created_at": "2026-04-02T00:00:00+00:00", + "updated_at": "2026-04-02T00:00:00+00:00" +} diff --git a/examples/cards/templates/calendar-timeline-live/template.html b/examples/cards/templates/calendar-timeline-live/template.html new file mode 100644 index 0000000..518ed95 --- /dev/null +++ b/examples/cards/templates/calendar-timeline-live/template.html @@ -0,0 +1,38 @@ + +
+
Today Calendar
+ +
+
+
Loading today…
+
Checking your calendar.
+
+
+ +
+
All day
+
+
+ +
No events for today.
+ +
+
+
+
diff --git a/examples/cards/templates/calendar-timeline-weather-live/card.js b/examples/cards/templates/calendar-timeline-weather-live/card.js new file mode 100644 index 0000000..d604897 --- /dev/null +++ b/examples/cards/templates/calendar-timeline-weather-live/card.js @@ -0,0 +1,778 @@ +export function mount({ root, state, host }) { + state = state || {}; + const __cleanup = []; + const __setInterval = (...args) => { + const id = window.setInterval(...args); + __cleanup.push(() => window.clearInterval(id)); + return id; + }; + const __setTimeout = (...args) => { + const id = window.setTimeout(...args); + __cleanup.push(() => window.clearTimeout(id)); + return id; + }; + if (!(root instanceof HTMLElement)) return; + + const headlineEl = root.querySelector('[data-calendar-headline]'); + const detailEl = root.querySelector('[data-calendar-detail]'); + const allDayWrapEl = root.querySelector('[data-calendar-all-day-wrap]'); + const allDayEl = root.querySelector('[data-calendar-all-day]'); + const emptyEl = root.querySelector('[data-calendar-empty]'); + const timelineShellEl = root.querySelector('[data-calendar-timeline-shell]'); + const weatherScaleEl = root.querySelector('[data-calendar-weather-scale]'); + const weatherLowEl = root.querySelector('[data-calendar-weather-low]'); + const weatherHighEl = root.querySelector('[data-calendar-weather-high]'); + const timelineEl = root.querySelector('[data-calendar-timeline]'); + + if (!(headlineEl instanceof HTMLElement) || + !(detailEl instanceof HTMLElement) || + !(allDayWrapEl instanceof HTMLElement) || + !(allDayEl instanceof HTMLElement) || + !(emptyEl instanceof HTMLElement) || + !(timelineShellEl instanceof HTMLElement) || + !(weatherScaleEl instanceof HTMLElement) || + !(weatherLowEl instanceof HTMLElement) || + !(weatherHighEl instanceof HTMLElement) || + !(timelineEl instanceof HTMLElement)) { + return; + } + + const subtitle = typeof state.subtitle === 'string' ? state.subtitle.trim() : ''; + const configuredToolName = typeof state.tool_name === 'string' ? state.tool_name.trim() : ''; + const calendarNames = Array.isArray(state.calendar_names) + ? state.calendar_names.map((value) => String(value || '').trim()).filter(Boolean) + : []; + const refreshMsRaw = Number(state.refresh_ms); + const refreshMs = Number.isFinite(refreshMsRaw) && refreshMsRaw >= 60000 ? refreshMsRaw : 15 * 60 * 1000; + const configuredWeatherToolName = typeof state.weather_tool_name === 'string' ? state.weather_tool_name.trim() : 'exec'; + const weatherCommand = typeof state.weather_command === 'string' ? state.weather_command.trim() : ''; + const minStartHourRaw = Number(state.min_start_hour); + const maxEndHourRaw = Number(state.max_end_hour); + const minWindowHoursRaw = Number(state.min_window_hours); + const slotHeightRaw = Number(state.slot_height); + const minStartHour = Number.isFinite(minStartHourRaw) ? Math.max(0, Math.min(23, Math.round(minStartHourRaw))) : 6; + const maxEndHour = Number.isFinite(maxEndHourRaw) ? Math.max(minStartHour + 1, Math.min(24, Math.round(maxEndHourRaw))) : 22; + const minWindowMinutes = (Number.isFinite(minWindowHoursRaw) ? Math.max(3, Math.min(18, minWindowHoursRaw)) : 6) * 60; + const slotHeight = Number.isFinite(slotHeightRaw) ? Math.max(14, Math.min(24, Math.round(slotHeightRaw))) : 18; + const emptyText = typeof state.empty_text === 'string' && state.empty_text.trim() + ? state.empty_text.trim() + : 'No events for today.'; + + const LABEL_WIDTH = 42; + const TRACK_TOP_PAD = 6; + const TOOL_FALLBACK = configuredToolName || 'mcp_home_assistant_calendar_get_events'; + + let latestEvents = []; + let latestSelectedCalendars = []; + let latestUpdatedAt = ''; + let latestWeatherPoints = []; + let latestWeatherRange = null; + let clockIntervalId = null; + + emptyEl.textContent = emptyText; + + const updateLiveContent = (snapshot) => { + host.setLiveContent(snapshot); + }; + + const normalizeDateValue = (value) => { + if (typeof value === 'string') return value; + if (value && typeof value === 'object') { + if (typeof value.dateTime === 'string') return value.dateTime; + if (typeof value.date === 'string') return value.date; + } + return ''; + }; + + const isAllDay = (start, end) => + /^\d{4}-\d{2}-\d{2}$/.test(normalizeDateValue(start)) || + /^\d{4}-\d{2}-\d{2}$/.test(normalizeDateValue(end)); + + const parseDate = (value, allDay, endOfDay = false) => { + const raw = normalizeDateValue(value); + if (!raw) return null; + const normalized = /^\d{4}-\d{2}-\d{2}$/.test(raw) + ? `${raw}T${endOfDay ? '23:59:59' : '00:00:00'}` + : raw; + const date = new Date(normalized); + return Number.isNaN(date.getTime()) ? null : date; + }; + + const eventBounds = (event) => { + const allDay = isAllDay(event?.start, event?.end); + const startDate = parseDate(event?.start, allDay, false); + const endDate = parseDate(event?.end, allDay, allDay); + if (!startDate) return null; + const start = startDate.getTime(); + let end = endDate ? endDate.getTime() : start + 30 * 60 * 1000; + if (!Number.isFinite(end) || end <= start) end = start + 30 * 60 * 1000; + return { start, end, allDay }; + }; + + const formatClock = (date) => { + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + return `${hours}:${minutes}`; + }; + const formatShortDate = (date) => date.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' }); + + const formatDistance = (ms) => { + const totalMinutes = Math.max(0, Math.round(ms / 60000)); + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + if (hours <= 0) return `${minutes}m`; + if (minutes === 0) return `${hours}h`; + return `${hours}h ${minutes}m`; + }; + + const formatTimeRange = (event) => { + const bounds = eventBounds(event); + if (!bounds) return '--'; + if (bounds.allDay) return 'All day'; + return `${formatClock(new Date(bounds.start))}–${formatClock(new Date(bounds.end))}`; + }; + + const hourLabel = (minutes) => String(Math.floor(minutes / 60)).padStart(2, '0'); + + const minutesIntoDay = (time) => { + const date = new Date(time); + return date.getHours() * 60 + date.getMinutes(); + }; + + const roundDownToHalfHour = (minutes) => Math.floor(minutes / 30) * 30; + const roundUpToHalfHour = (minutes) => Math.ceil(minutes / 30) * 30; + + const computeWeatherWindow = () => { + if (!Array.isArray(latestWeatherPoints) || latestWeatherPoints.length < 2) return null; + const sorted = latestWeatherPoints + .map((point) => minutesIntoDay(point.time)) + .filter((minutes) => Number.isFinite(minutes)) + .sort((left, right) => left - right); + if (sorted.length < 2) return null; + const start = roundDownToHalfHour(sorted[0]); + const end = roundUpToHalfHour(sorted[sorted.length - 1]); + if (end <= start) return null; + return { start, end }; + }; + + const computeVisibleWindow = (events) => { + const now = new Date(); + const nowMinutes = now.getHours() * 60 + now.getMinutes(); + const timedEvents = events.filter((event) => !event._allDay); + const weatherWindow = computeWeatherWindow(); + + if (timedEvents.length === 0) { + let start = roundDownToHalfHour(nowMinutes - 120); + let end = start + minWindowMinutes; + const minBound = minStartHour * 60; + const maxBound = maxEndHour * 60; + if (start < minBound) { + start = minBound; + end = start + minWindowMinutes; + } + if (end > maxBound) { + end = maxBound; + start = Math.max(minBound, end - minWindowMinutes); + } + if (weatherWindow) { + start = Math.max(start, weatherWindow.start); + end = Math.min(end, weatherWindow.end); + if (end <= start) return { start: weatherWindow.start, end: weatherWindow.end }; + } + return { start, end }; + } + + const earliest = Math.min(nowMinutes, ...timedEvents.map((event) => minutesIntoDay(event._start))); + const latest = Math.max(nowMinutes + 30, ...timedEvents.map((event) => minutesIntoDay(event._end))); + + let start = roundDownToHalfHour(earliest - 60); + let end = roundUpToHalfHour(latest + 90); + if (end - start < minWindowMinutes) { + const center = roundDownToHalfHour((earliest + latest) / 2); + start = center - Math.floor(minWindowMinutes / 2); + end = start + minWindowMinutes; + } + + const minBound = minStartHour * 60; + const maxBound = maxEndHour * 60; + + if (start < minBound) { + const shift = minBound - start; + start += shift; + end += shift; + } + if (end > maxBound) { + const shift = end - maxBound; + start -= shift; + end -= shift; + } + start = Math.max(minBound, start); + end = Math.min(maxBound, end); + if (end - start < 120) { + end = Math.min(maxBound, start + 120); + } + if (weatherWindow) { + start = Math.max(start, weatherWindow.start); + end = Math.min(end, weatherWindow.end); + if (end <= start) return { start: weatherWindow.start, end: weatherWindow.end }; + } + return { start, end }; + }; + + const assignColumns = (events) => { + const timed = events.filter((event) => !event._allDay).sort((left, right) => left._start - right._start); + let active = []; + let cluster = []; + let clusterEnd = -Infinity; + let clusterMax = 1; + + const finalizeCluster = () => { + for (const item of cluster) item._columns = clusterMax; + }; + + for (const event of timed) { + if (cluster.length > 0 && event._start >= clusterEnd) { + finalizeCluster(); + active = []; + cluster = []; + clusterEnd = -Infinity; + clusterMax = 1; + } + active = active.filter((item) => item.end > event._start); + const used = new Set(active.map((item) => item.column)); + let column = 0; + while (used.has(column)) column += 1; + event._column = column; + active.push({ end: event._end, column }); + cluster.push(event); + clusterEnd = Math.max(clusterEnd, event._end); + clusterMax = Math.max(clusterMax, active.length, column + 1); + } + + if (cluster.length > 0) finalizeCluster(); + return timed; + }; + + const extractEvents = (toolResult) => { + if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object' && Array.isArray(toolResult.parsed.result)) { + return toolResult.parsed.result; + } + if (typeof toolResult?.content === 'string') { + try { + const parsed = JSON.parse(toolResult.content); + if (parsed && typeof parsed === 'object' && Array.isArray(parsed.result)) return parsed.result; + } catch {} + } + return []; + }; + + const stripExecFooter = (value) => String(value || '').replace(/\n+\s*Exit code:\s*\d+\s*$/i, '').trim(); + + const extractExecJson = (toolResult) => { + const text = stripExecFooter(toolResult?.content); + if (!text) return null; + try { + return JSON.parse(text); + } catch { + return null; + } + }; + + const forecastTime = (entry) => { + const time = new Date(String(entry?.datetime || '')).getTime(); + return Number.isFinite(time) ? time : Number.NaN; + }; + + const extractWeatherPoints = (payload) => { + const rows = Array.isArray(payload?.nws?.forecast) ? payload.nws.forecast : []; + const today = new Date(); + const dayStart = new Date(today.getFullYear(), today.getMonth(), today.getDate(), 0, 0, 0, 0).getTime(); + const dayEnd = dayStart + 24 * 60 * 60 * 1000; + return rows + .map((entry) => { + const time = forecastTime(entry); + const temp = Number(entry?.temperature); + if (!Number.isFinite(time) || !Number.isFinite(temp)) return null; + if (time < dayStart || time >= dayEnd) return null; + return { + time, + temp, + }; + }) + .filter(Boolean); + }; + + const resolveWeatherForecast = async () => { + if (!weatherCommand) return []; + const toolResult = await host.callTool(configuredWeatherToolName || 'exec', { + command: weatherCommand, + max_output_chars: 200000, + }); + const payload = extractExecJson(toolResult); + if (!payload || typeof payload !== 'object') return []; + return extractWeatherPoints(payload); + }; + + const resolveToolConfig = async () => { + if (!host.listTools) { + return { name: TOOL_FALLBACK, availableCalendars: calendarNames }; + } + try { + const tools = await host.listTools(); + const tool = Array.isArray(tools) + ? tools.find((item) => /(^|_)calendar_get_events$/i.test(String(item?.name || ''))) + : null; + const enumValues = Array.isArray(tool?.parameters?.properties?.calendar?.enum) + ? tool.parameters.properties.calendar.enum.map((value) => String(value || '').trim()).filter(Boolean) + : []; + return { + name: tool?.name || TOOL_FALLBACK, + availableCalendars: enumValues, + }; + } catch { + return { name: TOOL_FALLBACK, availableCalendars: calendarNames }; + } + }; + + const computeScore = (events, nowTime) => { + const current = events.find((event) => !event._allDay && event._start <= nowTime && event._end > nowTime); + if (current) return 99; + const next = events.find((event) => !event._allDay && event._start > nowTime); + if (!next) { + if (events.some((event) => event._allDay)) return 74; + return 18; + } + const minutesAway = Math.max(0, Math.round((next._start - nowTime) / 60000)); + let score = 70; + if (minutesAway <= 15) score = 98; + else if (minutesAway <= 30) score = 95; + else if (minutesAway <= 60) score = 92; + else if (minutesAway <= 180) score = 88; + else if (minutesAway <= 360) score = 82; + else score = 76; + score += Math.min(events.length, 3); + return Math.min(100, score); + }; + + const createAllDayChip = (event) => { + const chip = document.createElement('div'); + chip.style.padding = '3px 6px'; + chip.style.border = '1px solid rgba(161, 118, 84, 0.28)'; + chip.style.background = 'rgba(255, 249, 241, 0.94)'; + chip.style.color = '#5e412d'; + chip.style.fontSize = '0.62rem'; + chip.style.lineHeight = '1.2'; + chip.style.fontWeight = '700'; + chip.style.minWidth = '0'; + chip.style.maxWidth = '100%'; + chip.style.overflow = 'hidden'; + chip.style.textOverflow = 'ellipsis'; + chip.style.whiteSpace = 'nowrap'; + chip.textContent = String(event.summary || '(No title)'); + return chip; + }; + + const computeWeatherRange = (points) => { + if (!Array.isArray(points) || points.length === 0) return null; + let low = Number.POSITIVE_INFINITY; + let high = Number.NEGATIVE_INFINITY; + for (const point of points) { + low = Math.min(low, point.temp); + high = Math.max(high, point.temp); + } + if (!Number.isFinite(low) || !Number.isFinite(high)) return null; + if (low === high) high = low + 1; + return { low, high }; + }; + + const renderWeatherGraph = (windowRange, timelineHeight) => { + weatherScaleEl.style.display = 'none'; + weatherLowEl.textContent = '--'; + weatherHighEl.textContent = '--'; + + if (!Array.isArray(latestWeatherPoints) || latestWeatherPoints.length === 0 || !latestWeatherRange) return; + + const visiblePoints = latestWeatherPoints.filter((point) => { + const minutes = minutesIntoDay(point.time); + return minutes >= windowRange.start && minutes <= windowRange.end; + }); + if (visiblePoints.length < 2) return; + + weatherScaleEl.style.display = 'flex'; + weatherLowEl.textContent = `${Math.round(latestWeatherRange.low)}°`; + weatherHighEl.textContent = `${Math.round(latestWeatherRange.high)}°`; + + const timelineWidth = timelineEl.getBoundingClientRect().width; + const overlayWidth = Math.max(1, timelineWidth - (LABEL_WIDTH + 6)); + + const overlay = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + overlay.setAttribute('viewBox', `0 0 ${overlayWidth} ${timelineHeight}`); + overlay.style.position = 'absolute'; + overlay.style.left = `${LABEL_WIDTH + 6}px`; + overlay.style.top = '0'; + overlay.style.height = `${timelineHeight}px`; + overlay.style.width = `${overlayWidth}px`; + overlay.style.pointerEvents = 'none'; + overlay.style.opacity = '0.85'; + + const toPoint = (point) => { + const minutes = minutesIntoDay(point.time); + const y = TRACK_TOP_PAD + ((minutes - windowRange.start) / 30) * slotHeight; + const x = ((point.temp - latestWeatherRange.low) / (latestWeatherRange.high - latestWeatherRange.low)) * overlayWidth; + return { x, y }; + }; + + const coords = visiblePoints.map(toPoint); + const buildSmoothPath = (points) => { + if (points.length === 0) return ''; + if (points.length === 1) return `M ${points[0].x.toFixed(2)} ${points[0].y.toFixed(2)}`; + let d = `M ${points[0].x.toFixed(2)} ${points[0].y.toFixed(2)}`; + for (let index = 1; index < points.length - 1; index += 1) { + const point = points[index]; + const next = points[index + 1]; + const midX = (point.x + next.x) / 2; + const midY = (point.y + next.y) / 2; + d += ` Q ${point.x.toFixed(2)} ${point.y.toFixed(2)} ${midX.toFixed(2)} ${midY.toFixed(2)}`; + } + const penultimate = points[points.length - 2]; + const last = points[points.length - 1]; + d += ` Q ${penultimate.x.toFixed(2)} ${penultimate.y.toFixed(2)} ${last.x.toFixed(2)} ${last.y.toFixed(2)}`; + return d; + }; + + const line = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + line.setAttribute('d', buildSmoothPath(coords)); + line.setAttribute('fill', 'none'); + line.setAttribute('stroke', 'rgba(184, 110, 58, 0.34)'); + line.setAttribute('stroke-width', '1.8'); + line.setAttribute('stroke-linecap', 'round'); + line.setAttribute('stroke-linejoin', 'round'); + line.setAttribute('vector-effect', 'non-scaling-stroke'); + overlay.appendChild(line); + + for (const point of coords) { + const dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); + dot.setAttribute('cx', point.x.toFixed(2)); + dot.setAttribute('cy', point.y.toFixed(2)); + dot.setAttribute('r', '1.9'); + dot.setAttribute('fill', 'rgba(184, 110, 58, 0.34)'); + dot.setAttribute('stroke', 'rgba(226, 188, 156, 0.62)'); + dot.setAttribute('stroke-width', '0.55'); + overlay.appendChild(dot); + } + + timelineEl.appendChild(overlay); + }; + + const renderState = () => { + const now = new Date(); + const nowTime = now.getTime(); + const todayLabel = formatShortDate(now); + if (!Array.isArray(latestEvents) || latestEvents.length === 0) { + headlineEl.textContent = todayLabel; + detailEl.textContent = emptyText; + allDayWrapEl.style.display = 'none'; + timelineShellEl.style.display = 'none'; + emptyEl.style.display = 'block'; + updateLiveContent({ + kind: 'calendar_timeline', + subtitle: subtitle || null, + tool_name: TOOL_FALLBACK, + calendar_names: latestSelectedCalendars, + updated_at: latestUpdatedAt || null, + now_label: formatClock(now), + headline: headlineEl.textContent || null, + detail: detailEl.textContent || null, + weather_temperature_range: latestWeatherRange ? { + low: latestWeatherRange.low, + high: latestWeatherRange.high, + } : null, + event_count: 0, + all_day_count: 0, + score: 18, + events: [], + }); + return; + } + + const allDayEvents = latestEvents.filter((event) => event._allDay); + const timedEvents = latestEvents.filter((event) => !event._allDay); + const currentEvent = timedEvents.find((event) => event._start <= nowTime && event._end > nowTime) || null; + const nextEvent = timedEvents.find((event) => event._start > nowTime) || null; + + headlineEl.textContent = todayLabel; + + if (currentEvent) { + detailEl.textContent = ''; + } else if (nextEvent) { + detailEl.textContent = ''; + } else if (allDayEvents.length > 0) { + detailEl.textContent = 'All-day events on your calendar.'; + } else { + detailEl.textContent = 'Your calendar is clear for the rest of the day.'; + } + + const windowRange = computeVisibleWindow(latestEvents); + allDayEl.innerHTML = ''; + allDayWrapEl.style.display = allDayEvents.length > 0 ? 'block' : 'none'; + for (const event of allDayEvents) allDayEl.appendChild(createAllDayChip(event)); + + emptyEl.style.display = 'none'; + timelineShellEl.style.display = timedEvents.length > 0 ? 'block' : 'none'; + timelineEl.innerHTML = ''; + + if (timedEvents.length > 0) { + const slotCount = Math.max(1, Math.round((windowRange.end - windowRange.start) / 30)); + const timelineHeight = TRACK_TOP_PAD + slotCount * slotHeight; + timelineEl.style.height = `${timelineHeight}px`; + + const gridLayer = document.createElement('div'); + gridLayer.style.position = 'absolute'; + gridLayer.style.inset = '0'; + timelineEl.appendChild(gridLayer); + + for (let index = 0; index <= slotCount; index += 1) { + const minutes = windowRange.start + index * 30; + const top = TRACK_TOP_PAD + index * slotHeight; + + const line = document.createElement('div'); + line.style.position = 'absolute'; + line.style.left = `${LABEL_WIDTH}px`; + line.style.right = '0'; + line.style.top = `${top}px`; + line.style.borderTop = minutes % 60 === 0 + ? '1px solid rgba(143, 101, 69, 0.24)' + : '1px dashed rgba(181, 145, 116, 0.18)'; + gridLayer.appendChild(line); + + if (minutes % 60 === 0 && minutes < windowRange.end) { + const label = document.createElement('div'); + label.style.position = 'absolute'; + label.style.left = '0'; + label.style.top = `${Math.max(0, top - 7)}px`; + label.style.width = `${LABEL_WIDTH - 8}px`; + label.style.fontFamily = "'M-1m Code', ui-monospace, Menlo, Consolas, monospace"; + label.style.fontSize = '0.54rem'; + label.style.lineHeight = '1'; + label.style.color = '#8a6248'; + label.style.textAlign = 'right'; + label.style.textTransform = 'uppercase'; + label.style.letterSpacing = '0.05em'; + label.textContent = hourLabel(minutes); + gridLayer.appendChild(label); + } + } + + renderWeatherGraph(windowRange, timelineHeight); + + const eventsLayer = document.createElement('div'); + eventsLayer.style.position = 'absolute'; + eventsLayer.style.left = `${LABEL_WIDTH + 6}px`; + eventsLayer.style.right = '0'; + eventsLayer.style.top = `${TRACK_TOP_PAD}px`; + eventsLayer.style.bottom = '0'; + timelineEl.appendChild(eventsLayer); + + const layoutEvents = assignColumns(timedEvents.map((event) => ({ ...event }))); + for (const event of layoutEvents) { + const offsetStart = Math.max(windowRange.start, minutesIntoDay(event._start)) - windowRange.start; + const offsetEnd = Math.min(windowRange.end, minutesIntoDay(event._end)) - windowRange.start; + const top = Math.max(0, (offsetStart / 30) * slotHeight); + const height = Math.max(16, ((offsetEnd - offsetStart) / 30) * slotHeight - 3); + + const block = document.createElement('div'); + block.style.position = 'absolute'; + block.style.top = `${top}px`; + block.style.height = `${height}px`; + block.style.left = `calc(${(100 / event._columns) * event._column}% + ${event._column * 4}px)`; + block.style.width = `calc(${100 / event._columns}% - 4px)`; + block.style.padding = '5px 6px'; + block.style.border = currentEvent && currentEvent._start === event._start && currentEvent._end === event._end + ? '1px solid rgba(169, 39, 29, 0.38)' + : '1px solid rgba(162, 105, 62, 0.26)'; + block.style.background = currentEvent && currentEvent._start === event._start && currentEvent._end === event._end + ? 'linear-gradient(180deg, rgba(255, 228, 224, 0.98) 0%, rgba(248, 205, 198, 0.94) 100%)' + : 'linear-gradient(180deg, rgba(244, 220, 196, 0.98) 0%, rgba(230, 197, 165, 0.98) 100%)'; + block.style.boxShadow = '0 4px 10px rgba(84, 51, 29, 0.08)'; + block.style.overflow = 'hidden'; + + const title = document.createElement('div'); + title.style.fontFamily = "'IBM Plex Sans Condensed', 'Arial Narrow', sans-serif"; + title.style.fontSize = '0.74rem'; + title.style.lineHeight = '0.98'; + title.style.letterSpacing = '-0.01em'; + title.style.color = '#22140c'; + title.style.fontWeight = '700'; + title.style.whiteSpace = 'nowrap'; + title.style.textOverflow = 'ellipsis'; + title.style.overflow = 'hidden'; + title.textContent = String(event.summary || '(No title)'); + block.appendChild(title); + + eventsLayer.appendChild(block); + } + + if (nowTime >= new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, windowRange.start, 0, 0).getTime() && + nowTime <= new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, windowRange.end, 0, 0).getTime()) { + const nowMinutes = now.getHours() * 60 + now.getMinutes(); + const nowTop = TRACK_TOP_PAD + ((nowMinutes - windowRange.start) / 30) * slotHeight; + + const line = document.createElement('div'); + line.style.position = 'absolute'; + line.style.left = `${LABEL_WIDTH + 6}px`; + line.style.right = '0'; + line.style.top = `${nowTop}px`; + line.style.borderTop = '1.5px solid #cf2f21'; + line.style.transition = 'top 28s linear'; + timelineEl.appendChild(line); + + const dot = document.createElement('div'); + dot.style.position = 'absolute'; + dot.style.left = `${LABEL_WIDTH + 1}px`; + dot.style.top = `${nowTop - 4}px`; + dot.style.width = '8px'; + dot.style.height = '8px'; + dot.style.borderRadius = '999px'; + dot.style.background = '#cf2f21'; + dot.style.boxShadow = '0 0 0 2px rgba(255, 255, 255, 0.95)'; + dot.style.transition = 'top 28s linear'; + timelineEl.appendChild(dot); + + } + } + + updateLiveContent({ + kind: 'calendar_timeline', + subtitle: subtitle || null, + tool_name: TOOL_FALLBACK, + calendar_names: latestSelectedCalendars, + updated_at: latestUpdatedAt || null, + now_label: formatClock(now), + headline: headlineEl.textContent || null, + detail: detailEl.textContent || null, + weather_temperature_range: latestWeatherRange ? { + low: latestWeatherRange.low, + high: latestWeatherRange.high, + } : null, + current_event: currentEvent ? { + summary: String(currentEvent.summary || '(No title)'), + start: normalizeDateValue(currentEvent.start) || null, + end: normalizeDateValue(currentEvent.end) || null, + } : null, + next_event: nextEvent ? { + summary: String(nextEvent.summary || '(No title)'), + start: normalizeDateValue(nextEvent.start) || null, + end: normalizeDateValue(nextEvent.end) || null, + starts_in: formatDistance(nextEvent._start - nowTime), + } : null, + event_count: latestEvents.length, + all_day_count: allDayEvents.length, + score: computeScore(latestEvents, nowTime), + events: latestEvents.map((event) => ({ + summary: String(event.summary || '(No title)'), + start: normalizeDateValue(event.start) || null, + end: normalizeDateValue(event.end) || null, + all_day: Boolean(event._allDay), + })), + }); + }; + + const refresh = async () => { + headlineEl.textContent = 'Loading today…'; + detailEl.textContent = 'Checking your calendar.'; + + try { + const toolConfig = await resolveToolConfig(); + const selectedCalendars = calendarNames.length > 0 ? calendarNames : toolConfig.availableCalendars; + if (!toolConfig.name) throw new Error('Calendar tool unavailable'); + if (!Array.isArray(selectedCalendars) || selectedCalendars.length === 0) { + throw new Error('No calendars configured'); + } + + const [weatherPoints, allEvents] = await Promise.all([ + resolveWeatherForecast().catch(() => []), + (async () => { + const allEvents = []; + for (const calendarName of selectedCalendars) { + const toolResult = await host.callTool(toolConfig.name, { + calendar: calendarName, + range: 'today', + }); + const events = extractEvents(toolResult); + for (const event of events) { + const bounds = eventBounds(event); + if (!bounds) continue; + allEvents.push({ + ...event, + _calendarName: calendarName, + _start: bounds.start, + _end: bounds.end, + _allDay: bounds.allDay, + }); + } + } + return allEvents; + })(), + ]); + + allEvents.sort((left, right) => left._start - right._start); + latestWeatherPoints = weatherPoints; + latestWeatherRange = computeWeatherRange(weatherPoints); + latestEvents = allEvents; + latestSelectedCalendars = selectedCalendars; + latestUpdatedAt = new Date().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); + renderState(); + } catch (error) { + const errorText = String(error); + latestWeatherPoints = []; + latestWeatherRange = null; + latestEvents = []; + latestSelectedCalendars = calendarNames; + latestUpdatedAt = errorText; + headlineEl.textContent = formatShortDate(new Date()); + detailEl.textContent = errorText; + allDayWrapEl.style.display = 'none'; + timelineShellEl.style.display = 'none'; + emptyEl.style.display = 'block'; + updateLiveContent({ + kind: 'calendar_timeline', + subtitle: subtitle || null, + tool_name: TOOL_FALLBACK, + calendar_names: latestSelectedCalendars, + updated_at: errorText, + now_label: formatClock(new Date()), + headline: headlineEl.textContent || null, + detail: detailEl.textContent || null, + weather_temperature_range: null, + event_count: 0, + all_day_count: 0, + score: 0, + events: [], + error: errorText, + }); + } + }; + + host.setRefreshHandler(() => { + void refresh(); + }); + + if (clockIntervalId) window.clearInterval(clockIntervalId); + clockIntervalId = __setInterval(() => { + renderState(); + }, 30000); + + void refresh(); + __setInterval(() => { + void refresh(); + }, refreshMs); + + return { + destroy() { + host.setRefreshHandler(null); + host.setLiveContent(null); + host.clearSelection(); + for (const cleanup of __cleanup.splice(0)) cleanup(); + }, + }; +} diff --git a/examples/cards/templates/calendar-timeline-weather-live/manifest.json b/examples/cards/templates/calendar-timeline-weather-live/manifest.json new file mode 100644 index 0000000..c4c43fb --- /dev/null +++ b/examples/cards/templates/calendar-timeline-weather-live/manifest.json @@ -0,0 +1,22 @@ +{ + "key": "calendar-timeline-weather-live", + "title": "Today Calendar Weather Timeline", + "notes": "Experimental copy of the today-only Home Assistant calendar timeline with a subtle hourly temperature graph behind the timeline. Fill template_state with subtitle, tool_name (defaults to calendar_get_events), optional calendar_names, refresh_ms, min_start_hour, max_end_hour, min_window_hours, slot_height, empty_text, weather_tool_name (defaults to exec), and weather_command.", + "example_state": { + "subtitle": "Family Calendar", + "tool_name": "mcp_home_assistant_calendar_get_events", + "calendar_names": [ + "Family Calendar" + ], + "refresh_ms": 900000, + "min_start_hour": 6, + "max_end_hour": 22, + "min_window_hours": 6, + "slot_height": 24, + "empty_text": "No events for today.", + "weather_tool_name": "exec", + "weather_command": "python3 /home/kacper/nanobot/scripts/card_upcoming_conditions.py --nws-entity weather.korh --uv-entity weather.openweathermap_2 --forecast-type hourly --limit 48" + }, + "created_at": "2026-04-02T00:00:00+00:00", + "updated_at": "2026-04-02T00:00:00+00:00" +} diff --git a/examples/cards/templates/calendar-timeline-weather-live/template.html b/examples/cards/templates/calendar-timeline-weather-live/template.html new file mode 100644 index 0000000..30ede4d --- /dev/null +++ b/examples/cards/templates/calendar-timeline-weather-live/template.html @@ -0,0 +1,41 @@ + +
+
Today Calendar + Weather
+ +
+
+
Loading today…
+
Checking your calendar.
+
+
+ +
+
+
+ +
No events for today.
+ +
+
+ -- + -- +
+
+
+
diff --git a/examples/cards/templates/git-diff-live/card.js b/examples/cards/templates/git-diff-live/card.js new file mode 100644 index 0000000..64874a3 --- /dev/null +++ b/examples/cards/templates/git-diff-live/card.js @@ -0,0 +1,719 @@ +export function mount({ root, state, host }) { + state = state || {}; + const __cleanup = []; + const __setInterval = (...args) => { + const id = window.setInterval(...args); + __cleanup.push(() => window.clearInterval(id)); + return id; + }; + const __setTimeout = (...args) => { + const id = window.setTimeout(...args); + __cleanup.push(() => window.clearTimeout(id)); + return id; + }; + if (!(root instanceof HTMLElement)) return; + + const subtitleEl = root.querySelector('[data-git-subtitle]'); + const branchEl = root.querySelector('[data-git-branch]'); + const statusEl = root.querySelector('[data-git-status]'); + const changedEl = root.querySelector('[data-git-changed]'); + const stagingEl = root.querySelector('[data-git-staging]'); + const untrackedEl = root.querySelector('[data-git-untracked]'); + const upstreamEl = root.querySelector('[data-git-upstream]'); + const plusEl = root.querySelector('[data-git-plus]'); + const minusEl = root.querySelector('[data-git-minus]'); + const updatedEl = root.querySelector('[data-git-updated]'); + const filesEl = root.querySelector('[data-git-files]'); + if (!(subtitleEl instanceof HTMLElement) || !(branchEl instanceof HTMLElement) || !(statusEl instanceof HTMLElement) || !(changedEl instanceof HTMLElement) || !(stagingEl instanceof HTMLElement) || !(untrackedEl instanceof HTMLElement) || !(upstreamEl instanceof HTMLElement) || !(plusEl instanceof HTMLElement) || !(minusEl instanceof HTMLElement) || !(updatedEl instanceof HTMLElement) || !(filesEl instanceof HTMLElement)) return; + + const configuredToolName = typeof state.tool_name === 'string' ? state.tool_name.trim() : ''; + const rawToolArguments = state && typeof state.tool_arguments === 'object' && state.tool_arguments && !Array.isArray(state.tool_arguments) + ? state.tool_arguments + : {}; + const subtitle = typeof state.subtitle === 'string' ? state.subtitle.trim() : ''; + const numberFormatter = new Intl.NumberFormat([], { maximumFractionDigits: 0 }); + + const setStatus = (label, fg, bg) => { + statusEl.textContent = label; + statusEl.style.color = fg; + statusEl.style.background = bg; + statusEl.style.padding = '3px 7px'; + statusEl.style.borderRadius = '999px'; + }; + + const statusTone = (value) => { + if (value === 'Clean') return { fg: '#6c8b63', bg: '#dfe9d8' }; + if (value === 'Dirty') return { fg: '#9a6a2f', bg: '#f4e2b8' }; + return { fg: '#a14d43', bg: '#f3d8d2' }; + }; + + const formatBranch = (payload) => { + const parts = []; + const branch = typeof payload.branch === 'string' ? payload.branch : ''; + if (branch) parts.push(branch); + if (typeof payload.upstream === 'string' && payload.upstream) { + parts.push(payload.upstream); + } + const ahead = Number(payload.ahead || 0); + const behind = Number(payload.behind || 0); + if (ahead || behind) { + parts.push(`+${ahead} / -${behind}`); + } + if (!parts.length && typeof payload.head === 'string' && payload.head) { + parts.push(payload.head); + } + return parts.join(' · ') || 'No branch information'; + }; + + const formatUpdated = (raw) => { + if (typeof raw !== 'string' || !raw) return '--'; + const parsed = new Date(raw); + if (Number.isNaN(parsed.getTime())) return raw; + return parsed.toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); + }; + + const chipStyle = (status) => { + if (status === '??') return { fg: '#6f7582', bg: '#e8edf2' }; + if (status.includes('D')) return { fg: '#a45b51', bg: '#f3d7d2' }; + if (status.includes('A')) return { fg: '#6d8a5d', bg: '#dce7d6' }; + return { fg: '#9a6a2f', bg: '#f3e1ba' }; + }; + + const asLineNumber = (value) => { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string' && /^\d+$/.test(value)) return Number(value); + return null; + }; + + const formatRangePart = (label, start, end) => { + if (start === null || end === null) return ''; + return start === end ? `${label} ${start}` : `${label} ${start}-${end}`; + }; + + const buildSelectionPayload = (filePath, lines) => { + const oldNumbers = lines.map((line) => line.oldNumber).filter((value) => value !== null); + const newNumbers = lines.map((line) => line.newNumber).filter((value) => value !== null); + const oldStart = oldNumbers.length ? Math.min(...oldNumbers) : null; + const oldEnd = oldNumbers.length ? Math.max(...oldNumbers) : null; + const newStart = newNumbers.length ? Math.min(...newNumbers) : null; + const newEnd = newNumbers.length ? Math.max(...newNumbers) : null; + const rangeParts = [ + formatRangePart('old', oldStart, oldEnd), + formatRangePart('new', newStart, newEnd), + ].filter(Boolean); + const fileLabel = filePath || 'Selected diff'; + const rangeLabel = rangeParts.join(' · ') || 'Selected diff lines'; + return { + kind: 'git_diff_range', + file_path: filePath || fileLabel, + file_label: fileLabel, + range_label: rangeLabel, + label: `${fileLabel} · ${rangeLabel}`, + old_start: oldStart, + old_end: oldEnd, + new_start: newStart, + new_end: newEnd, + }; + }; + + let activeSelectionController = null; + + const clearActiveSelection = () => { + if (activeSelectionController) { + const controller = activeSelectionController; + activeSelectionController = null; + controller.clear(false); + } + host.setSelection(null); + }; + + const renderPatchBody = (target, item) => { + target.innerHTML = ''; + target.dataset.noSwipe = '1'; + target.style.marginTop = '10px'; + target.style.marginLeft = '-12px'; + target.style.marginRight = '-12px'; + target.style.width = 'calc(100% + 24px)'; + target.style.paddingTop = '10px'; + target.style.borderTop = '1px solid rgba(177, 140, 112, 0.16)'; + target.style.overflow = 'hidden'; + target.style.minWidth = '0'; + target.style.maxWidth = 'none'; + + const viewport = document.createElement('div'); + viewport.dataset.noSwipe = '1'; + viewport.style.width = '100%'; + viewport.style.maxWidth = 'none'; + viewport.style.minWidth = '0'; + viewport.style.overflowX = 'auto'; + viewport.style.overflowY = 'hidden'; + viewport.style.touchAction = 'auto'; + viewport.style.overscrollBehavior = 'contain'; + viewport.style.webkitOverflowScrolling = 'touch'; + viewport.style.scrollbarWidth = 'thin'; + viewport.style.scrollbarColor = 'rgba(120, 94, 74, 0.28) transparent'; + + const diffText = typeof item?.diff === 'string' ? item.diff : ''; + const diffLines = Array.isArray(item?.diff_lines) ? item.diff_lines : []; + if (!diffText && diffLines.length === 0) { + const message = document.createElement('div'); + message.textContent = 'No line diff available for this path.'; + message.style.fontSize = '0.76rem'; + message.style.lineHeight = '1.4'; + message.style.color = '#9a7b68'; + message.style.fontWeight = '600'; + target.appendChild(message); + return; + } + + const block = document.createElement('div'); + block.dataset.noSwipe = '1'; + block.style.display = 'grid'; + block.style.gap = '0'; + block.style.padding = '0'; + block.style.borderRadius = '0'; + block.style.background = 'rgba(255,255,255,0.58)'; + block.style.border = '1px solid rgba(153, 118, 92, 0.14)'; + block.style.borderLeft = '0'; + block.style.borderRight = '0'; + block.style.fontFamily = + "var(--card-font, 'Iosevka', 'SF Mono', ui-monospace, Menlo, Consolas, monospace)"; + block.style.fontSize = '0.64rem'; + block.style.lineHeight = '1.45'; + block.style.color = '#5f4a3f'; + block.style.width = 'max-content'; + block.style.minWidth = '100%'; + + const selectableLines = []; + let localSelection = null; + let localAnchorIndex = null; + + const setSelectedState = (entry, selected) => { + if (selected) entry.lineEl.dataset.selected = 'true'; + else delete entry.lineEl.dataset.selected; + }; + + const controller = { + clear(publish = true) { + localSelection = null; + localAnchorIndex = null; + for (const entry of selectableLines) setSelectedState(entry, false); + if (publish) { + if (activeSelectionController === controller) activeSelectionController = null; + host.setSelection(null); + } + }, + }; + + const applySelection = (startIndex, endIndex) => { + const lower = Math.min(startIndex, endIndex); + const upper = Math.max(startIndex, endIndex); + localSelection = { startIndex: lower, endIndex: upper }; + for (const [index, entry] of selectableLines.entries()) { + setSelectedState(entry, index >= lower && index <= upper); + } + if (activeSelectionController && activeSelectionController !== controller) { + activeSelectionController.clear(false); + } + activeSelectionController = controller; + host.setSelection(buildSelectionPayload(String(item?.path || ''), selectableLines.slice(lower, upper + 1)), + ); + }; + + const handleSelectableLine = (index) => { + if (!localSelection) { + localAnchorIndex = index; + applySelection(index, index); + return; + } + const singleLine = localSelection.startIndex === localSelection.endIndex; + if (singleLine) { + const anchorIndex = localAnchorIndex ?? localSelection.startIndex; + if (index === anchorIndex) { + controller.clear(true); + return; + } + applySelection(anchorIndex, index); + localAnchorIndex = null; + return; + } + localAnchorIndex = index; + applySelection(index, index); + }; + + const registerSelectableLine = (lineEl, oldNumber, newNumber) => { + const entry = { + lineEl, + oldNumber: asLineNumber(oldNumber), + newNumber: asLineNumber(newNumber), + }; + const index = selectableLines.push(entry) - 1; + lineEl.dataset.selectable = 'true'; + lineEl.tabIndex = 0; + lineEl.setAttribute('role', 'button'); + lineEl.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + handleSelectableLine(index); + }); + lineEl.addEventListener('keydown', (event) => { + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + handleSelectableLine(index); + }); + }; + + if (diffLines.length > 0) { + for (const row of diffLines) { + const lineEl = document.createElement('div'); + lineEl.dataset.gitPatchRow = '1'; + lineEl.style.display = 'grid'; + lineEl.style.gridTemplateColumns = 'max-content max-content'; + lineEl.style.columnGap = '8px'; + lineEl.style.alignItems = 'start'; + lineEl.style.justifyContent = 'start'; + lineEl.style.padding = '0'; + lineEl.style.borderRadius = '0'; + lineEl.style.width = 'max-content'; + lineEl.style.minWidth = '100%'; + + const numberEl = document.createElement('span'); + const lineNumber = + typeof row?.line_number === 'number' || typeof row?.line_number === 'string' + ? String(row.line_number) + : ''; + numberEl.textContent = lineNumber; + numberEl.style.minWidth = '2.2em'; + numberEl.style.textAlign = 'right'; + numberEl.style.color = '#8c7464'; + numberEl.style.opacity = '0.92'; + + const textEl = document.createElement('span'); + textEl.dataset.gitPatchText = '1'; + textEl.textContent = typeof row?.text === 'string' ? row.text : ''; + textEl.style.whiteSpace = 'pre'; + textEl.style.wordBreak = 'normal'; + + const kind = typeof row?.kind === 'string' ? row.kind : ''; + if (kind === 'added') { + lineEl.style.color = '#0c3f12'; + lineEl.style.background = 'rgba(158, 232, 147, 0.98)'; + } else if (kind === 'removed') { + lineEl.style.color = '#6d0d08'; + lineEl.style.background = 'rgba(249, 156, 145, 0.98)'; + } else { + lineEl.style.color = '#5f4a3f'; + } + + lineEl.append(numberEl, textEl); + block.appendChild(lineEl); + } + } else { + const makePatchLine = (line, kind, oldNumber = '', newNumber = '') => { + const lineEl = document.createElement('div'); + lineEl.dataset.gitPatchRow = '1'; + lineEl.style.display = 'grid'; + lineEl.style.gridTemplateColumns = 'max-content max-content max-content'; + lineEl.style.columnGap = '8px'; + lineEl.style.alignItems = 'start'; + lineEl.style.justifyContent = 'start'; + lineEl.style.padding = '0'; + lineEl.style.borderRadius = '0'; + lineEl.style.width = 'max-content'; + lineEl.style.minWidth = '100%'; + + const oldEl = document.createElement('span'); + oldEl.textContent = oldNumber ? String(oldNumber) : ''; + oldEl.style.minWidth = '2.4em'; + oldEl.style.textAlign = 'right'; + oldEl.style.color = '#8c7464'; + oldEl.style.opacity = '0.92'; + + const newEl = document.createElement('span'); + newEl.textContent = newNumber ? String(newNumber) : ''; + newEl.style.minWidth = '2.4em'; + newEl.style.textAlign = 'right'; + newEl.style.color = '#8c7464'; + newEl.style.opacity = '0.92'; + + const textEl = document.createElement('span'); + textEl.dataset.gitPatchText = '1'; + textEl.textContent = line || ' '; + textEl.style.whiteSpace = 'pre'; + textEl.style.wordBreak = 'normal'; + + if (kind === 'hunk') { + lineEl.style.color = '#6c523f'; + lineEl.style.background = 'rgba(224, 204, 184, 0.94)'; + lineEl.style.fontWeight = '800'; + } else if (kind === 'added') { + lineEl.style.color = '#0f4515'; + lineEl.style.background = 'rgba(170, 232, 160, 0.98)'; + } else if (kind === 'removed') { + lineEl.style.color = '#74110a'; + lineEl.style.background = 'rgba(247, 170, 160, 0.98)'; + } else if (kind === 'context') { + lineEl.style.color = '#6f5b4d'; + lineEl.style.background = 'rgba(247, 236, 223, 0.72)'; + } else if (kind === 'meta') { + lineEl.style.color = '#725c4f'; + lineEl.style.background = 'rgba(255, 255, 255, 0.42)'; + } else if (kind === 'note') { + lineEl.style.color = '#8a6f5c'; + lineEl.style.background = 'rgba(236, 226, 216, 0.72)'; + lineEl.style.fontStyle = 'italic'; + } + + lineEl.append(oldEl, newEl, textEl); + if (kind === 'added' || kind === 'removed' || kind === 'context') { + registerSelectableLine(lineEl, oldNumber, newNumber); + } + return lineEl; + }; + + const parseHunkHeader = (line) => { + const match = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(line); + if (!match) return null; + return { + oldLine: Number(match[1] || '0'), + newLine: Number(match[3] || '0'), + }; + }; + + const prelude = []; + const hunks = []; + let currentHunk = null; + for (const line of diffText.split('\n')) { + if (line.startsWith('@@')) { + if (currentHunk) hunks.push(currentHunk); + currentHunk = { header: line, lines: [] }; + continue; + } + if (currentHunk) { + currentHunk.lines.push(line); + } else { + prelude.push(line); + } + } + if (currentHunk) hunks.push(currentHunk); + + for (const line of prelude) { + let kind = 'meta'; + if (line.startsWith('Binary files') || line.startsWith('\\')) kind = 'note'; + else if (line.startsWith('+') && !line.startsWith('+++')) kind = 'added'; + else if (line.startsWith('-') && !line.startsWith('---')) kind = 'removed'; + else if (line.startsWith(' ')) kind = 'context'; + block.appendChild(makePatchLine(line, kind)); + } + + for (const hunk of hunks) { + const section = document.createElement('section'); + section.style.display = 'grid'; + section.style.gap = '0'; + section.style.marginTop = block.childNodes.length ? '10px' : '0'; + section.style.borderTop = '1px solid rgba(177, 140, 112, 0.24)'; + section.style.borderBottom = '1px solid rgba(177, 140, 112, 0.24)'; + + section.appendChild(makePatchLine(hunk.header, 'hunk')); + const parsed = parseHunkHeader(hunk.header); + let oldLine = parsed ? parsed.oldLine : 0; + let newLine = parsed ? parsed.newLine : 0; + for (const line of hunk.lines) { + if (line.startsWith('+') && !line.startsWith('+++')) { + section.appendChild(makePatchLine(line, 'added', '', newLine)); + newLine += 1; + } else if (line.startsWith('-') && !line.startsWith('---')) { + section.appendChild(makePatchLine(line, 'removed', oldLine, '')); + oldLine += 1; + } else if (line.startsWith('\\')) { + section.appendChild(makePatchLine(line, 'note')); + } else if (line.startsWith('+++') || line.startsWith('---')) { + section.appendChild(makePatchLine(line, 'meta')); + } else { + const oldNumber = oldLine ? oldLine : ''; + const newNumber = newLine ? newLine : ''; + section.appendChild(makePatchLine(line, 'context', oldNumber, newNumber)); + oldLine += 1; + newLine += 1; + } + } + block.appendChild(section); + } + } + + viewport.appendChild(block); + target.appendChild(viewport); + + if (item?.diff_truncated) { + const note = document.createElement('div'); + note.textContent = 'Diff truncated for readability.'; + note.style.marginTop = '8px'; + note.style.fontSize = '0.72rem'; + note.style.lineHeight = '1.35'; + note.style.color = '#9a7b68'; + note.style.fontWeight = '600'; + target.appendChild(note); + } + }; + + const renderFiles = (items) => { + clearActiveSelection(); + filesEl.innerHTML = ''; + if (!Array.isArray(items) || items.length === 0) { + const empty = document.createElement('div'); + empty.textContent = 'Working tree clean.'; + empty.style.fontSize = '0.92rem'; + empty.style.lineHeight = '1.4'; + empty.style.color = '#7d8f73'; + empty.style.fontWeight = '700'; + empty.style.padding = '12px'; + empty.style.borderRadius = '12px'; + empty.style.background = 'rgba(223, 233, 216, 0.55)'; + empty.style.border = '1px solid rgba(109, 138, 93, 0.12)'; + filesEl.appendChild(empty); + return; + } + + for (const item of items) { + const row = document.createElement('div'); + row.style.display = 'block'; + row.style.minWidth = '0'; + row.style.maxWidth = '100%'; + row.style.padding = '0'; + row.style.borderRadius = '0'; + row.style.background = 'transparent'; + row.style.border = '0'; + row.style.boxShadow = 'none'; + + const summaryButton = document.createElement('button'); + summaryButton.type = 'button'; + summaryButton.style.display = 'flex'; + summaryButton.style.alignItems = 'flex-start'; + summaryButton.style.justifyContent = 'space-between'; + summaryButton.style.gap = '8px'; + summaryButton.style.width = '100%'; + summaryButton.style.minWidth = '0'; + summaryButton.style.padding = '0'; + summaryButton.style.margin = '0'; + summaryButton.style.border = '0'; + summaryButton.style.background = 'transparent'; + summaryButton.style.textAlign = 'left'; + summaryButton.style.cursor = 'pointer'; + + const left = document.createElement('div'); + left.style.display = 'flex'; + left.style.alignItems = 'flex-start'; + left.style.gap = '8px'; + left.style.minWidth = '0'; + left.style.flex = '1 1 auto'; + + const chip = document.createElement('span'); + const chipTone = chipStyle(String(item?.status || 'M')); + chip.textContent = String(item?.status || 'M'); + chip.style.fontSize = '0.72rem'; + chip.style.lineHeight = '1.1'; + chip.style.fontWeight = '800'; + chip.style.color = chipTone.fg; + chip.style.background = chipTone.bg; + chip.style.padding = '4px 7px'; + chip.style.borderRadius = '999px'; + chip.style.flex = '0 0 auto'; + + const pathWrap = document.createElement('div'); + pathWrap.style.minWidth = '0'; + + const pathEl = document.createElement('div'); + pathEl.textContent = String(item?.path || '--'); + pathEl.style.fontSize = '0.92rem'; + pathEl.style.lineHeight = '1.3'; + pathEl.style.fontWeight = '700'; + pathEl.style.color = '#65483a'; + pathEl.style.wordBreak = 'break-word'; + + const detailEl = document.createElement('div'); + detailEl.style.marginTop = '3px'; + detailEl.style.fontSize = '0.77rem'; + detailEl.style.lineHeight = '1.35'; + detailEl.style.color = '#9a7b68'; + const insertions = Number(item?.insertions || 0); + const deletions = Number(item?.deletions || 0); + detailEl.textContent = insertions || deletions + ? `+${numberFormatter.format(insertions)} / -${numberFormatter.format(deletions)}` + : 'No line diff'; + + pathWrap.append(pathEl, detailEl); + left.append(chip, pathWrap); + const toggle = document.createElement('span'); + toggle.setAttribute('aria-hidden', 'true'); + toggle.style.fontSize = '0.95rem'; + toggle.style.lineHeight = '1'; + toggle.style.fontWeight = '800'; + toggle.style.color = '#9a7b68'; + toggle.style.whiteSpace = 'nowrap'; + toggle.style.flex = '0 0 auto'; + toggle.style.paddingTop = '1px'; + + const body = document.createElement('div'); + body.hidden = true; + body.style.width = '100%'; + body.style.maxWidth = '100%'; + body.style.minWidth = '0'; + renderPatchBody(body, item); + + const hasDiff = Boolean(item?.diff_available) || Boolean(item?.diff); + const setExpanded = (expanded) => { + body.hidden = !expanded; + summaryButton.setAttribute('aria-expanded', expanded ? 'true' : 'false'); + toggle.textContent = expanded ? '▴' : '▾'; + }; + setExpanded(false); + + summaryButton.addEventListener('click', () => { + setExpanded(body.hidden); + }); + + summaryButton.append(left, toggle); + row.append(summaryButton, body); + filesEl.appendChild(row); + } + }; + + const updateLiveContent = (snapshot) => { + host.setLiveContent(snapshot); + }; + + const render = (payload) => { + subtitleEl.textContent = subtitle || payload.repo_name || payload.repo_path || 'Git repo'; + branchEl.textContent = formatBranch(payload); + changedEl.textContent = numberFormatter.format(Number(payload.changed_files || 0)); + stagingEl.textContent = `${numberFormatter.format(Number(payload.staged_files || 0))} staged · ${numberFormatter.format(Number(payload.unstaged_files || 0))} unstaged`; + untrackedEl.textContent = numberFormatter.format(Number(payload.untracked_files || 0)); + upstreamEl.textContent = typeof payload.repo_path === 'string' ? payload.repo_path : '--'; + plusEl.textContent = `+${numberFormatter.format(Number(payload.insertions || 0))}`; + minusEl.textContent = `-${numberFormatter.format(Number(payload.deletions || 0))}`; + updatedEl.textContent = `Updated ${formatUpdated(payload.generated_at)}`; + const label = payload.dirty ? 'Dirty' : 'Clean'; + const tone = statusTone(label); + setStatus(label, tone.fg, tone.bg); + renderFiles(payload.files); + updateLiveContent({ + kind: 'git_repo_diff', + repo_name: payload.repo_name || null, + repo_path: payload.repo_path || null, + branch: payload.branch || null, + upstream: payload.upstream || null, + ahead: Number(payload.ahead || 0), + behind: Number(payload.behind || 0), + dirty: Boolean(payload.dirty), + changed_files: Number(payload.changed_files || 0), + staged_files: Number(payload.staged_files || 0), + unstaged_files: Number(payload.unstaged_files || 0), + untracked_files: Number(payload.untracked_files || 0), + insertions: Number(payload.insertions || 0), + deletions: Number(payload.deletions || 0), + files: Array.isArray(payload.files) + ? payload.files.map((item) => ({ + path: item?.path || null, + status: item?.status || null, + insertions: Number(item?.insertions || 0), + deletions: Number(item?.deletions || 0), + diff_available: Boolean(item?.diff_available), + diff_truncated: Boolean(item?.diff_truncated), + })) + : [], + generated_at: payload.generated_at || null, + }); + }; + + const renderError = (message) => { + clearActiveSelection(); + subtitleEl.textContent = subtitle || 'Git repo'; + branchEl.textContent = 'Unable to load repo diff'; + changedEl.textContent = '--'; + stagingEl.textContent = '--'; + untrackedEl.textContent = '--'; + upstreamEl.textContent = '--'; + plusEl.textContent = '+--'; + minusEl.textContent = '- --'; + updatedEl.textContent = message; + const tone = statusTone('Unavailable'); + setStatus('Unavailable', tone.fg, tone.bg); + filesEl.innerHTML = ''; + const error = document.createElement('div'); + error.textContent = message; + error.style.fontSize = '0.88rem'; + error.style.lineHeight = '1.4'; + error.style.color = '#a45b51'; + error.style.fontWeight = '700'; + error.style.padding = '12px'; + error.style.borderRadius = '12px'; + error.style.background = 'rgba(243, 216, 210, 0.55)'; + error.style.border = '1px solid rgba(164, 91, 81, 0.14)'; + filesEl.appendChild(error); + updateLiveContent({ + kind: 'git_repo_diff', + repo_name: null, + repo_path: null, + dirty: null, + error: message, + }); + }; + + const loadPayload = async () => { + if (!configuredToolName) throw new Error('Missing template_state.tool_name'); + if (!host.callToolAsync) throw new Error('Async tool helper unavailable'); + const toolResult = await host.callToolAsync( + configuredToolName, + rawToolArguments, + { timeoutMs: 180000 }, + ); + if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object' && !Array.isArray(toolResult.parsed)) { + return toolResult.parsed; + } + const rawContent = typeof toolResult?.content === 'string' ? toolResult.content.trim() : ''; + if (rawContent) { + if (rawContent.includes('(truncated,')) { + throw new Error('Tool output was truncated. Increase exec max_output_chars for this card.'); + } + const normalizedContent = rawContent.replace(/\n+Exit code:\s*-?\d+\s*$/i, '').trim(); + if (!normalizedContent.startsWith('{')) { + throw new Error(rawContent); + } + try { + const parsed = JSON.parse(normalizedContent); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed; + } + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new Error(`Tool returned invalid JSON: ${detail}`); + } + } + throw new Error('Tool returned invalid JSON'); + }; + + const refresh = async () => { + const loadingTone = { fg: '#9a7b68', bg: '#efe3d6' }; + setStatus('Refreshing', loadingTone.fg, loadingTone.bg); + try { + const payload = await loadPayload(); + render(payload); + } catch (error) { + renderError(String(error)); + } + }; + + host.setRefreshHandler(() => { + void refresh(); + }); + void refresh(); + + return { + destroy() { + host.setRefreshHandler(null); + host.setLiveContent(null); + host.clearSelection(); + for (const cleanup of __cleanup.splice(0)) cleanup(); + }, + }; +} diff --git a/examples/cards/templates/git-diff-live/template.html b/examples/cards/templates/git-diff-live/template.html index 626db78..7dd37f5 100644 --- a/examples/cards/templates/git-diff-live/template.html +++ b/examples/cards/templates/git-diff-live/template.html @@ -162,708 +162,3 @@
- diff --git a/examples/cards/templates/list-total-live/card.js b/examples/cards/templates/list-total-live/card.js new file mode 100644 index 0000000..0834bd7 --- /dev/null +++ b/examples/cards/templates/list-total-live/card.js @@ -0,0 +1,258 @@ +function clampDigits(raw) { + const parsed = Number(raw); + if (!Number.isFinite(parsed)) return 4; + return Math.max(1, Math.min(4, Math.round(parsed))); +} + +function sanitizeValue(raw, maxDigits) { + return String(raw || "") + .replace(/\D+/g, "") + .slice(0, maxDigits); +} + +function sanitizeName(raw) { + return String(raw || "") + .replace(/\s+/g, " ") + .trimStart(); +} + +function isBlankRow(row) { + return !row || (!String(row.value || "").trim() && !String(row.name || "").trim()); +} + +function normalizeRows(raw, maxDigits) { + if (!Array.isArray(raw)) return []; + return raw + .filter((row) => row && typeof row === "object" && !Array.isArray(row)) + .map((row) => ({ + value: sanitizeValue(row.value, maxDigits), + name: sanitizeName(row.name), + })); +} + +function ensureTrailingBlankRow(rows) { + const next = rows.map((row) => ({ + value: String(row.value || ""), + name: String(row.name || ""), + })); + if (!next.length || !isBlankRow(next[next.length - 1])) { + next.push({ value: "", name: "" }); + } + return next; +} + +function persistedRows(rows) { + return rows + .filter((row) => !isBlankRow(row)) + .map((row) => ({ + value: String(row.value || ""), + name: String(row.name || ""), + })); +} + +function totalValue(rows) { + return persistedRows(rows).reduce((sum, row) => sum + (Number.parseInt(row.value, 10) || 0), 0); +} + +function normalizeConfig(state) { + const maxDigits = clampDigits(state.max_digits); + return { + leftLabel: String(state.left_label || "Value").trim() || "Value", + rightLabel: String(state.right_label || "Item").trim() || "Item", + totalLabel: String(state.total_label || "Total").trim() || "Total", + totalSuffix: String(state.total_suffix || "").trim(), + maxDigits, + score: + typeof state.score === "number" && Number.isFinite(state.score) + ? Math.max(0, Math.min(100, state.score)) + : 24, + rows: ensureTrailingBlankRow(normalizeRows(state.rows, maxDigits)), + }; +} + +function configState(config) { + return { + left_label: config.leftLabel, + right_label: config.rightLabel, + total_label: config.totalLabel, + total_suffix: config.totalSuffix, + max_digits: config.maxDigits, + score: config.score, + rows: persistedRows(config.rows), + }; +} + +function autoscore(config) { + if (typeof config.score === "number" && Number.isFinite(config.score) && config.score > 0) { + return config.score; + } + return persistedRows(config.rows).length ? 24 : 16; +} + +export function mount({ root, state, host }) { + const labelsEl = root.querySelector(".list-total-card-ui__labels"); + const rowsEl = root.querySelector(".list-total-card-ui__rows"); + const statusEl = root.querySelector(".list-total-card-ui__status"); + const totalLabelEl = root.querySelector(".list-total-card-ui__total-label"); + const totalEl = root.querySelector(".list-total-card-ui__total-value"); + + if ( + !(labelsEl instanceof HTMLElement) || + !(rowsEl instanceof HTMLElement) || + !(statusEl instanceof HTMLElement) || + !(totalLabelEl instanceof HTMLElement) || + !(totalEl instanceof HTMLElement) + ) { + return; + } + + const leftLabelEl = labelsEl.children.item(0); + const rightLabelEl = labelsEl.children.item(1); + if (!(leftLabelEl instanceof HTMLElement) || !(rightLabelEl instanceof HTMLElement)) return; + + let config = normalizeConfig(state); + let saveTimer = null; + let busy = false; + + const clearSaveTimer = () => { + if (saveTimer !== null) { + window.clearTimeout(saveTimer); + saveTimer = null; + } + }; + + const setStatus = (text, kind) => { + statusEl.textContent = text || ""; + statusEl.dataset.kind = kind || ""; + }; + + const publishLiveContent = () => { + host.setLiveContent({ + kind: "list_total", + item_count: persistedRows(config.rows).length, + total: totalValue(config.rows), + total_suffix: config.totalSuffix || null, + score: autoscore(config), + }); + }; + + const renderTotal = () => { + const total = totalValue(config.rows); + totalEl.textContent = `${total.toLocaleString()}${config.totalSuffix || ""}`; + publishLiveContent(); + }; + + const persist = async () => { + clearSaveTimer(); + busy = true; + setStatus("Saving", "ok"); + try { + const nextState = configState(config); + await host.replaceState(nextState); + config = normalizeConfig(nextState); + setStatus("", ""); + } catch (error) { + console.error("List total card save failed", error); + setStatus("Unavailable", "error"); + } finally { + busy = false; + render(); + } + }; + + const schedulePersist = () => { + clearSaveTimer(); + saveTimer = window.setTimeout(() => { + void persist(); + }, 280); + }; + + const normalizeRowsAfterBlur = () => { + config.rows = ensureTrailingBlankRow(persistedRows(config.rows)); + render(); + schedulePersist(); + }; + + const renderRows = () => { + rowsEl.innerHTML = ""; + config.rows.forEach((row, index) => { + const rowEl = document.createElement("div"); + rowEl.className = "list-total-card-ui__row"; + + const valueInput = document.createElement("input"); + valueInput.className = "list-total-card-ui__input list-total-card-ui__value"; + valueInput.type = "text"; + valueInput.inputMode = "numeric"; + valueInput.maxLength = config.maxDigits; + valueInput.placeholder = "0"; + valueInput.value = row.value; + valueInput.disabled = busy; + + const nameInput = document.createElement("input"); + nameInput.className = "list-total-card-ui__input list-total-card-ui__name"; + nameInput.type = "text"; + nameInput.placeholder = "Item"; + nameInput.value = row.name; + nameInput.disabled = busy; + + valueInput.addEventListener("input", () => { + config.rows[index].value = sanitizeValue(valueInput.value, config.maxDigits); + valueInput.value = config.rows[index].value; + if (index === config.rows.length - 1 && !isBlankRow(config.rows[index])) { + config.rows = ensureTrailingBlankRow(config.rows); + render(); + schedulePersist(); + return; + } + renderTotal(); + schedulePersist(); + }); + + nameInput.addEventListener("input", () => { + config.rows[index].name = sanitizeName(nameInput.value); + nameInput.value = config.rows[index].name; + if (index === config.rows.length - 1 && !isBlankRow(config.rows[index])) { + config.rows = ensureTrailingBlankRow(config.rows); + render(); + schedulePersist(); + return; + } + renderTotal(); + schedulePersist(); + }); + + valueInput.addEventListener("blur", normalizeRowsAfterBlur); + nameInput.addEventListener("blur", normalizeRowsAfterBlur); + + rowEl.append(valueInput, nameInput); + rowsEl.appendChild(rowEl); + }); + }; + + const render = () => { + leftLabelEl.textContent = config.leftLabel; + rightLabelEl.textContent = config.rightLabel; + totalLabelEl.textContent = config.totalLabel; + renderRows(); + renderTotal(); + }; + + host.setRefreshHandler(() => { + config.rows = ensureTrailingBlankRow(config.rows); + render(); + }); + + render(); + + return { + update({ state: nextState }) { + config = normalizeConfig(nextState); + render(); + }, + destroy() { + clearSaveTimer(); + host.setRefreshHandler(null); + host.setLiveContent(null); + }, + }; +} diff --git a/examples/cards/templates/list-total-live/template.html b/examples/cards/templates/list-total-live/template.html index 34465c2..18c8c09 100644 --- a/examples/cards/templates/list-total-live/template.html +++ b/examples/cards/templates/list-total-live/template.html @@ -1,336 +1,12 @@ - - -
-
-
Value
-
Item
+
+
+
Value
+
Item
-
-
-
-
Total
-
0
+
+
+
+
Total
+
0
- - diff --git a/examples/cards/templates/litellm-ups-usage-live/card.js b/examples/cards/templates/litellm-ups-usage-live/card.js new file mode 100644 index 0000000..8c04613 --- /dev/null +++ b/examples/cards/templates/litellm-ups-usage-live/card.js @@ -0,0 +1,268 @@ +export function mount({ root, state, host }) { + state = state || {}; + const __cleanup = []; + const __setInterval = (...args) => { + const id = window.setInterval(...args); + __cleanup.push(() => window.clearInterval(id)); + return id; + }; + const __setTimeout = (...args) => { + const id = window.setTimeout(...args); + __cleanup.push(() => window.clearTimeout(id)); + return id; + }; + if (!(root instanceof HTMLElement)) return; + + const subtitleEl = root.querySelector('[data-usage-subtitle]'); + const statusEl = root.querySelector('[data-usage-status]'); + const updatedEl = root.querySelector('[data-usage-updated]'); + const gridEl = root.querySelector('[data-usage-grid]'); + const monthSectionEl = root.querySelector('[data-usage-month-section]'); + const tokens24hEl = root.querySelector('[data-usage-tokens-24h]'); + const power24hEl = root.querySelector('[data-usage-power-24h]'); + const window24hEl = root.querySelector('[data-usage-window-24h]'); + const tokensMonthEl = root.querySelector('[data-usage-tokens-month]'); + const powerMonthEl = root.querySelector('[data-usage-power-month]'); + const windowMonthEl = root.querySelector('[data-usage-window-month]'); + if (!(subtitleEl instanceof HTMLElement) || !(statusEl instanceof HTMLElement) || !(updatedEl instanceof HTMLElement) || !(gridEl instanceof HTMLElement) || !(monthSectionEl instanceof HTMLElement) || !(tokens24hEl instanceof HTMLElement) || !(power24hEl instanceof HTMLElement) || !(window24hEl instanceof HTMLElement) || !(tokensMonthEl instanceof HTMLElement) || !(powerMonthEl instanceof HTMLElement) || !(windowMonthEl instanceof HTMLElement)) return; + + const subtitle = typeof state.subtitle === 'string' ? state.subtitle : ''; + const configuredToolName24h = typeof state.tool_name_24h === 'string' + ? state.tool_name_24h.trim() + : typeof state.tool_name === 'string' + ? state.tool_name.trim() + : ''; + const configuredToolNameMonth = typeof state.tool_name_month === 'string' + ? state.tool_name_month.trim() + : ''; + const rawToolArguments24h = state && typeof state.tool_arguments_24h === 'object' && state.tool_arguments_24h && !Array.isArray(state.tool_arguments_24h) + ? state.tool_arguments_24h + : state && typeof state.tool_arguments === 'object' && state.tool_arguments && !Array.isArray(state.tool_arguments) + ? state.tool_arguments + : {}; + const rawToolArgumentsMonth = state && typeof state.tool_arguments_month === 'object' && state.tool_arguments_month && !Array.isArray(state.tool_arguments_month) + ? state.tool_arguments_month + : {}; + const source24h = typeof state.source_url_24h === 'string' ? state.source_url_24h.trim() : ''; + const sourceMonth = typeof state.source_url_month === 'string' ? state.source_url_month.trim() : ''; + const refreshMsRaw = Number(state.refresh_ms); + const refreshMs = Number.isFinite(refreshMsRaw) && refreshMsRaw >= 60000 ? refreshMsRaw : 15 * 60 * 1000; + + const tokenFormatter = new Intl.NumberFormat([], { notation: 'compact', maximumFractionDigits: 1 }); + const kwhFormatter = new Intl.NumberFormat([], { minimumFractionDigits: 1, maximumFractionDigits: 1 }); + const moneyFormatter = new Intl.NumberFormat([], { style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 2 }); + + subtitleEl.textContent = subtitle || 'LiteLLM activity vs local UPS energy'; + const hasMonthSource = Boolean( + sourceMonth || + configuredToolNameMonth || + Object.keys(rawToolArgumentsMonth).length, + ); + if (!hasMonthSource) { + monthSectionEl.style.display = 'none'; + gridEl.style.gridTemplateColumns = 'minmax(0, 1fr)'; + } + const updateLiveContent = (snapshot) => { + host.setLiveContent(snapshot); + }; + + const setStatus = (label, color) => { + statusEl.textContent = label; + statusEl.style.color = color; + }; + + const parseLocalTimestamp = (raw) => { + if (typeof raw !== 'string' || !raw.trim()) return null; + const match = raw.trim().match(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}) ([+-]\d{2})(\d{2})$/); + if (!match) return null; + const value = `${match[1]}T${match[2]}${match[3]}:${match[4]}`; + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? null : parsed; + }; + + const formatRangeLabel = (payload, fallbackLabel) => { + const startRaw = typeof payload?.range_start_local === 'string' ? payload.range_start_local : ''; + const endRaw = typeof payload?.range_end_local === 'string' ? payload.range_end_local : ''; + const start = parseLocalTimestamp(startRaw); + const end = parseLocalTimestamp(endRaw); + if (!(start instanceof Date) || Number.isNaN(start.getTime()) || !(end instanceof Date) || Number.isNaN(end.getTime())) { + return fallbackLabel; + } + return `${start.toLocaleDateString([], { month: 'short', day: 'numeric' })} to ${end.toLocaleDateString([], { month: 'short', day: 'numeric' })}`; + }; + + const renderSection = (elements, payload, fallbackLabel) => { + const tokens = Number(payload?.total_tokens_processed); + const kwh = Number(payload?.total_ups_kwh_in_range); + const localCost = Number(payload?.local_cost_usd_in_range); + + elements.tokens.textContent = Number.isFinite(tokens) ? tokenFormatter.format(tokens) : '--'; + elements.power.textContent = + Number.isFinite(kwh) && Number.isFinite(localCost) + ? `${kwhFormatter.format(kwh)} kWh · ${moneyFormatter.format(localCost)}` + : Number.isFinite(kwh) + ? `${kwhFormatter.format(kwh)} kWh` + : '--'; + elements.window.textContent = formatRangeLabel(payload, fallbackLabel); + }; + + const blankSection = (elements, fallbackLabel) => { + elements.tokens.textContent = '--'; + elements.power.textContent = '--'; + elements.window.textContent = fallbackLabel; + }; + + const shellEscape = (value) => `'${String(value ?? '').replace(/'/g, `'\"'\"'`)}'`; + + const buildLegacyExecCommand = (rawUrl) => { + if (typeof rawUrl !== 'string' || !rawUrl.startsWith('/script/proxy/')) return ''; + const [pathPart, queryPart = ''] = rawUrl.split('?', 2); + const relativeScript = pathPart.slice('/script/proxy/'.length).replace(/^\/+/, ''); + if (!relativeScript) return ''; + const params = new URLSearchParams(queryPart); + const args = params.getAll('arg').map((value) => value.trim()).filter(Boolean); + const scriptPath = `$HOME/.nanobot/workspace/${relativeScript}`; + return `python3 ${scriptPath}${args.length ? ` ${args.map(shellEscape).join(' ')}` : ''}`; + }; + + const resolveToolCall = (toolName, toolArguments, legacySourceUrl) => { + if (toolName) { + return { + toolName, + toolArguments, + }; + } + const legacyCommand = buildLegacyExecCommand(legacySourceUrl); + if (!legacyCommand) return null; + return { + toolName: 'exec', + toolArguments: { command: legacyCommand }, + }; + }; + + const loadPayload = async (toolCall) => { + if (!toolCall) throw new Error('Missing tool_name/tool_arguments'); + if (!host.callToolAsync) throw new Error('Async tool helper unavailable'); + const toolResult = await host.callToolAsync( + toolCall.toolName, + toolCall.toolArguments, + { timeoutMs: 180000 }, + ); + if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object' && !Array.isArray(toolResult.parsed)) { + return toolResult.parsed; + } + if (typeof toolResult?.content === 'string' && toolResult.content.trim()) { + const parsed = JSON.parse(toolResult.content); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed; + } + } + throw new Error('Tool returned invalid JSON'); + }; + + const refresh = async () => { + setStatus('Refreshing', 'var(--theme-status-muted)'); + try { + const toolCall24h = resolveToolCall(configuredToolName24h, rawToolArguments24h, source24h); + const toolCallMonth = hasMonthSource + ? resolveToolCall(configuredToolNameMonth, rawToolArgumentsMonth, sourceMonth) + : null; + const jobs = [loadPayload(toolCall24h), hasMonthSource ? loadPayload(toolCallMonth) : Promise.resolve(null)]; + const results = await Promise.allSettled(jobs); + const twentyFourHour = results[0].status === 'fulfilled' ? results[0].value : null; + const month = hasMonthSource && results[1].status === 'fulfilled' ? results[1].value : null; + + if (twentyFourHour) { + renderSection( + { tokens: tokens24hEl, power: power24hEl, window: window24hEl }, + twentyFourHour, + 'Last 24 hours', + ); + } else { + blankSection( + { tokens: tokens24hEl, power: power24hEl, window: window24hEl }, + 'Last 24 hours', + ); + } + + if (hasMonthSource && month) { + renderSection( + { tokens: tokensMonthEl, power: powerMonthEl, window: windowMonthEl }, + month, + 'This month', + ); + } else if (hasMonthSource) { + blankSection( + { tokens: tokensMonthEl, power: powerMonthEl, window: windowMonthEl }, + 'This month', + ); + } + + const successCount = [twentyFourHour, month].filter(Boolean).length; + const expectedCount = hasMonthSource ? 2 : 1; + if (successCount === expectedCount) { + setStatus('Live', 'var(--theme-status-live)'); + } else if (successCount === 1) { + setStatus('Partial', 'var(--theme-status-warning)'); + } else { + setStatus('Unavailable', 'var(--theme-status-danger)'); + } + + const updatedText = new Date().toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); + updatedEl.textContent = updatedText; + updateLiveContent({ + kind: 'litellm_ups_usage', + subtitle: subtitleEl.textContent || null, + status: statusEl.textContent || null, + updated_at: updatedText, + last_24h: twentyFourHour + ? { + total_tokens_processed: Number(twentyFourHour.total_tokens_processed) || 0, + total_ups_kwh_in_range: Number(twentyFourHour.total_ups_kwh_in_range) || 0, + local_cost_usd_in_range: Number(twentyFourHour.local_cost_usd_in_range) || 0, + range_start_local: twentyFourHour.range_start_local || null, + range_end_local: twentyFourHour.range_end_local || null, + } + : null, + this_month: month + ? { + total_tokens_processed: Number(month.total_tokens_processed) || 0, + total_ups_kwh_in_range: Number(month.total_ups_kwh_in_range) || 0, + local_cost_usd_in_range: Number(month.local_cost_usd_in_range) || 0, + range_start_local: month.range_start_local || null, + range_end_local: month.range_end_local || null, + } + : null, + }); + } catch (error) { + const errorText = String(error); + blankSection({ tokens: tokens24hEl, power: power24hEl, window: window24hEl }, 'Last 24 hours'); + if (hasMonthSource) { + blankSection({ tokens: tokensMonthEl, power: powerMonthEl, window: windowMonthEl }, 'This month'); + } + updatedEl.textContent = errorText; + setStatus('Unavailable', 'var(--theme-status-danger)'); + updateLiveContent({ + kind: 'litellm_ups_usage', + subtitle: subtitleEl.textContent || null, + status: 'Unavailable', + updated_at: errorText, + last_24h: null, + this_month: null, + error: errorText, + }); + } + }; + + void refresh(); + __setInterval(() => { void refresh(); }, refreshMs); + + return { + destroy() { + host.setRefreshHandler(null); + host.setLiveContent(null); + host.clearSelection(); + for (const cleanup of __cleanup.splice(0)) cleanup(); + }, + }; +} diff --git a/examples/cards/templates/litellm-ups-usage-live/template.html b/examples/cards/templates/litellm-ups-usage-live/template.html index 2087e92..0df43de 100644 --- a/examples/cards/templates/litellm-ups-usage-live/template.html +++ b/examples/cards/templates/litellm-ups-usage-live/template.html @@ -1,282 +1,30 @@ -
+
-
Loading…
- Loading… +
Loading…
+ Loading…
-
Last 24h
+
Last 24h
-- - tokens + tokens
-
--
-
--
+
--
+
--
-
This Month
+
This Month
-- - tokens + tokens
-
--
-
--
+
--
+
--
-
Updated --
+
Updated --
- diff --git a/examples/cards/templates/live-bedroom-co2/card.js b/examples/cards/templates/live-bedroom-co2/card.js new file mode 100644 index 0000000..bcef002 --- /dev/null +++ b/examples/cards/templates/live-bedroom-co2/card.js @@ -0,0 +1,218 @@ +export function mount({ root, state, host }) { + state = state || {}; + const __cleanup = []; + const __setInterval = (...args) => { + const id = window.setInterval(...args); + __cleanup.push(() => window.clearInterval(id)); + return id; + }; + const __setTimeout = (...args) => { + const id = window.setTimeout(...args); + __cleanup.push(() => window.clearTimeout(id)); + return id; + }; + if (!(root instanceof HTMLElement)) return; + + const valueEl = root.querySelector("[data-co2-value]"); + const statusEl = root.querySelector("[data-co2-status]"); + const updatedEl = root.querySelector("[data-co2-updated]"); + if (!(valueEl instanceof HTMLElement) || !(statusEl instanceof HTMLElement) || !(updatedEl instanceof HTMLElement)) return; + + const configuredToolName = typeof state.tool_name === 'string' ? state.tool_name.trim() : ''; + const matchName = typeof state.match_name === 'string' ? state.match_name.trim() : 'Bedroom-Esp-Sensor CO2'; + const INTERVAL_RAW = Number(state.refresh_ms); + const INTERVAL_MS = Number.isFinite(INTERVAL_RAW) && INTERVAL_RAW >= 1000 ? INTERVAL_RAW : 15000; + + const setStatus = (label, color) => { + statusEl.textContent = label; + statusEl.style.color = color; + }; + + const setUpdatedNow = () => { + updatedEl.textContent = new Date().toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + }; + + const parseValue = (raw) => { + const n = Number(raw); + return Number.isFinite(n) ? Math.round(n) : null; + }; + + const computeScore = (value) => { + if (!Number.isFinite(value)) return 0; + if (value >= 1600) return 96; + if (value >= 1400) return 90; + if (value >= 1200) return 82; + if (value >= 1000) return 68; + if (value >= 900) return 54; + if (value >= 750) return 34; + return 16; + }; + + const stripQuotes = (value) => { + const text = String(value ?? '').trim(); + if ((text.startsWith("'") && text.endsWith("'")) || (text.startsWith('"') && text.endsWith('"'))) { + return text.slice(1, -1); + } + return text; + }; + + const normalizeText = (value) => String(value || '').trim().toLowerCase(); + + const parseLiveContextEntries = (payloadText) => { + const text = String(payloadText || '').replace(/\r/g, ''); + const startIndex = text.indexOf('- names: '); + const relevant = startIndex >= 0 ? text.slice(startIndex) : text; + const entries = []; + let current = null; + let inAttributes = false; + + const pushCurrent = () => { + if (current) entries.push(current); + current = null; + inAttributes = false; + }; + + for (const rawLine of relevant.split('\n')) { + if (rawLine.startsWith('- names: ')) { + pushCurrent(); + current = { + name: stripQuotes(rawLine.slice(9)), + domain: '', + state: '', + attributes: {}, + }; + continue; + } + if (!current) continue; + const trimmed = rawLine.trim(); + if (!trimmed) continue; + if (trimmed === 'attributes:') { + inAttributes = true; + continue; + } + if (rawLine.startsWith(' domain:')) { + current.domain = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1)); + inAttributes = false; + continue; + } + if (rawLine.startsWith(' state:')) { + current.state = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1)); + inAttributes = false; + continue; + } + if (inAttributes && rawLine.startsWith(' ')) { + const separatorIndex = rawLine.indexOf(':'); + if (separatorIndex >= 0) { + const key = rawLine.slice(4, separatorIndex).trim(); + const value = stripQuotes(rawLine.slice(separatorIndex + 1)); + current.attributes[key] = value; + } + continue; + } + inAttributes = false; + } + + pushCurrent(); + return entries; + }; + + const extractLiveContextText = (toolResult) => { + if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object') { + const parsed = toolResult.parsed; + if (typeof parsed.result === 'string') return parsed.result; + } + if (typeof toolResult?.content === 'string') { + try { + const parsed = JSON.parse(toolResult.content); + if (parsed && typeof parsed === 'object' && typeof parsed.result === 'string') { + return parsed.result; + } + } catch { + return toolResult.content; + } + return toolResult.content; + } + return ''; + }; + + const resolveToolName = async () => { + if (configuredToolName) return configuredToolName; + if (!host.listTools) return 'mcp_home_assistant_GetLiveContext'; + try { + const tools = await host.listTools(); + const liveContextTool = Array.isArray(tools) + ? tools.find((tool) => /(^|_)GetLiveContext$/i.test(String(tool?.name || ''))) + : null; + return liveContextTool?.name || 'mcp_home_assistant_GetLiveContext'; + } catch { + return 'mcp_home_assistant_GetLiveContext'; + } + }; + + const refresh = async () => { + setStatus("Refreshing", "var(--theme-status-muted)"); + try { + const toolName = await resolveToolName(); + const toolResult = await host.callTool(toolName, {}); + const entries = parseLiveContextEntries(extractLiveContextText(toolResult)); + const entry = entries.find((item) => normalizeText(item.name) === normalizeText(matchName)); + if (!entry) throw new Error(`Missing sensor ${matchName}`); + const value = parseValue(entry.state); + if (value === null) throw new Error("Invalid sensor payload"); + + valueEl.textContent = String(value); + if (value >= 1200) setStatus("High", "var(--theme-status-danger)"); + else if (value >= 900) setStatus("Elevated", "var(--theme-status-warning)"); + else setStatus("Good", "var(--theme-status-live)"); + setUpdatedNow(); + host.setLiveContent({ + kind: 'sensor', + tool_name: toolName, + match_name: entry.name, + value, + display_value: String(value), + unit: entry.attributes?.unit_of_measurement || 'ppm', + status: statusEl.textContent || null, + updated_at: updatedEl.textContent || null, + score: computeScore(value), + }); + } catch (err) { + valueEl.textContent = "--"; + setStatus("Unavailable", "var(--theme-status-danger)"); + updatedEl.textContent = String(err); + host.setLiveContent({ + kind: 'sensor', + tool_name: configuredToolName || 'mcp_home_assistant_GetLiveContext', + match_name: matchName, + value: null, + display_value: '--', + unit: 'ppm', + status: 'Unavailable', + updated_at: String(err), + error: String(err), + score: 0, + }); + } + }; + + host.setRefreshHandler(() => { + void refresh(); + }); + void refresh(); + __setInterval(() => { + void refresh(); + }, INTERVAL_MS); + + return { + destroy() { + host.setRefreshHandler(null); + host.setLiveContent(null); + host.clearSelection(); + for (const cleanup of __cleanup.splice(0)) cleanup(); + }, + }; +} diff --git a/examples/cards/templates/live-bedroom-co2/template.html b/examples/cards/templates/live-bedroom-co2/template.html index 3092b1b..3aeefe7 100644 --- a/examples/cards/templates/live-bedroom-co2/template.html +++ b/examples/cards/templates/live-bedroom-co2/template.html @@ -1,204 +1,15 @@ -
+
-

Bedroom CO2

- Loading... +

Bedroom CO2

+ Loading...
-- - ppm + ppm
-
+
Updated: --
- diff --git a/examples/cards/templates/live-calendar-today/card.js b/examples/cards/templates/live-calendar-today/card.js new file mode 100644 index 0000000..038df2b --- /dev/null +++ b/examples/cards/templates/live-calendar-today/card.js @@ -0,0 +1,266 @@ +export function mount({ root, state, host }) { + state = state || {}; + const __cleanup = []; + const __setInterval = (...args) => { + const id = window.setInterval(...args); + __cleanup.push(() => window.clearInterval(id)); + return id; + }; + const __setTimeout = (...args) => { + const id = window.setTimeout(...args); + __cleanup.push(() => window.clearTimeout(id)); + return id; + }; + if (!(root instanceof HTMLElement)) return; + + const statusEl = root.querySelector("[data-cal-status]"); + const subtitleEl = root.querySelector("[data-cal-subtitle]"); + const dateEl = root.querySelector("[data-cal-date]"); + const listEl = root.querySelector("[data-cal-list]"); + const emptyEl = root.querySelector("[data-cal-empty]"); + const updatedEl = root.querySelector("[data-cal-updated]"); + + if (!(statusEl instanceof HTMLElement) || !(subtitleEl instanceof HTMLElement) || !(dateEl instanceof HTMLElement) || + !(listEl instanceof HTMLElement) || !(emptyEl instanceof HTMLElement) || !(updatedEl instanceof HTMLElement)) { + return; + } + + const configuredToolName = typeof state.tool_name === 'string' ? state.tool_name.trim() : ''; + const configuredCalendarNames = Array.isArray(state.calendar_names) + ? state.calendar_names.map((value) => String(value || '').trim()).filter(Boolean) + : []; + const refreshMsRaw = Number(state.refresh_ms); + const REFRESH_MS = Number.isFinite(refreshMsRaw) && refreshMsRaw >= 60000 ? refreshMsRaw : 15 * 60 * 1000; + + const setStatus = (label, color) => { + statusEl.textContent = label; + statusEl.style.color = color; + }; + + const updateLiveContent = (snapshot) => { + host.setLiveContent(snapshot); + }; + + const dayBounds = () => { + const now = new Date(); + const start = new Date(now); + start.setHours(0, 0, 0, 0); + const end = new Date(now); + end.setHours(23, 59, 59, 999); + return { start, end }; + }; + + const formatDateHeader = () => { + const now = new Date(); + return now.toLocaleDateString([], { weekday: "long", month: "short", day: "numeric", year: "numeric" }); + }; + + const normalizeDateValue = (value) => { + if (typeof value === "string") return value; + if (value && typeof value === "object") { + if (typeof value.dateTime === "string") return value.dateTime; + if (typeof value.date === "string") return value.date; + } + return ""; + }; + + const formatTime = (value) => { + const raw = normalizeDateValue(value); + if (!raw) return "--:--"; + if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) return "All day"; + const d = new Date(raw); + if (Number.isNaN(d.getTime())) return "--:--"; + return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); + }; + + const isAllDay = (start, end) => { + const s = normalizeDateValue(start); + const e = normalizeDateValue(end); + return /^\d{4}-\d{2}-\d{2}$/.test(s) || /^\d{4}-\d{2}-\d{2}$/.test(e); + }; + + const eventSortKey = (evt) => { + const raw = normalizeDateValue(evt && evt.start); + const t = new Date(raw).getTime(); + return Number.isFinite(t) ? t : Number.MAX_SAFE_INTEGER; + }; + + const extractEvents = (toolResult) => { + if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object') { + const parsed = toolResult.parsed; + if (Array.isArray(parsed.result)) return parsed.result; + } + if (typeof toolResult?.content === 'string') { + try { + const parsed = JSON.parse(toolResult.content); + if (parsed && typeof parsed === 'object' && Array.isArray(parsed.result)) { + return parsed.result; + } + } catch { + return []; + } + } + return []; + }; + + const resolveToolConfig = async () => { + const fallbackName = configuredToolName || 'mcp_home_assistant_calendar_get_events'; + if (!host.listTools) { + return { name: fallbackName, calendars: configuredCalendarNames }; + } + try { + const tools = await host.listTools(); + const tool = Array.isArray(tools) + ? tools.find((item) => /(^|_)calendar_get_events$/i.test(String(item?.name || ''))) + : null; + const enumValues = Array.isArray(tool?.parameters?.properties?.calendar?.enum) + ? tool.parameters.properties.calendar.enum.map((value) => String(value || '').trim()).filter(Boolean) + : []; + return { + name: tool?.name || fallbackName, + calendars: configuredCalendarNames.length > 0 ? configuredCalendarNames : enumValues, + }; + } catch { + return { name: fallbackName, calendars: configuredCalendarNames }; + } + }; + + const renderEvents = (events) => { + listEl.innerHTML = ""; + + if (!Array.isArray(events) || events.length === 0) { + emptyEl.style.display = "block"; + return; + } + + emptyEl.style.display = "none"; + + for (const evt of events) { + const li = document.createElement("li"); + li.style.border = "1px solid var(--theme-card-neutral-border)"; + li.style.borderRadius = "10px"; + li.style.padding = "10px 12px"; + li.style.background = "#ffffff"; + + const summary = document.createElement("div"); + summary.style.fontSize = "0.96rem"; + summary.style.fontWeight = "600"; + summary.style.color = "var(--theme-card-neutral-text)"; + summary.textContent = String(evt.summary || "(No title)"); + + const timing = document.createElement("div"); + timing.style.marginTop = "4px"; + timing.style.fontSize = "0.86rem"; + timing.style.color = "var(--theme-card-neutral-subtle)"; + + if (isAllDay(evt.start, evt.end)) { + timing.textContent = "All day"; + } else { + timing.textContent = `${formatTime(evt.start)} - ${formatTime(evt.end)}`; + } + + li.appendChild(summary); + li.appendChild(timing); + + if (evt.location) { + const location = document.createElement("div"); + location.style.marginTop = "4px"; + location.style.fontSize = "0.84rem"; + location.style.color = "var(--theme-card-neutral-muted)"; + location.textContent = `Location: ${String(evt.location)}`; + li.appendChild(location); + } + + listEl.appendChild(li); + } + }; + + const refresh = async () => { + dateEl.textContent = formatDateHeader(); + setStatus("Refreshing", "var(--theme-status-muted)"); + + try { + const toolConfig = await resolveToolConfig(); + if (!toolConfig.name) throw new Error('Calendar tool unavailable'); + if (!Array.isArray(toolConfig.calendars) || toolConfig.calendars.length === 0) { + subtitleEl.textContent = "No Home Assistant calendars available"; + renderEvents([]); + updatedEl.textContent = new Date().toLocaleTimeString(); + setStatus("OK", "var(--theme-status-live)"); + updateLiveContent({ + kind: 'calendar_today', + tool_name: toolConfig.name, + calendar_names: [], + updated_at: updatedEl.textContent || null, + event_count: 0, + events: [], + }); + return; + } + + subtitleEl.textContent = `${toolConfig.calendars.length} calendar${toolConfig.calendars.length === 1 ? "" : "s"}`; + + const allEvents = []; + for (const calendarName of toolConfig.calendars) { + const toolResult = await host.callTool(toolConfig.name, { + calendar: calendarName, + range: 'today', + }); + const events = extractEvents(toolResult); + for (const evt of events) { + allEvents.push({ ...evt, _calendarName: calendarName }); + } + } + + allEvents.sort((a, b) => eventSortKey(a) - eventSortKey(b)); + renderEvents(allEvents); + updatedEl.textContent = new Date().toLocaleTimeString(); + setStatus("Daily", "var(--theme-status-live)"); + updateLiveContent({ + kind: 'calendar_today', + tool_name: toolConfig.name, + calendar_names: toolConfig.calendars, + updated_at: updatedEl.textContent || null, + event_count: allEvents.length, + events: allEvents.map((evt) => ({ + summary: String(evt.summary || '(No title)'), + start: normalizeDateValue(evt.start) || null, + end: normalizeDateValue(evt.end) || null, + location: typeof evt.location === 'string' ? evt.location : null, + calendar_name: evt._calendarName || null, + })), + }); + } catch (err) { + subtitleEl.textContent = "Could not load Home Assistant calendar"; + renderEvents([]); + updatedEl.textContent = String(err); + setStatus("Unavailable", "var(--theme-status-danger)"); + updateLiveContent({ + kind: 'calendar_today', + tool_name: configuredToolName || 'mcp_home_assistant_calendar_get_events', + calendar_names: configuredCalendarNames, + updated_at: String(err), + event_count: 0, + events: [], + error: String(err), + }); + } + }; + + host.setRefreshHandler(() => { + void refresh(); + }); + void refresh(); + __setInterval(() => { + void refresh(); + }, REFRESH_MS); + + return { + destroy() { + host.setRefreshHandler(null); + host.setLiveContent(null); + host.clearSelection(); + for (const cleanup of __cleanup.splice(0)) cleanup(); + }, + }; +} diff --git a/examples/cards/templates/live-calendar-today/template.html b/examples/cards/templates/live-calendar-today/template.html index 501814b..6ba2c87 100644 --- a/examples/cards/templates/live-calendar-today/template.html +++ b/examples/cards/templates/live-calendar-today/template.html @@ -1,273 +1,23 @@ -
+
-

Today's Calendar

-
Loading calendars...
+

Today's Calendar

+
Loading calendars...
- Loading... + Loading...
-
- Date: -- +
+ Date: --
-
+
No events for today.
    -
    +
    Updated: --
    - diff --git a/examples/cards/templates/live-weather-01545/card.js b/examples/cards/templates/live-weather-01545/card.js new file mode 100644 index 0000000..26a4668 --- /dev/null +++ b/examples/cards/templates/live-weather-01545/card.js @@ -0,0 +1,243 @@ +export function mount({ root, state, host }) { + state = state || {}; + const __cleanup = []; + const __setInterval = (...args) => { + const id = window.setInterval(...args); + __cleanup.push(() => window.clearInterval(id)); + return id; + }; + const __setTimeout = (...args) => { + const id = window.setTimeout(...args); + __cleanup.push(() => window.clearTimeout(id)); + return id; + }; + if (!(root instanceof HTMLElement)) return; + + const tempEl = root.querySelector("[data-weather-temp]"); + const unitEl = root.querySelector("[data-weather-unit]"); + const condEl = root.querySelector("[data-weather-condition]"); + const humidityEl = root.querySelector("[data-weather-humidity]"); + const windEl = root.querySelector("[data-weather-wind]"); + const pressureEl = root.querySelector("[data-weather-pressure]"); + const updatedEl = root.querySelector("[data-weather-updated]"); + const statusEl = root.querySelector("[data-weather-status]"); + + if (!(tempEl instanceof HTMLElement) || !(unitEl instanceof HTMLElement) || !(condEl instanceof HTMLElement) || + !(humidityEl instanceof HTMLElement) || !(windEl instanceof HTMLElement) || !(pressureEl instanceof HTMLElement) || + !(updatedEl instanceof HTMLElement) || !(statusEl instanceof HTMLElement)) { + return; + } + + const configuredToolName = typeof state.tool_name === 'string' ? state.tool_name.trim() : ''; + const providerPrefix = typeof state.provider_prefix === 'string' ? state.provider_prefix.trim() : 'OpenWeatherMap'; + const temperatureName = typeof state.temperature_name === 'string' ? state.temperature_name.trim() : `${providerPrefix} Temperature`; + const humidityName = typeof state.humidity_name === 'string' ? state.humidity_name.trim() : `${providerPrefix} Humidity`; + const pressureName = typeof state.pressure_name === 'string' ? state.pressure_name.trim() : `${providerPrefix} Pressure`; + const windName = typeof state.wind_name === 'string' ? state.wind_name.trim() : `${providerPrefix} Wind speed`; + const conditionLabel = typeof state.condition_label === 'string' ? state.condition_label.trim() : `${providerPrefix} live context`; + const refreshMsRaw = Number(state.refresh_ms); + const REFRESH_MS = Number.isFinite(refreshMsRaw) && refreshMsRaw >= 60000 ? refreshMsRaw : 24 * 60 * 60 * 1000; + + const setStatus = (label, color) => { + statusEl.textContent = label; + statusEl.style.color = color; + }; + + const nowLabel = () => new Date().toLocaleString([], { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + + const stripQuotes = (value) => { + const text = String(value ?? '').trim(); + if ((text.startsWith("'") && text.endsWith("'")) || (text.startsWith('"') && text.endsWith('"'))) { + return text.slice(1, -1); + } + return text; + }; + + const normalizeText = (value) => String(value || '').trim().toLowerCase(); + + const parseLiveContextEntries = (payloadText) => { + const text = String(payloadText || '').replace(/\r/g, ''); + const startIndex = text.indexOf('- names: '); + const relevant = startIndex >= 0 ? text.slice(startIndex) : text; + const entries = []; + let current = null; + let inAttributes = false; + + const pushCurrent = () => { + if (current) entries.push(current); + current = null; + inAttributes = false; + }; + + for (const rawLine of relevant.split('\n')) { + if (rawLine.startsWith('- names: ')) { + pushCurrent(); + current = { + name: stripQuotes(rawLine.slice(9)), + domain: '', + state: '', + attributes: {}, + }; + continue; + } + if (!current) continue; + const trimmed = rawLine.trim(); + if (!trimmed) continue; + if (trimmed === 'attributes:') { + inAttributes = true; + continue; + } + if (rawLine.startsWith(' domain:')) { + current.domain = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1)); + inAttributes = false; + continue; + } + if (rawLine.startsWith(' state:')) { + current.state = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1)); + inAttributes = false; + continue; + } + if (inAttributes && rawLine.startsWith(' ')) { + const separatorIndex = rawLine.indexOf(':'); + if (separatorIndex >= 0) { + const key = rawLine.slice(4, separatorIndex).trim(); + const value = stripQuotes(rawLine.slice(separatorIndex + 1)); + current.attributes[key] = value; + } + continue; + } + inAttributes = false; + } + + pushCurrent(); + return entries; + }; + + const extractLiveContextText = (toolResult) => { + if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object') { + const parsed = toolResult.parsed; + if (typeof parsed.result === 'string') return parsed.result; + } + if (typeof toolResult?.content === 'string') { + try { + const parsed = JSON.parse(toolResult.content); + if (parsed && typeof parsed === 'object' && typeof parsed.result === 'string') { + return parsed.result; + } + } catch { + return toolResult.content; + } + return toolResult.content; + } + return ''; + }; + + const resolveToolName = async () => { + if (configuredToolName) return configuredToolName; + if (!host.listTools) return 'mcp_home_assistant_GetLiveContext'; + try { + const tools = await host.listTools(); + const liveContextTool = Array.isArray(tools) + ? tools.find((tool) => /(^|_)GetLiveContext$/i.test(String(tool?.name || ''))) + : null; + return liveContextTool?.name || 'mcp_home_assistant_GetLiveContext'; + } catch { + return 'mcp_home_assistant_GetLiveContext'; + } + }; + + const findEntry = (entries, candidates) => { + const normalizedCandidates = candidates.map((value) => normalizeText(value)).filter(Boolean); + if (normalizedCandidates.length === 0) return null; + const exactMatch = entries.find((entry) => normalizedCandidates.includes(normalizeText(entry.name))); + if (exactMatch) return exactMatch; + return entries.find((entry) => { + const entryName = normalizeText(entry.name); + return normalizedCandidates.some((candidate) => entryName.includes(candidate)); + }) || null; + }; + + const refresh = async () => { + setStatus("Refreshing", "var(--theme-status-muted)"); + try { + const toolName = await resolveToolName(); + const toolResult = await host.callTool(toolName, {}); + const entries = parseLiveContextEntries(extractLiveContextText(toolResult)).filter((entry) => normalizeText(entry.domain) === 'sensor'); + const temperatureEntry = findEntry(entries, [temperatureName]); + const humidityEntry = findEntry(entries, [humidityName]); + const pressureEntry = findEntry(entries, [pressureName]); + const windEntry = findEntry(entries, [windName]); + + const tempNum = Number(temperatureEntry?.state); + tempEl.textContent = Number.isFinite(tempNum) ? String(Math.round(tempNum)) : "--"; + unitEl.textContent = String(temperatureEntry?.attributes?.unit_of_measurement || "°F"); + condEl.textContent = conditionLabel; + + const humidity = Number(humidityEntry?.state); + humidityEl.textContent = Number.isFinite(humidity) ? `${Math.round(humidity)}%` : "--"; + + const windSpeed = Number(windEntry?.state); + const windUnit = String(windEntry?.attributes?.unit_of_measurement || "mph"); + windEl.textContent = Number.isFinite(windSpeed) ? `${windSpeed} ${windUnit}` : "--"; + + const pressureNum = Number(pressureEntry?.state); + pressureEl.textContent = Number.isFinite(pressureNum) + ? `${pressureNum} ${String(pressureEntry?.attributes?.unit_of_measurement || "")}`.trim() + : "--"; + + updatedEl.textContent = nowLabel(); + setStatus("Live", "var(--theme-status-live)"); + host.setLiveContent({ + kind: 'weather', + tool_name: toolName, + provider_prefix: providerPrefix, + temperature: Number.isFinite(tempNum) ? Math.round(tempNum) : null, + temperature_unit: unitEl.textContent || null, + humidity: Number.isFinite(humidity) ? Math.round(humidity) : null, + wind: windEl.textContent || null, + pressure: pressureEl.textContent || null, + condition: condEl.textContent || null, + updated_at: updatedEl.textContent || null, + status: statusEl.textContent || null, + }); + } catch (err) { + setStatus("Unavailable", "var(--theme-status-danger)"); + updatedEl.textContent = String(err); + host.setLiveContent({ + kind: 'weather', + tool_name: configuredToolName || 'mcp_home_assistant_GetLiveContext', + provider_prefix: providerPrefix, + temperature: null, + humidity: null, + wind: null, + pressure: null, + condition: null, + updated_at: String(err), + status: 'Unavailable', + error: String(err), + }); + } + }; + + host.setRefreshHandler(() => { + void refresh(); + }); + void refresh(); + __setInterval(() => { + void refresh(); + }, REFRESH_MS); + + return { + destroy() { + host.setRefreshHandler(null); + host.setLiveContent(null); + host.clearSelection(); + for (const cleanup of __cleanup.splice(0)) cleanup(); + }, + }; +} diff --git a/examples/cards/templates/live-weather-01545/template.html b/examples/cards/templates/live-weather-01545/template.html index 51c5774..2249cca 100644 --- a/examples/cards/templates/live-weather-01545/template.html +++ b/examples/cards/templates/live-weather-01545/template.html @@ -1,250 +1,23 @@ -
    +
    -

    Weather 01545

    -
    Source: Home Assistant (weather.openweathermap)
    +

    Weather 01545

    +
    Source: Home Assistant (weather.openweathermap)
    - Loading... + Loading...
    -- - °F + °F
    -
    --
    +
    --
    -
    +
    Humidity: --
    Wind: --
    Pressure: --
    Updated: --
    - diff --git a/examples/cards/templates/sensor-live/card.js b/examples/cards/templates/sensor-live/card.js new file mode 100644 index 0000000..633ab5a --- /dev/null +++ b/examples/cards/templates/sensor-live/card.js @@ -0,0 +1,309 @@ +export function mount({ root, state, host }) { + state = state || {}; + const __cleanup = []; + const __setInterval = (...args) => { + const id = window.setInterval(...args); + __cleanup.push(() => window.clearInterval(id)); + return id; + }; + const __setTimeout = (...args) => { + const id = window.setTimeout(...args); + __cleanup.push(() => window.clearTimeout(id)); + return id; + }; + if (!(root instanceof HTMLElement)) return; + + const subtitleEl = root.querySelector('[data-sensor-subtitle]'); + const valueEl = root.querySelector('[data-sensor-value]'); + const unitEl = root.querySelector('[data-sensor-unit]'); + const statusEl = root.querySelector('[data-sensor-status]'); + const updatedEl = root.querySelector('[data-sensor-updated]'); + if (!(subtitleEl instanceof HTMLElement) || !(valueEl instanceof HTMLElement) || !(unitEl instanceof HTMLElement) || !(statusEl instanceof HTMLElement) || !(updatedEl instanceof HTMLElement)) return; + + const subtitle = typeof state.subtitle === 'string' ? state.subtitle : ''; + const configuredToolName = typeof state.tool_name === 'string' ? state.tool_name.trim() : ''; + const matchName = typeof state.match_name === 'string' ? state.match_name.trim() : ''; + const matchNames = Array.isArray(state.match_names) + ? state.match_names.map((value) => String(value || '').trim()).filter(Boolean) + : []; + const searchTerms = Array.isArray(state.search_terms) + ? state.search_terms.map((value) => String(value || '').trim()).filter(Boolean) + : []; + const deviceClass = typeof state.device_class === 'string' ? state.device_class.trim().toLowerCase() : ''; + const refreshMsRaw = Number(state.refresh_ms); + const refreshMs = Number.isFinite(refreshMsRaw) && refreshMsRaw >= 1000 ? refreshMsRaw : 15000; + const decimalsRaw = Number(state.value_decimals); + const valueDecimals = Number.isFinite(decimalsRaw) && decimalsRaw >= 0 ? decimalsRaw : 0; + const fallbackUnit = typeof state.unit === 'string' ? state.unit : ''; + const thresholds = state && typeof state.thresholds === 'object' && state.thresholds ? state.thresholds : {}; + const goodMax = Number(thresholds.good_max); + const elevatedMax = Number(thresholds.elevated_max); + const alertOnly = state.alert_only === true; + const elevatedScoreRaw = Number(state.alert_score_elevated); + const highScoreRaw = Number(state.alert_score_high); + const elevatedScore = Number.isFinite(elevatedScoreRaw) ? elevatedScoreRaw : 88; + const highScore = Number.isFinite(highScoreRaw) ? highScoreRaw : 98; + + subtitleEl.textContent = subtitle || matchName || matchNames[0] || 'Waiting for sensor data'; + unitEl.textContent = fallbackUnit || '--'; + const updateLiveContent = (snapshot) => { + host.setLiveContent(snapshot); + }; + + const setStatus = (label, color) => { + statusEl.textContent = label; + statusEl.style.color = color; + }; + + const renderValue = (value) => { + if (!Number.isFinite(value)) return '--'; + return valueDecimals > 0 ? value.toFixed(valueDecimals) : String(Math.round(value)); + }; + + const stripQuotes = (value) => { + const text = String(value ?? '').trim(); + if ((text.startsWith("'") && text.endsWith("'")) || (text.startsWith('"') && text.endsWith('"'))) { + return text.slice(1, -1); + } + return text; + }; + + const normalizeText = (value) => String(value || '').trim().toLowerCase(); + + const parseLiveContextEntries = (payloadText) => { + const text = String(payloadText || '').replace(/\r/g, ''); + const startIndex = text.indexOf('- names: '); + const relevant = startIndex >= 0 ? text.slice(startIndex) : text; + const entries = []; + let current = null; + let inAttributes = false; + + const pushCurrent = () => { + if (current) entries.push(current); + current = null; + inAttributes = false; + }; + + for (const rawLine of relevant.split('\n')) { + if (rawLine.startsWith('- names: ')) { + pushCurrent(); + current = { + name: stripQuotes(rawLine.slice(9)), + domain: '', + state: '', + areas: '', + attributes: {}, + }; + continue; + } + if (!current) continue; + const trimmed = rawLine.trim(); + if (!trimmed) continue; + if (trimmed === 'attributes:') { + inAttributes = true; + continue; + } + if (rawLine.startsWith(' domain:')) { + current.domain = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1)); + inAttributes = false; + continue; + } + if (rawLine.startsWith(' state:')) { + current.state = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1)); + inAttributes = false; + continue; + } + if (rawLine.startsWith(' areas:')) { + current.areas = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1)); + inAttributes = false; + continue; + } + if (inAttributes && rawLine.startsWith(' ')) { + const separatorIndex = rawLine.indexOf(':'); + if (separatorIndex >= 0) { + const key = rawLine.slice(4, separatorIndex).trim(); + const value = stripQuotes(rawLine.slice(separatorIndex + 1)); + current.attributes[key] = value; + } + continue; + } + inAttributes = false; + } + + pushCurrent(); + return entries; + }; + + const extractLiveContextText = (toolResult) => { + if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object') { + const parsed = toolResult.parsed; + if (typeof parsed.result === 'string') return parsed.result; + } + if (typeof toolResult?.content === 'string') { + try { + const parsed = JSON.parse(toolResult.content); + if (parsed && typeof parsed === 'object' && typeof parsed.result === 'string') { + return parsed.result; + } + } catch { + return toolResult.content; + } + return toolResult.content; + } + return ''; + }; + + const allMatchNames = [matchName, ...matchNames].filter(Boolean); + const allSearchTerms = [...allMatchNames, ...searchTerms].filter(Boolean); + + const scoreEntry = (entry) => { + if (!entry || normalizeText(entry.domain) !== 'sensor') return Number.NEGATIVE_INFINITY; + const entryName = normalizeText(entry.name); + let score = 0; + for (const candidate of allMatchNames) { + const normalized = normalizeText(candidate); + if (!normalized) continue; + if (entryName === normalized) score += 100; + else if (entryName.includes(normalized)) score += 40; + } + for (const term of allSearchTerms) { + const normalized = normalizeText(term); + if (!normalized) continue; + if (entryName.includes(normalized)) score += 10; + } + const entryDeviceClass = normalizeText(entry.attributes?.device_class); + if (deviceClass && entryDeviceClass === deviceClass) score += 30; + if (fallbackUnit && normalizeText(entry.attributes?.unit_of_measurement) === normalizeText(fallbackUnit)) score += 8; + return score; + }; + + const findSensorEntry = (entries) => { + const scored = entries + .map((entry) => ({ entry, score: scoreEntry(entry) })) + .filter((item) => Number.isFinite(item.score) && item.score > 0) + .sort((left, right) => right.score - left.score); + return scored.length > 0 ? scored[0].entry : null; + }; + + const resolveToolName = async () => { + if (configuredToolName) return configuredToolName; + if (!host.listTools) return 'mcp_home_assistant_GetLiveContext'; + try { + const tools = await host.listTools(); + const liveContextTool = Array.isArray(tools) + ? tools.find((tool) => /(^|_)GetLiveContext$/i.test(String(tool?.name || ''))) + : null; + return liveContextTool?.name || 'mcp_home_assistant_GetLiveContext'; + } catch { + return 'mcp_home_assistant_GetLiveContext'; + } + }; + + const classify = (value) => { + if (!Number.isFinite(value)) return { label: 'Unavailable', color: 'var(--theme-status-danger)' }; + if (Number.isFinite(elevatedMax) && value > elevatedMax) return { label: 'High', color: 'var(--theme-status-danger)' }; + if (Number.isFinite(goodMax) && value > goodMax) return { label: 'Elevated', color: 'var(--theme-status-warning)' }; + return { label: 'Good', color: 'var(--theme-status-live)' }; + }; + + const computeAlertVisibility = (statusLabel) => { + if (!alertOnly) return false; + return statusLabel === 'Good' || statusLabel === 'Unavailable'; + }; + + const computeAlertScore = (statusLabel, value) => { + if (!alertOnly) return Number.isFinite(value) ? 0 : null; + if (statusLabel === 'High') return highScore; + if (statusLabel === 'Elevated') return elevatedScore; + return 0; + }; + + const refresh = async () => { + const resolvedToolName = await resolveToolName(); + if (!resolvedToolName) { + const errorText = 'Missing tool_name'; + valueEl.textContent = '--'; + setStatus('No tool', 'var(--theme-status-danger)'); + updatedEl.textContent = errorText; + updateLiveContent({ + kind: 'sensor', + subtitle: subtitleEl.textContent || null, + tool_name: null, + match_name: matchName || matchNames[0] || null, + value: null, + display_value: '--', + unit: fallbackUnit || null, + status: 'No tool', + updated_at: errorText, + error: errorText, + }); + return; + } + + setStatus('Refreshing', 'var(--theme-status-muted)'); + try { + const toolResult = await host.callTool(resolvedToolName, {}); + const entries = parseLiveContextEntries(extractLiveContextText(toolResult)); + const entry = findSensorEntry(entries); + if (!entry) throw new Error('Matching sensor not found in live context'); + const attrs = entry.attributes && typeof entry.attributes === 'object' ? entry.attributes : {}; + const numericValue = Number(entry.state); + const renderedValue = renderValue(numericValue); + valueEl.textContent = renderedValue; + const unit = fallbackUnit || String(attrs.unit_of_measurement || '--'); + unitEl.textContent = unit; + subtitleEl.textContent = subtitle || entry.name || matchName || matchNames[0] || 'Sensor'; + const status = classify(numericValue); + setStatus(status.label, status.color); + const updatedText = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); + updatedEl.textContent = updatedText; + updateLiveContent({ + kind: 'sensor', + subtitle: subtitleEl.textContent || null, + tool_name: resolvedToolName, + match_name: entry.name || matchName || matchNames[0] || null, + value: Number.isFinite(numericValue) ? numericValue : null, + display_value: renderedValue, + unit, + status: status.label, + updated_at: updatedText, + score: computeAlertScore(status.label, numericValue), + hidden: computeAlertVisibility(status.label), + }); + } catch (error) { + const errorText = String(error); + valueEl.textContent = '--'; + setStatus('Unavailable', 'var(--theme-status-danger)'); + updatedEl.textContent = errorText; + updateLiveContent({ + kind: 'sensor', + subtitle: subtitleEl.textContent || null, + tool_name: resolvedToolName, + match_name: matchName || matchNames[0] || null, + value: null, + display_value: '--', + unit: fallbackUnit || null, + status: 'Unavailable', + updated_at: errorText, + error: errorText, + score: alertOnly ? 0 : null, + hidden: alertOnly, + }); + } + }; + + host.setRefreshHandler(() => { + void refresh(); + }); + void refresh(); + __setInterval(() => { void refresh(); }, refreshMs); + + return { + destroy() { + host.setRefreshHandler(null); + host.setLiveContent(null); + host.clearSelection(); + for (const cleanup of __cleanup.splice(0)) cleanup(); + }, + }; +} diff --git a/examples/cards/templates/sensor-live/manifest.json b/examples/cards/templates/sensor-live/manifest.json index 5a8a396..84f9640 100644 --- a/examples/cards/templates/sensor-live/manifest.json +++ b/examples/cards/templates/sensor-live/manifest.json @@ -1,7 +1,7 @@ { "key": "sensor-live", "title": "Live Sensor", - "notes": "Generic live numeric sensor card. Fill template_state with subtitle, tool_name (defaults to Home Assistant GetLiveContext), match_name or match_names, optional device_class, unit, refresh_ms, value_decimals, and optional thresholds.good_max/elevated_max. The card title comes from the feed header, not the template body.", + "notes": "Generic live numeric sensor card. Fill template_state with subtitle, tool_name (defaults to Home Assistant GetLiveContext), match_name or match_names, optional device_class, unit, refresh_ms, value_decimals, and optional thresholds.good_max/elevated_max. Set alert_only=true to hide the card while readings are good and surface it only when elevated/high, with optional alert_score_elevated/alert_score_high overrides. The card title comes from the feed header, not the template body.", "example_state": { "subtitle": "Home Assistant sensor", "tool_name": "mcp_home_assistant_GetLiveContext", @@ -13,7 +13,8 @@ "thresholds": { "good_max": 900, "elevated_max": 1200 - } + }, + "alert_only": false }, "created_at": "2026-03-11T04:12:48.601255+00:00", "updated_at": "2026-03-11T19:18:04.632189+00:00" diff --git a/examples/cards/templates/sensor-live/template.html b/examples/cards/templates/sensor-live/template.html index 72c4788..5de02b2 100644 --- a/examples/cards/templates/sensor-live/template.html +++ b/examples/cards/templates/sensor-live/template.html @@ -1,287 +1,15 @@ -
    +
    -
    Loading…
    - Loading… +
    Loading…
    + Loading…
    -- - -- + --
    -
    +
    Updated --
    - diff --git a/examples/cards/templates/today-briefing-live/card.js b/examples/cards/templates/today-briefing-live/card.js new file mode 100644 index 0000000..cbcc349 --- /dev/null +++ b/examples/cards/templates/today-briefing-live/card.js @@ -0,0 +1,494 @@ +export function mount({ root, state, host }) { + state = state || {}; + const __cleanup = []; + const __setInterval = (...args) => { + const id = window.setInterval(...args); + __cleanup.push(() => window.clearInterval(id)); + return id; + }; + const __setTimeout = (...args) => { + const id = window.setTimeout(...args); + __cleanup.push(() => window.clearTimeout(id)); + return id; + }; + if (!(root instanceof HTMLElement)) return; + + const dateEl = root.querySelector('[data-today-date]'); + const tempEl = root.querySelector('[data-today-temp]'); + const unitEl = root.querySelector('[data-today-unit]'); + const feelsLikeEl = root.querySelector('[data-today-feels-like]'); + const aqiChipEl = root.querySelector('[data-today-aqi-chip]'); + const countEl = root.querySelector('[data-today-events-count]'); + const emptyEl = root.querySelector('[data-today-empty]'); + const listEl = root.querySelector('[data-today-events-list]'); + const updatedEl = root.querySelector('[data-today-updated]'); + if (!(dateEl instanceof HTMLElement) || !(tempEl instanceof HTMLElement) || !(unitEl instanceof HTMLElement) || !(feelsLikeEl instanceof HTMLElement) || !(aqiChipEl instanceof HTMLElement) || !(countEl instanceof HTMLElement) || !(emptyEl instanceof HTMLElement) || !(listEl instanceof HTMLElement) || !(updatedEl instanceof HTMLElement)) return; + + const configuredWeatherToolName = typeof state.weather_tool_name === 'string' ? state.weather_tool_name.trim() : ''; + const configuredCalendarToolName = typeof state.calendar_tool_name === 'string' ? state.calendar_tool_name.trim() : ''; + const calendarNames = Array.isArray(state.calendar_names) + ? state.calendar_names.map((value) => String(value || '').trim()).filter(Boolean) + : []; + const weatherPrefix = typeof state.weather_prefix === 'string' ? state.weather_prefix.trim() : 'OpenWeatherMap'; + const temperatureName = typeof state.temperature_name === 'string' ? state.temperature_name.trim() : ''; + const apparentTemperatureName = typeof state.apparent_temperature_name === 'string' ? state.apparent_temperature_name.trim() : ''; + const aqiName = typeof state.aqi_name === 'string' ? state.aqi_name.trim() : ''; + const maxEventsRaw = Number(state.max_events); + const maxEvents = Number.isFinite(maxEventsRaw) && maxEventsRaw >= 1 ? Math.min(maxEventsRaw, 8) : 6; + const refreshMsRaw = Number(state.refresh_ms); + const refreshMs = Number.isFinite(refreshMsRaw) && refreshMsRaw >= 60000 ? refreshMsRaw : 15 * 60 * 1000; + const emptyText = typeof state.empty_text === 'string' && state.empty_text.trim() ? state.empty_text.trim() : 'Nothing scheduled today.'; + + emptyEl.textContent = emptyText; + + const updateLiveContent = (snapshot) => { + host.setLiveContent(snapshot); + }; + + const stripQuotes = (value) => { + const text = String(value ?? '').trim(); + if ((text.startsWith("'") && text.endsWith("'")) || (text.startsWith('"') && text.endsWith('"'))) { + return text.slice(1, -1); + } + return text; + }; + + const normalizeText = (value) => String(value || '').trim().toLowerCase(); + + const parseLiveContextEntries = (payloadText) => { + const text = String(payloadText || '').replace(/\r/g, ''); + const startIndex = text.indexOf('- names: '); + const relevant = startIndex >= 0 ? text.slice(startIndex) : text; + const entries = []; + let current = null; + let inAttributes = false; + + const pushCurrent = () => { + if (current) entries.push(current); + current = null; + inAttributes = false; + }; + + for (const rawLine of relevant.split('\n')) { + if (rawLine.startsWith('- names: ')) { + pushCurrent(); + current = { + name: stripQuotes(rawLine.slice(9)), + domain: '', + state: '', + areas: '', + attributes: {}, + }; + continue; + } + if (!current) continue; + const trimmed = rawLine.trim(); + if (!trimmed) continue; + if (trimmed === 'attributes:') { + inAttributes = true; + continue; + } + if (rawLine.startsWith(' domain:')) { + current.domain = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1)); + inAttributes = false; + continue; + } + if (rawLine.startsWith(' state:')) { + current.state = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1)); + inAttributes = false; + continue; + } + if (rawLine.startsWith(' areas:')) { + current.areas = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1)); + inAttributes = false; + continue; + } + if (inAttributes && rawLine.startsWith(' ')) { + const separatorIndex = rawLine.indexOf(':'); + if (separatorIndex >= 0) { + const key = rawLine.slice(4, separatorIndex).trim(); + const value = stripQuotes(rawLine.slice(separatorIndex + 1)); + current.attributes[key] = value; + } + continue; + } + inAttributes = false; + } + + pushCurrent(); + return entries; + }; + + const extractLiveContextText = (toolResult) => { + if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object') { + const parsed = toolResult.parsed; + if (typeof parsed.result === 'string') return parsed.result; + } + if (typeof toolResult?.content === 'string') { + try { + const parsed = JSON.parse(toolResult.content); + if (parsed && typeof parsed === 'object' && typeof parsed.result === 'string') { + return parsed.result; + } + } catch { + return toolResult.content; + } + return toolResult.content; + } + return ''; + }; + + const normalizeDateValue = (value) => { + if (typeof value === 'string') return value; + if (value && typeof value === 'object') { + if (typeof value.dateTime === 'string') return value.dateTime; + if (typeof value.date === 'string') return value.date; + } + return ''; + }; + + const formatHeaderDate = (value) => value.toLocaleDateString([], { weekday: 'long', month: 'short', day: 'numeric' }); + const formatTime = (value) => { + const raw = normalizeDateValue(value); + if (!raw) return '--:--'; + if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) return 'All day'; + const date = new Date(raw); + if (Number.isNaN(date.getTime())) return '--:--'; + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + }; + const formatEventDay = (value) => { + const raw = normalizeDateValue(value); + if (!raw) return '--'; + const date = /^\d{4}-\d{2}-\d{2}$/.test(raw) ? new Date(`${raw}T00:00:00`) : new Date(raw); + if (Number.isNaN(date.getTime())) return '--'; + return date.toLocaleDateString([], { weekday: 'short' }); + }; + const isAllDay = (start, end) => /^\d{4}-\d{2}-\d{2}$/.test(normalizeDateValue(start)) || /^\d{4}-\d{2}-\d{2}$/.test(normalizeDateValue(end)); + + const extractEvents = (toolResult) => { + if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object') { + const parsed = toolResult.parsed; + if (Array.isArray(parsed.result)) return parsed.result; + } + if (typeof toolResult?.content === 'string') { + try { + const parsed = JSON.parse(toolResult.content); + if (parsed && typeof parsed === 'object' && Array.isArray(parsed.result)) { + return parsed.result; + } + } catch { + return []; + } + } + return []; + }; + + const eventTime = (value) => { + const raw = normalizeDateValue(value); + if (!raw) return Number.MAX_SAFE_INTEGER; + const normalized = /^\d{4}-\d{2}-\d{2}$/.test(raw) ? `${raw}T00:00:00` : raw; + const time = new Date(normalized).getTime(); + return Number.isFinite(time) ? time : Number.MAX_SAFE_INTEGER; + }; + + const findEntry = (entries, candidates) => { + const normalizedCandidates = candidates.map((value) => normalizeText(value)).filter(Boolean); + if (normalizedCandidates.length === 0) return null; + const exactMatch = entries.find((entry) => normalizedCandidates.includes(normalizeText(entry.name))); + if (exactMatch) return exactMatch; + return entries.find((entry) => { + const entryName = normalizeText(entry.name); + return normalizedCandidates.some((candidate) => entryName.includes(candidate)); + }) || null; + }; + + const findByDeviceClass = (entries, deviceClass) => entries.find((entry) => normalizeText(entry.attributes?.device_class) === normalizeText(deviceClass)) || null; + const parseNumericValue = (entry) => { + const value = Number(entry?.state); + return Number.isFinite(value) ? value : null; + }; + const formatMetric = (value, unit) => { + if (!Number.isFinite(value)) return '--'; + return `${Math.round(value)}${unit ? ` ${unit}` : ''}`; + }; + + const buildAqiStyle = (aqiValue) => { + if (!Number.isFinite(aqiValue)) { + return { label: 'AQI --', tone: 'Unavailable', background: 'color-mix(in srgb, var(--theme-card-neutral-border) 65%, white)', color: 'var(--theme-card-neutral-subtle)' }; + } + if (aqiValue <= 50) return { label: `AQI ${Math.round(aqiValue)}`, tone: 'Good', background: 'color-mix(in srgb, var(--theme-status-live) 16%, white)', color: 'var(--theme-status-live)' }; + if (aqiValue <= 100) return { label: `AQI ${Math.round(aqiValue)}`, tone: 'Moderate', background: 'color-mix(in srgb, var(--theme-status-warning) 16%, white)', color: 'var(--theme-status-warning)' }; + if (aqiValue <= 150) return { label: `AQI ${Math.round(aqiValue)}`, tone: 'Sensitive', background: 'color-mix(in srgb, var(--theme-status-warning) 24%, white)', color: 'var(--theme-status-warning)' }; + if (aqiValue <= 200) return { label: `AQI ${Math.round(aqiValue)}`, tone: 'Unhealthy', background: 'color-mix(in srgb, var(--theme-status-danger) 14%, white)', color: 'var(--theme-status-danger)' }; + if (aqiValue <= 300) return { label: `AQI ${Math.round(aqiValue)}`, tone: 'Very unhealthy', background: 'color-mix(in srgb, var(--theme-accent) 14%, white)', color: 'var(--theme-accent-strong)' }; + return { label: `AQI ${Math.round(aqiValue)}`, tone: 'Hazardous', background: 'color-mix(in srgb, var(--theme-accent-strong) 18%, white)', color: 'var(--theme-accent-strong)' }; + }; + + const startTime = (value) => { + const raw = normalizeDateValue(value); + if (!raw) return Number.NaN; + const normalized = /^\d{4}-\d{2}-\d{2}$/.test(raw) ? `${raw}T12:00:00` : raw; + const time = new Date(normalized).getTime(); + return Number.isFinite(time) ? time : Number.NaN; + }; + + const computeTodayScore = (events, status) => { + if (status === 'Unavailable') return 5; + if (!Array.isArray(events) || events.length === 0) { + return status === 'Partial' ? 18 : 24; + } + const now = Date.now(); + const soonestMs = events + .map((event) => startTime(event?.start)) + .filter((time) => Number.isFinite(time) && time >= now) + .sort((left, right) => left - right)[0]; + const soonestHours = Number.isFinite(soonestMs) ? (soonestMs - now) / (60 * 60 * 1000) : null; + let score = 34; + if (soonestHours !== null) { + if (soonestHours <= 1) score = 80; + else if (soonestHours <= 3) score = 70; + else if (soonestHours <= 8) score = 58; + else if (soonestHours <= 24) score = 46; + } + score += Math.min(events.length, 3) * 3; + if (status === 'Partial') score -= 8; + return Math.max(0, Math.min(100, Math.round(score))); + }; + + const renderEvents = (events) => { + listEl.innerHTML = ''; + if (!Array.isArray(events) || events.length === 0) { + emptyEl.style.display = 'block'; + countEl.textContent = 'No events'; + return; + } + emptyEl.style.display = 'none'; + countEl.textContent = `${events.length} ${events.length === 1 ? 'event' : 'events'}`; + + for (const [index, event] of events.slice(0, maxEvents).entries()) { + const item = document.createElement('li'); + item.style.padding = index === 0 ? '10px 0 0' : '10px 0 0'; + item.style.borderTop = '1px solid var(--theme-card-neutral-border)'; + + const timing = document.createElement('div'); + timing.style.fontSize = '0.76rem'; + timing.style.lineHeight = '1.2'; + timing.style.textTransform = 'uppercase'; + timing.style.letterSpacing = '0.05em'; + timing.style.color = 'var(--theme-card-neutral-muted)'; + timing.style.fontWeight = '700'; + const timeLabel = isAllDay(event.start, event.end) ? 'All day' : `${formatEventDay(event.start)} · ${formatTime(event.start)}`; + timing.textContent = timeLabel; + item.appendChild(timing); + + const summary = document.createElement('div'); + summary.style.marginTop = '4px'; + summary.style.fontSize = '0.95rem'; + summary.style.lineHeight = '1.35'; + summary.style.color = 'var(--theme-card-neutral-text)'; + summary.style.fontWeight = '700'; + summary.textContent = String(event.summary || '(No title)'); + item.appendChild(summary); + + listEl.appendChild(item); + } + }; + + const resolveToolName = async (configuredName, pattern, fallbackName) => { + if (configuredName) return configuredName; + if (!host.listTools) return fallbackName; + try { + const tools = await host.listTools(); + const tool = Array.isArray(tools) + ? tools.find((item) => pattern.test(String(item?.name || ''))) + : null; + return tool?.name || fallbackName; + } catch { + return fallbackName; + } + }; + + const resolveCalendarNames = async (toolName) => { + if (calendarNames.length > 0) return calendarNames; + if (!host.listTools) return calendarNames; + try { + const tools = await host.listTools(); + const tool = Array.isArray(tools) + ? tools.find((item) => String(item?.name || '') === toolName) + : null; + const enumValues = Array.isArray(tool?.parameters?.properties?.calendar?.enum) + ? tool.parameters.properties.calendar.enum.map((value) => String(value || '').trim()).filter(Boolean) + : []; + return enumValues; + } catch { + return calendarNames; + } + }; + + const loadWeather = async (toolName) => { + const toolResult = await host.callTool(toolName, {}); + const entries = parseLiveContextEntries(extractLiveContextText(toolResult)).filter((entry) => normalizeText(entry.domain) === 'sensor'); + const temperatureEntry = findEntry(entries, [ + temperatureName, + `${weatherPrefix} Temperature`, + ]) || findByDeviceClass(entries, 'temperature'); + const apparentEntry = findEntry(entries, [ + apparentTemperatureName, + `${weatherPrefix} Apparent temperature`, + `${weatherPrefix} Feels like`, + ]); + const aqiEntry = findEntry(entries, [ + aqiName, + 'Air quality index', + ]) || findByDeviceClass(entries, 'aqi'); + + return { + toolName, + temperature: parseNumericValue(temperatureEntry), + temperatureUnit: String(temperatureEntry?.attributes?.unit_of_measurement || state.unit || '°F'), + feelsLike: parseNumericValue(apparentEntry), + feelsLikeUnit: String(apparentEntry?.attributes?.unit_of_measurement || temperatureEntry?.attributes?.unit_of_measurement || state.unit || '°F'), + aqi: parseNumericValue(aqiEntry), + }; + }; + + const loadEvents = async (toolName) => { + const selectedCalendars = await resolveCalendarNames(toolName); + if (!toolName) throw new Error('Calendar tool unavailable'); + if (!Array.isArray(selectedCalendars) || selectedCalendars.length === 0) { + throw new Error('No calendars configured'); + } + + const start = new Date(); + start.setHours(0, 0, 0, 0); + const end = new Date(start); + end.setHours(23, 59, 59, 999); + const endExclusiveTime = end.getTime() + 1; + const allEvents = []; + + for (const calendarName of selectedCalendars) { + const toolResult = await host.callTool(toolName, { + calendar: calendarName, + range: 'today', + }); + const events = extractEvents(toolResult); + for (const event of events) { + const startTime = eventTime(event?.start); + if (startTime < start.getTime() || startTime >= endExclusiveTime) continue; + allEvents.push({ ...event, _calendarName: calendarName }); + } + } + + allEvents.sort((left, right) => eventTime(left?.start) - eventTime(right?.start)); + return { + toolName, + calendarNames: selectedCalendars, + events: allEvents.slice(0, maxEvents), + }; + }; + + const refresh = async () => { + const now = new Date(); + dateEl.textContent = formatHeaderDate(now); + + const [weatherToolName, calendarToolName] = await Promise.all([ + resolveToolName(configuredWeatherToolName, /(^|_)GetLiveContext$/i, 'mcp_home_assistant_GetLiveContext'), + resolveToolName(configuredCalendarToolName, /(^|_)calendar_get_events$/i, 'mcp_home_assistant_calendar_get_events'), + ]); + + const [weatherResult, eventsResult] = await Promise.allSettled([ + loadWeather(weatherToolName), + loadEvents(calendarToolName), + ]); + + const snapshot = { + kind: 'today_briefing', + date_label: dateEl.textContent || null, + weather_tool_name: weatherToolName || null, + calendar_tool_name: calendarToolName || null, + updated_at: null, + status: null, + weather: null, + events: [], + errors: {}, + }; + + let successCount = 0; + + if (weatherResult.status === 'fulfilled') { + successCount += 1; + const weather = weatherResult.value; + tempEl.textContent = Number.isFinite(weather.temperature) ? String(Math.round(weather.temperature)) : '--'; + unitEl.textContent = weather.temperatureUnit || '°F'; + feelsLikeEl.textContent = formatMetric(weather.feelsLike, weather.feelsLikeUnit); + const aqiStyle = buildAqiStyle(weather.aqi); + aqiChipEl.textContent = `${aqiStyle.tone} · ${aqiStyle.label}`; + aqiChipEl.style.background = aqiStyle.background; + aqiChipEl.style.color = aqiStyle.color; + snapshot.weather = { + temperature: Number.isFinite(weather.temperature) ? Math.round(weather.temperature) : null, + temperature_unit: weather.temperatureUnit || null, + feels_like: Number.isFinite(weather.feelsLike) ? Math.round(weather.feelsLike) : null, + aqi: Number.isFinite(weather.aqi) ? Math.round(weather.aqi) : null, + aqi_tone: aqiStyle.tone, + }; + } else { + tempEl.textContent = '--'; + unitEl.textContent = '°F'; + feelsLikeEl.textContent = '--'; + aqiChipEl.textContent = 'AQI unavailable'; + aqiChipEl.style.background = 'color-mix(in srgb, var(--theme-card-neutral-border) 65%, white)'; + aqiChipEl.style.color = 'var(--theme-card-neutral-subtle)'; + snapshot.errors.weather = String(weatherResult.reason); + } + + if (eventsResult.status === 'fulfilled') { + successCount += 1; + const eventsData = eventsResult.value; + renderEvents(eventsData.events); + snapshot.events = eventsData.events.map((event) => ({ + summary: String(event.summary || '(No title)'), + start: normalizeDateValue(event.start) || null, + end: normalizeDateValue(event.end) || null, + all_day: isAllDay(event.start, event.end), + })); + } else { + renderEvents([]); + countEl.textContent = 'Unavailable'; + emptyEl.style.display = 'block'; + emptyEl.textContent = 'Calendar unavailable.'; + snapshot.errors.events = String(eventsResult.reason); + } + + const updatedText = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + updatedEl.textContent = updatedText; + snapshot.updated_at = updatedText; + + if (successCount === 2) { + snapshot.status = 'Ready'; + } else if (successCount === 1) { + snapshot.status = 'Partial'; + } else { + snapshot.status = 'Unavailable'; + } + + snapshot.score = computeTodayScore(snapshot.events, snapshot.status); + + updateLiveContent(snapshot); + }; + + host.setRefreshHandler(() => { + void refresh(); + }); + void refresh(); + __setInterval(() => { void refresh(); }, refreshMs); + + return { + destroy() { + host.setRefreshHandler(null); + host.setLiveContent(null); + host.clearSelection(); + for (const cleanup of __cleanup.splice(0)) cleanup(); + }, + }; +} diff --git a/examples/cards/templates/today-briefing-live/manifest.json b/examples/cards/templates/today-briefing-live/manifest.json new file mode 100644 index 0000000..32b9a2b --- /dev/null +++ b/examples/cards/templates/today-briefing-live/manifest.json @@ -0,0 +1,21 @@ +{ + "key": "today-briefing-live", + "title": "Today Briefing", + "notes": "Single-card day overview for local use. Fill template_state with weather_tool_name (defaults to Home Assistant GetLiveContext), calendar_tool_name (defaults to calendar_get_events), calendar_names, weather_prefix or exact sensor names for temperature/apparent/AQI, max_events, refresh_ms, and empty_text.", + "example_state": { + "weather_tool_name": "mcp_home_assistant_GetLiveContext", + "calendar_tool_name": "mcp_home_assistant_calendar_get_events", + "calendar_names": [ + "Family Calendar" + ], + "weather_prefix": "OpenWeatherMap", + "temperature_name": "OpenWeatherMap Temperature", + "apparent_temperature_name": "OpenWeatherMap Apparent temperature", + "aqi_name": "Worcester Summer St Air quality index", + "max_events": 6, + "refresh_ms": 900000, + "empty_text": "Nothing scheduled today." + }, + "created_at": "2026-03-15T22:35:00+00:00", + "updated_at": "2026-03-15T22:35:00+00:00" +} diff --git a/examples/cards/templates/today-briefing-live/template.html b/examples/cards/templates/today-briefing-live/template.html new file mode 100644 index 0000000..560b39f --- /dev/null +++ b/examples/cards/templates/today-briefing-live/template.html @@ -0,0 +1,32 @@ +
    +
    Today
    + +
    +
    Weather
    + +
    + -- + °F +
    + +
    + Feels like + -- +
    + +
    + AQI -- +
    +
    + +
    +
    +
    Agenda
    +
    --
    +
    +
    Nothing scheduled today.
    +
      +
      + +
      Updated --
      +
      diff --git a/examples/cards/templates/todo-item-live/card.js b/examples/cards/templates/todo-item-live/card.js new file mode 100644 index 0000000..e2dc2fd --- /dev/null +++ b/examples/cards/templates/todo-item-live/card.js @@ -0,0 +1,708 @@ +const TASK_LANES = ["backlog", "committed", "in-progress", "blocked", "done", "canceled"]; + +const TASK_ACTION_LABELS = { + backlog: "Backlog", + committed: "Commit", + "in-progress": "Start", + blocked: "Block", + done: "Done", + canceled: "Cancel", +}; + +const TASK_LANE_LABELS = { + backlog: "Backlog", + committed: "Committed", + "in-progress": "In Progress", + blocked: "Blocked", + done: "Done", + canceled: "Canceled", +}; + +const TASK_LANE_THEMES = { + backlog: { + accent: "#5f7884", + accentSoft: "rgba(95, 120, 132, 0.13)", + muted: "#6b7e87", + buttonInk: "#294a57", + }, + committed: { + accent: "#8a6946", + accentSoft: "rgba(138, 105, 70, 0.14)", + muted: "#7f664a", + buttonInk: "#5a3b19", + }, + "in-progress": { + accent: "#4f7862", + accentSoft: "rgba(79, 120, 98, 0.13)", + muted: "#5e7768", + buttonInk: "#214437", + }, + blocked: { + accent: "#a55f4b", + accentSoft: "rgba(165, 95, 75, 0.13)", + muted: "#906659", + buttonInk: "#6c2f21", + }, + done: { + accent: "#6d7f58", + accentSoft: "rgba(109, 127, 88, 0.12)", + muted: "#6b755d", + buttonInk: "#304121", + }, + canceled: { + accent: "#7b716a", + accentSoft: "rgba(123, 113, 106, 0.12)", + muted: "#7b716a", + buttonInk: "#433932", + }, +}; + +function isTaskLane(value) { + return typeof value === "string" && TASK_LANES.includes(value); +} + +function normalizeTag(raw) { + const trimmed = String(raw || "") + .trim() + .replace(/^#+/, "") + .replace(/\s+/g, "-"); + return trimmed ? `#${trimmed}` : ""; +} + +function normalizeTags(raw) { + if (!Array.isArray(raw)) return []; + const seen = new Set(); + const tags = []; + for (const value of raw) { + const tag = normalizeTag(value); + const key = tag.toLowerCase(); + if (!tag || seen.has(key)) continue; + seen.add(key); + tags.push(tag); + } + return tags; +} + +function normalizeMetadata(raw) { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {}; + const entries = Object.entries(raw).filter(([, value]) => value !== undefined); + return Object.fromEntries(entries); +} + +function normalizeTask(raw, fallbackTitle) { + const record = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {}; + const lane = isTaskLane(record.lane) ? record.lane : "backlog"; + return { + taskPath: typeof record.task_path === "string" ? record.task_path.trim() : "", + taskKey: typeof record.task_key === "string" ? record.task_key.trim() : "", + title: + typeof record.title === "string" && record.title.trim() + ? record.title.trim() + : fallbackTitle || "(Untitled task)", + lane, + created: typeof record.created === "string" ? record.created.trim() : "", + updated: typeof record.updated === "string" ? record.updated.trim() : "", + due: typeof record.due === "string" ? record.due.trim() : "", + tags: normalizeTags(record.tags), + body: typeof record.body === "string" ? record.body : "", + metadata: normalizeMetadata(record.metadata), + }; +} + +function normalizeTaskFromPayload(raw, fallback) { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return fallback; + return { + taskPath: typeof raw.path === "string" ? raw.path.trim() : fallback.taskPath, + taskKey: fallback.taskKey, + title: + typeof raw.title === "string" && raw.title.trim() + ? raw.title.trim() + : fallback.title, + lane: isTaskLane(raw.lane) ? raw.lane : fallback.lane, + created: typeof raw.created === "string" ? raw.created.trim() : fallback.created, + updated: typeof raw.updated === "string" ? raw.updated.trim() : fallback.updated, + due: typeof raw.due === "string" ? raw.due.trim() : fallback.due, + tags: normalizeTags(raw.tags), + body: typeof raw.body === "string" ? raw.body : fallback.body, + metadata: normalizeMetadata(raw.metadata), + }; +} + +function parseToolPayload(result) { + if (result && typeof result === "object" && result.parsed && typeof result.parsed === "object") { + return result.parsed; + } + const raw = typeof result?.content === "string" ? result.content : ""; + if (!raw.trim()) return null; + try { + const parsed = JSON.parse(raw); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null; + } catch { + return null; + } +} + +function dueScore(hoursUntilDue) { + if (hoursUntilDue <= 0) return 100; + if (hoursUntilDue <= 6) return 96; + if (hoursUntilDue <= 24) return 92; + if (hoursUntilDue <= 72) return 82; + if (hoursUntilDue <= 168) return 72; + return 62; +} + +function ageScore(ageDays) { + if (ageDays >= 30) return 80; + if (ageDays >= 21) return 76; + if (ageDays >= 14) return 72; + if (ageDays >= 7) return 68; + if (ageDays >= 3) return 62; + if (ageDays >= 1) return 58; + return 54; +} + +function computeTaskScore(task) { + const now = Date.now(); + const rawDue = task.due ? (task.due.includes("T") ? task.due : `${task.due}T12:00:00`) : ""; + const dueMs = rawDue ? new Date(rawDue).getTime() : Number.NaN; + let score = 54; + if (Number.isFinite(dueMs)) { + score = dueScore((dueMs - now) / (60 * 60 * 1000)); + } else { + const createdMs = task.created ? new Date(task.created).getTime() : Number.NaN; + if (Number.isFinite(createdMs)) { + score = ageScore(Math.max(0, (now - createdMs) / (24 * 60 * 60 * 1000))); + } + } + if (task.lane === "committed") return Math.min(100, score + 1); + if (task.lane === "blocked") return Math.min(100, score + 4); + if (task.lane === "in-progress") return Math.min(100, score + 2); + return score; +} + +function summarizeTaskBody(task) { + const trimmed = String(task.body || "").trim(); + if (!trimmed || /^##\s+Imported\b/i.test(trimmed)) return ""; + return trimmed; +} + +function renderTaskBodyMarkdown(host, body) { + if (!body) return ""; + return body + .replace(/\r\n?/g, "\n") + .trim() + .split("\n") + .map((line) => { + const trimmed = line.trim(); + if (!trimmed) return ''; + + let className = "task-card-ui__md-line"; + let content = trimmed; + let prefix = ""; + + const headingMatch = trimmed.match(/^(#{1,6})\s+(.*)$/); + if (headingMatch) { + className += " task-card-ui__md-line--heading"; + content = headingMatch[2]; + } else if (/^[-*]\s+/.test(trimmed)) { + className += " task-card-ui__md-line--bullet"; + content = trimmed.replace(/^[-*]\s+/, ""); + prefix = "\u2022 "; + } else if (/^\d+\.\s+/.test(trimmed)) { + className += " task-card-ui__md-line--bullet"; + content = trimmed.replace(/^\d+\.\s+/, ""); + prefix = "\u2022 "; + } else if (/^>\s+/.test(trimmed)) { + className += " task-card-ui__md-line--quote"; + content = trimmed.replace(/^>\s+/, ""); + prefix = "> "; + } + + const html = host.renderMarkdown(content, { inline: true }); + return `${ + prefix ? `${prefix}` : "" + }${html}`; + }) + .join(""); +} + +function formatTaskDue(task) { + if (!task.due) return ""; + const raw = task.due.includes("T") ? task.due : `${task.due}T00:00:00`; + const parsed = new Date(raw); + if (Number.isNaN(parsed.getTime())) return task.due; + if (task.due.includes("T")) { + const label = parsed.toLocaleString([], { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }); + return label.replace(/\s([AP]M)$/i, "$1"); + } + return parsed.toLocaleDateString([], { month: "short", day: "numeric" }); +} + +function taskMoveOptions(lane) { + return TASK_LANES.filter((targetLane) => targetLane !== lane).map((targetLane) => ({ + lane: targetLane, + label: TASK_ACTION_LABELS[targetLane], + })); +} + +function taskLiveContent(task, errorText) { + return { + kind: "file_task", + exists: true, + task_path: task.taskPath || null, + task_key: task.taskKey || null, + title: task.title || null, + lane: task.lane, + created: task.created || null, + updated: task.updated || null, + due: task.due || null, + tags: task.tags, + metadata: task.metadata, + score: computeTaskScore(task), + status: task.lane, + error: errorText || null, + }; +} + +function autosizeEditor(editor) { + editor.style.height = "0px"; + editor.style.height = `${Math.max(editor.scrollHeight, 20)}px`; +} + +export function mount({ root, item, state, host }) { + const cardEl = root.querySelector(".task-card-ui"); + const laneToggleEl = root.querySelector(".task-card-ui__lane-button"); + const laneWrapEl = root.querySelector(".task-card-ui__lane-wrap"); + const laneMenuEl = root.querySelector(".task-card-ui__lane-menu"); + const statusEl = root.querySelector(".task-card-ui__status"); + const titleEl = root.querySelector(".task-card-ui__title-slot"); + const tagsEl = root.querySelector(".task-card-ui__tags"); + const bodyEl = root.querySelector(".task-card-ui__body-slot"); + const metaEl = root.querySelector(".task-card-ui__meta"); + const dueEl = root.querySelector(".task-card-ui__chip"); + + if ( + !(cardEl instanceof HTMLElement) || + !(laneToggleEl instanceof HTMLButtonElement) || + !(laneWrapEl instanceof HTMLElement) || + !(laneMenuEl instanceof HTMLElement) || + !(statusEl instanceof HTMLElement) || + !(titleEl instanceof HTMLElement) || + !(tagsEl instanceof HTMLElement) || + !(bodyEl instanceof HTMLElement) || + !(metaEl instanceof HTMLElement) || + !(dueEl instanceof HTMLElement) + ) { + return; + } + + let task = normalizeTask(state, item.title); + let busy = false; + let errorText = ""; + let statusLabel = ""; + let statusKind = "neutral"; + let laneMenuOpen = false; + let editingField = null; + let holdTimer = null; + + const clearHoldTimer = () => { + if (holdTimer !== null) { + window.clearTimeout(holdTimer); + holdTimer = null; + } + }; + + const setStatus = (label, kind) => { + statusLabel = label || ""; + statusKind = kind || "neutral"; + statusEl.textContent = statusLabel; + statusEl.className = `task-card-ui__status${statusKind === "error" ? " is-error" : ""}`; + }; + + const publishLiveContent = () => { + host.setLiveContent(taskLiveContent(task, errorText)); + }; + + const closeLaneMenu = () => { + laneMenuOpen = false; + laneToggleEl.setAttribute("aria-expanded", "false"); + laneMenuEl.style.display = "none"; + }; + + const openLaneMenu = () => { + if (busy || !task.taskPath) return; + laneMenuOpen = true; + laneToggleEl.setAttribute("aria-expanded", "true"); + laneMenuEl.style.display = "flex"; + }; + + const refreshFeed = () => { + closeLaneMenu(); + host.requestFeedRefresh(); + }; + + const setBusy = (nextBusy) => { + busy = !!nextBusy; + laneToggleEl.disabled = busy || !task.taskPath; + titleEl.style.pointerEvents = busy ? "none" : ""; + bodyEl.style.pointerEvents = busy ? "none" : ""; + tagsEl + .querySelectorAll("button") + .forEach((button) => (button.disabled = busy || (!task.taskPath && button.dataset.role !== "noop"))); + laneMenuEl.querySelectorAll("button").forEach((button) => { + button.disabled = busy; + }); + }; + + const runBusyAction = async (action) => { + setBusy(true); + errorText = ""; + setStatus("Saving", "neutral"); + try { + await action(); + setStatus("", "neutral"); + } catch (error) { + console.error("Task card action failed", error); + errorText = error instanceof Error ? error.message : String(error); + setStatus("Unavailable", "error"); + } finally { + setBusy(false); + render(); + } + }; + + const callTaskBoard = async (argumentsValue) => { + const result = await host.callTool("task_board", argumentsValue); + const payload = parseToolPayload(result); + if (payload && typeof payload.error === "string" && payload.error.trim()) { + throw new Error(payload.error); + } + return payload; + }; + + const moveTask = async (lane) => + runBusyAction(async () => { + const payload = await callTaskBoard({ action: "move", task: task.taskPath, lane }); + const nextTask = normalizeTaskFromPayload(payload?.task, { + ...task, + lane, + taskPath: typeof payload?.task_path === "string" ? payload.task_path.trim() : task.taskPath, + updated: new Date().toISOString(), + }); + task = nextTask; + refreshFeed(); + }); + + const editField = async (field, rawValue) => { + const nextValue = rawValue.trim(); + const currentValue = field === "title" ? task.title : task.body; + if (field === "title" && !nextValue) return false; + if (nextValue === currentValue) { + editingField = null; + render(); + return true; + } + await runBusyAction(async () => { + const payload = await callTaskBoard({ + action: "edit", + task: task.taskPath, + ...(field === "title" ? { title: nextValue } : { description: nextValue }), + }); + task = normalizeTaskFromPayload(payload?.task, { + ...task, + ...(field === "title" ? { title: nextValue } : { body: nextValue }), + }); + editingField = null; + refreshFeed(); + }); + return true; + }; + + const addTag = async () => { + const raw = window.prompt("Add tag to task", ""); + const tag = raw == null ? "" : normalizeTag(raw); + if (!tag) return; + await runBusyAction(async () => { + const payload = await callTaskBoard({ + action: "add_tag", + task: task.taskPath, + tags: [tag], + }); + task = normalizeTaskFromPayload(payload?.task, { + ...task, + tags: Array.from(new Set([...task.tags, tag])), + }); + refreshFeed(); + }); + }; + + const removeTag = async (tag) => + runBusyAction(async () => { + const payload = await callTaskBoard({ + action: "remove_tag", + task: task.taskPath, + tags: [tag], + }); + task = normalizeTaskFromPayload(payload?.task, { + ...task, + tags: task.tags.filter((value) => value !== tag), + }); + refreshFeed(); + }); + + const beginTitleEdit = () => { + if (!task.taskPath || busy || editingField) return; + closeLaneMenu(); + editingField = "title"; + render(); + }; + + const beginBodyEdit = () => { + if (!task.taskPath || busy || editingField) return; + closeLaneMenu(); + editingField = "body"; + render(); + }; + + const renderInlineEditor = (targetEl, field, value, placeholder) => { + targetEl.innerHTML = ""; + const editor = document.createElement("textarea"); + editor.className = `${field === "title" ? "task-card-ui__title" : "task-card-ui__body"} task-card-ui__editor`; + editor.rows = field === "title" ? 1 : Math.max(1, value.split("\n").length); + editor.value = value; + if (placeholder) editor.placeholder = placeholder; + editor.disabled = busy; + targetEl.appendChild(editor); + autosizeEditor(editor); + + const cancel = () => { + editingField = null; + render(); + }; + + editor.addEventListener("input", () => { + autosizeEditor(editor); + }); + + editor.addEventListener("keydown", (event) => { + if (event.key === "Escape") { + event.preventDefault(); + cancel(); + return; + } + if (field === "title" && event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + editor.blur(); + return; + } + if ((event.metaKey || event.ctrlKey) && event.key === "Enter") { + event.preventDefault(); + editor.blur(); + } + }); + + editor.addEventListener("blur", () => { + if (editingField !== field) return; + void editField(field, editor.value); + }); + + requestAnimationFrame(() => { + editor.focus(); + const end = editor.value.length; + editor.setSelectionRange(end, end); + }); + }; + + const renderTags = () => { + tagsEl.innerHTML = ""; + task.tags.forEach((tag) => { + const button = document.createElement("button"); + button.className = "task-card-ui__tag"; + button.type = "button"; + button.textContent = tag; + button.title = `Hold to remove ${tag}`; + button.disabled = busy; + button.addEventListener("pointerdown", (event) => { + if (event.pointerType === "mouse" && event.button !== 0) return; + clearHoldTimer(); + button.classList.add("is-holding"); + holdTimer = window.setTimeout(() => { + holdTimer = null; + button.classList.remove("is-holding"); + if (window.confirm(`Remove ${tag} from this task?`)) { + void removeTag(tag); + } + }, 650); + }); + ["pointerup", "pointerleave", "pointercancel"].forEach((eventName) => { + button.addEventListener(eventName, () => { + clearHoldTimer(); + button.classList.remove("is-holding"); + }); + }); + button.addEventListener("contextmenu", (event) => { + event.preventDefault(); + }); + button.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + }); + tagsEl.appendChild(button); + }); + + const addButton = document.createElement("button"); + addButton.className = "task-card-ui__tag task-card-ui__tag--action"; + addButton.type = "button"; + addButton.textContent = "+"; + addButton.title = "Add tag"; + addButton.disabled = busy || !task.taskPath; + addButton.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + void addTag(); + }); + tagsEl.appendChild(addButton); + }; + + const renderLaneMenu = () => { + laneMenuEl.innerHTML = ""; + if (!task.taskPath) { + closeLaneMenu(); + return; + } + taskMoveOptions(task.lane).forEach((option) => { + const button = document.createElement("button"); + button.className = "task-card-ui__lane-menu-item"; + button.type = "button"; + button.textContent = option.label; + button.disabled = busy; + button.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + void moveTask(option.lane); + }); + laneMenuEl.appendChild(button); + }); + laneMenuEl.style.display = laneMenuOpen ? "flex" : "none"; + }; + + const applyTheme = () => { + const theme = TASK_LANE_THEMES[task.lane] || TASK_LANE_THEMES.backlog; + cardEl.style.setProperty("--task-accent", theme.accent); + cardEl.style.setProperty("--task-accent-soft", theme.accentSoft); + cardEl.style.setProperty("--task-muted", theme.muted); + cardEl.style.setProperty("--task-button-ink", theme.buttonInk); + }; + + const render = () => { + applyTheme(); + laneToggleEl.textContent = ""; + const laneLabelEl = document.createElement("span"); + laneLabelEl.className = "task-card-ui__lane"; + laneLabelEl.textContent = TASK_LANE_LABELS[task.lane] || "Task"; + const caretEl = document.createElement("span"); + caretEl.className = `task-card-ui__lane-caret${laneMenuOpen ? " open" : ""}`; + caretEl.textContent = "▾"; + laneToggleEl.append(laneLabelEl, caretEl); + laneToggleEl.disabled = busy || !task.taskPath; + laneToggleEl.setAttribute("aria-expanded", laneMenuOpen ? "true" : "false"); + + setStatus(statusLabel, statusKind); + + if (editingField === "title") { + renderInlineEditor(titleEl, "title", task.title, ""); + } else { + titleEl.innerHTML = ""; + const button = document.createElement("button"); + button.className = "task-card-ui__title task-card-ui__text-button"; + button.type = "button"; + button.disabled = busy || !task.taskPath; + button.textContent = task.title || "(Untitled task)"; + button.addEventListener("click", beginTitleEdit); + titleEl.appendChild(button); + } + + const bodySummary = summarizeTaskBody(task); + if (editingField === "body") { + renderInlineEditor(bodyEl, "body", task.body, "Add description"); + } else { + bodyEl.innerHTML = ""; + const button = document.createElement("button"); + button.className = `task-card-ui__body task-card-ui__text-button task-card-ui__body-markdown${ + bodySummary ? "" : " is-placeholder" + }`; + button.type = "button"; + button.disabled = busy || !task.taskPath; + const inner = document.createElement("span"); + inner.className = "task-card-ui__body-markdown-inner"; + inner.innerHTML = bodySummary ? renderTaskBodyMarkdown(host, bodySummary) : "Add description"; + button.appendChild(inner); + button.addEventListener("click", beginBodyEdit); + bodyEl.appendChild(button); + } + + renderTags(); + renderLaneMenu(); + + const dueText = formatTaskDue(task); + dueEl.textContent = dueText; + metaEl.style.display = dueText ? "flex" : "none"; + + publishLiveContent(); + setBusy(busy); + }; + + const handleLaneToggle = (event) => { + event.preventDefault(); + event.stopPropagation(); + if (laneMenuOpen) closeLaneMenu(); + else openLaneMenu(); + render(); + }; + + const handleDocumentPointerDown = (event) => { + if (!laneMenuOpen) return; + if (!(event.target instanceof Node)) return; + if (laneWrapEl.contains(event.target)) return; + closeLaneMenu(); + render(); + }; + + const handleEscape = (event) => { + if (event.key !== "Escape" || !laneMenuOpen) return; + closeLaneMenu(); + render(); + }; + + laneToggleEl.addEventListener("click", handleLaneToggle); + document.addEventListener("pointerdown", handleDocumentPointerDown); + document.addEventListener("keydown", handleEscape); + + render(); + + return { + update({ item: nextItem, state: nextState }) { + task = normalizeTask(nextState, nextItem.title); + errorText = ""; + statusLabel = ""; + statusKind = "neutral"; + laneMenuOpen = false; + editingField = null; + render(); + }, + destroy() { + clearHoldTimer(); + document.removeEventListener("pointerdown", handleDocumentPointerDown); + document.removeEventListener("keydown", handleEscape); + laneToggleEl.removeEventListener("click", handleLaneToggle); + host.setRefreshHandler(null); + host.setLiveContent(null); + host.clearSelection(); + }, + }; +} diff --git a/examples/cards/templates/todo-item-live/template.html b/examples/cards/templates/todo-item-live/template.html index f96f326..bfa4c7e 100644 --- a/examples/cards/templates/todo-item-live/template.html +++ b/examples/cards/templates/todo-item-live/template.html @@ -1,1035 +1,17 @@ - - -
      -
      -
      - - -
      +
      +
      +
      +
      + + +
      +
      - -
      Loading...
      -
      -
      - -
      -
      +
      +
      +
      + -
      - - diff --git a/examples/cards/templates/upcoming-conditions-live/assets/BlexMonoNerdFontMono-Regular.ttf b/examples/cards/templates/upcoming-conditions-live/assets/BlexMonoNerdFontMono-Regular.ttf new file mode 100644 index 0000000..6e32991 Binary files /dev/null and b/examples/cards/templates/upcoming-conditions-live/assets/BlexMonoNerdFontMono-Regular.ttf differ diff --git a/examples/cards/templates/upcoming-conditions-live/card.js b/examples/cards/templates/upcoming-conditions-live/card.js new file mode 100644 index 0000000..19bce62 --- /dev/null +++ b/examples/cards/templates/upcoming-conditions-live/card.js @@ -0,0 +1,410 @@ +export function mount({ root, state, host }) { + state = state || {}; + const __cleanup = []; + const __setInterval = (...args) => { + const id = window.setInterval(...args); + __cleanup.push(() => window.clearInterval(id)); + return id; + }; + const __setTimeout = (...args) => { + const id = window.setTimeout(...args); + __cleanup.push(() => window.clearTimeout(id)); + return id; + }; + if (!(root instanceof HTMLElement)) return; + + const emptyEl = root.querySelector('[data-upcoming-empty]'); + const listEl = root.querySelector('[data-upcoming-list]'); + if (!(emptyEl instanceof HTMLElement) || !(listEl instanceof HTMLElement)) return; + + const configuredCalendarToolName = typeof state.calendar_tool_name === 'string' ? state.calendar_tool_name.trim() : ''; + const configuredForecastToolName = typeof state.forecast_tool_name === 'string' ? state.forecast_tool_name.trim() : 'exec'; + const forecastCommand = typeof state.forecast_command === 'string' ? state.forecast_command.trim() : ''; + const calendarNames = Array.isArray(state.calendar_names) + ? state.calendar_names.map((value) => String(value || '').trim()).filter(Boolean) + : []; + const eventWindowHoursRaw = Number(state.event_window_hours); + const eventWindowHours = Number.isFinite(eventWindowHoursRaw) && eventWindowHoursRaw >= 1 ? Math.min(eventWindowHoursRaw, 168) : 36; + const maxEventsRaw = Number(state.max_events); + const maxEvents = Number.isFinite(maxEventsRaw) && maxEventsRaw >= 1 ? Math.min(maxEventsRaw, 8) : 4; + const refreshMsRaw = Number(state.refresh_ms); + const refreshMs = Number.isFinite(refreshMsRaw) && refreshMsRaw >= 60000 ? refreshMsRaw : 15 * 60 * 1000; + const emptyText = typeof state.empty_text === 'string' && state.empty_text.trim() + ? state.empty_text.trim() + : `No upcoming events in the next ${eventWindowHours} hours.`; + + emptyEl.textContent = emptyText; + + const updateLiveContent = (snapshot) => { + host.setLiveContent(snapshot); + }; + + const normalizeDateValue = (value) => { + if (typeof value === 'string') return value; + if (value && typeof value === 'object') { + if (typeof value.dateTime === 'string') return value.dateTime; + if (typeof value.date === 'string') return value.date; + } + return ''; + }; + + const isAllDay = (start, end) => /^\d{4}-\d{2}-\d{2}$/.test(normalizeDateValue(start)) || /^\d{4}-\d{2}-\d{2}$/.test(normalizeDateValue(end)); + + const eventTime = (value, allDay = false) => { + const raw = normalizeDateValue(value); + if (!raw) return Number.NaN; + const normalized = /^\d{4}-\d{2}-\d{2}$/.test(raw) + ? `${raw}T${allDay ? '12:00:00' : '00:00:00'}` + : raw; + return new Date(normalized).getTime(); + }; + + const formatEventLabel = (event) => { + const raw = normalizeDateValue(event?.start); + if (!raw) return '--'; + if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) { + const date = new Date(`${raw}T12:00:00`); + return `${date.toLocaleDateString([], { weekday: 'short' })} · all day`; + } + const date = new Date(raw); + if (Number.isNaN(date.getTime())) return '--'; + return date.toLocaleString([], { + weekday: 'short', + hour: 'numeric', + minute: '2-digit', + }); + }; + + const extractEvents = (toolResult) => { + if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object') { + const parsed = toolResult.parsed; + if (Array.isArray(parsed.result)) return parsed.result; + } + if (typeof toolResult?.content === 'string') { + try { + const parsed = JSON.parse(toolResult.content); + if (parsed && typeof parsed === 'object' && Array.isArray(parsed.result)) { + return parsed.result; + } + } catch { + return []; + } + } + return []; + }; + + const stripExecFooter = (value) => String(value || '').replace(/\n+\s*Exit code:\s*\d+\s*$/i, '').trim(); + + const extractExecJson = (toolResult) => { + const parsedText = stripExecFooter(toolResult?.content); + if (!parsedText) return null; + try { + return JSON.parse(parsedText); + } catch { + return null; + } + }; + + const resolveCalendarToolConfig = async () => { + const fallbackName = configuredCalendarToolName || 'mcp_home_assistant_calendar_get_events'; + if (!host.listTools) { + return { name: fallbackName, availableCalendars: calendarNames }; + } + try { + const tools = await host.listTools(); + const tool = Array.isArray(tools) + ? tools.find((item) => /(^|_)calendar_get_events$/i.test(String(item?.name || ''))) + : null; + const enumValues = Array.isArray(tool?.parameters?.properties?.calendar?.enum) + ? tool.parameters.properties.calendar.enum.map((value) => String(value || '').trim()).filter(Boolean) + : []; + return { + name: tool?.name || fallbackName, + availableCalendars: enumValues, + }; + } catch { + return { name: fallbackName, availableCalendars: calendarNames }; + } + }; + + const resolveUpcomingEvents = async (toolConfig) => { + const now = Date.now(); + const windowEnd = now + eventWindowHours * 60 * 60 * 1000; + const selectedCalendars = calendarNames.length > 0 ? calendarNames : toolConfig.availableCalendars; + if (!toolConfig.name) throw new Error('Calendar tool unavailable'); + if (!Array.isArray(selectedCalendars) || selectedCalendars.length === 0) { + throw new Error('No calendars configured'); + } + + const upcomingEvents = []; + for (const calendarName of selectedCalendars) { + const toolResult = await host.callTool(toolConfig.name, { + calendar: calendarName, + range: 'week', + }); + const events = extractEvents(toolResult); + for (const event of events) { + const allDay = isAllDay(event?.start, event?.end); + const startTime = eventTime(event?.start, allDay); + if (!Number.isFinite(startTime) || startTime < now || startTime > windowEnd) continue; + upcomingEvents.push({ ...event, _calendarName: calendarName, _allDay: allDay, _startTime: startTime }); + } + } + + upcomingEvents.sort((left, right) => left._startTime - right._startTime); + return upcomingEvents.slice(0, maxEvents); + }; + + const resolveForecastBundle = async () => { + if (!forecastCommand) throw new Error('Missing forecast_command'); + const toolResult = await host.callTool(configuredForecastToolName || 'exec', { + command: forecastCommand, + max_output_chars: 200000, + }); + const payload = extractExecJson(toolResult); + if (!payload || typeof payload !== 'object') { + throw new Error('Invalid forecast payload'); + } + return payload; + }; + + const forecastTime = (entry) => { + const time = new Date(String(entry?.datetime || '')).getTime(); + return Number.isFinite(time) ? time : Number.NaN; + }; + + const nearestForecast = (entries, targetTime) => { + if (!Array.isArray(entries) || entries.length === 0 || !Number.isFinite(targetTime)) return null; + let bestEntry = null; + let bestDistance = Number.POSITIVE_INFINITY; + for (const entry of entries) { + const entryTime = forecastTime(entry); + if (!Number.isFinite(entryTime)) continue; + const distance = Math.abs(entryTime - targetTime); + if (distance < bestDistance) { + bestDistance = distance; + bestEntry = entry; + } + } + return bestDistance <= 3 * 60 * 60 * 1000 ? bestEntry : null; + }; + + const computeUpcomingScore = (events) => { + if (!Array.isArray(events) || events.length === 0) return 0; + const now = Date.now(); + const soonestMs = events + .map((event) => Number(event?._startTime)) + .filter((time) => Number.isFinite(time) && time >= now) + .sort((left, right) => left - right)[0]; + const soonestHours = Number.isFinite(soonestMs) ? (soonestMs - now) / (60 * 60 * 1000) : null; + let score = 44; + if (soonestHours !== null) { + if (soonestHours <= 1) score = 100; + else if (soonestHours <= 3) score = 97; + else if (soonestHours <= 8) score = 94; + else if (soonestHours <= 24) score = 90; + else if (soonestHours <= 36) score = 86; + else if (soonestHours <= 48) score = 82; + else if (soonestHours <= 72) score = 76; + else if (soonestHours <= 168) score = 62; + } + score += Math.min(events.length, 3); + return Math.max(0, Math.min(100, Math.round(score))); + }; + + const metricValue = (value, fallback = '--') => { + if (value === null || value === undefined || value === '') return fallback; + return String(value); + }; + + const createMetricCell = (glyph, label, value) => { + const cell = document.createElement('div'); + cell.style.display = 'flex'; + cell.style.alignItems = 'baseline'; + cell.style.columnGap = '3px'; + cell.style.flex = '0 0 auto'; + cell.style.minWidth = '0'; + cell.style.whiteSpace = 'nowrap'; + + cell.title = label; + + const glyphEl = document.createElement('div'); + glyphEl.style.fontSize = '0.54rem'; + glyphEl.style.lineHeight = '1'; + glyphEl.style.color = 'var(--theme-card-neutral-muted)'; + glyphEl.style.fontWeight = '700'; + glyphEl.style.fontFamily = "'BlexMono Nerd Font Mono', monospace"; + glyphEl.style.flex = '0 0 auto'; + glyphEl.textContent = glyph; + cell.appendChild(glyphEl); + + const valueEl = document.createElement('div'); + valueEl.style.fontSize = '0.53rem'; + valueEl.style.lineHeight = '1.1'; + valueEl.style.color = 'var(--theme-card-neutral-text)'; + valueEl.style.fontWeight = '700'; + valueEl.style.fontFamily = "'BlexMono Nerd Font Mono', monospace"; + valueEl.style.whiteSpace = 'nowrap'; + valueEl.style.overflow = 'hidden'; + valueEl.style.textOverflow = 'ellipsis'; + valueEl.style.textAlign = 'right'; + valueEl.style.flex = '1 1 auto'; + valueEl.textContent = metricValue(value); + cell.appendChild(valueEl); + return cell; + }; + + const renderEvents = (items, temperatureUnit, windSpeedUnit) => { + listEl.innerHTML = ''; + if (!Array.isArray(items) || items.length === 0) { + emptyEl.style.display = 'block'; + return; + } + + emptyEl.style.display = 'none'; + + for (const [index, item] of items.entries()) { + const event = item.event; + const forecast = item.forecast; + + const entry = document.createElement('li'); + entry.style.padding = index === 0 ? '8px 0 0' : '8px 0 0'; + entry.style.borderTop = '1px solid var(--theme-card-neutral-border)'; + + const whenEl = document.createElement('div'); + whenEl.style.fontSize = '0.72rem'; + whenEl.style.lineHeight = '1.2'; + whenEl.style.letterSpacing = '0.02em'; + whenEl.style.color = 'var(--theme-card-neutral-muted)'; + whenEl.style.fontWeight = '700'; + whenEl.textContent = formatEventLabel(event); + entry.appendChild(whenEl); + + const titleEl = document.createElement('div'); + titleEl.style.marginTop = '3px'; + titleEl.style.fontSize = '0.9rem'; + titleEl.style.lineHeight = '1.25'; + titleEl.style.color = 'var(--theme-card-neutral-text)'; + titleEl.style.fontWeight = '700'; + titleEl.style.whiteSpace = 'normal'; + titleEl.style.wordBreak = 'break-word'; + titleEl.textContent = String(event.summary || '(No title)'); + entry.appendChild(titleEl); + + const detailGrid = document.createElement('div'); + detailGrid.style.marginTop = '4px'; + detailGrid.style.display = 'flex'; + detailGrid.style.flexWrap = 'nowrap'; + detailGrid.style.alignItems = 'baseline'; + detailGrid.style.gap = '6px'; + detailGrid.style.overflowX = 'auto'; + detailGrid.style.overflowY = 'hidden'; + detailGrid.style.scrollbarWidth = 'none'; + detailGrid.style.msOverflowStyle = 'none'; + detailGrid.style.webkitOverflowScrolling = 'touch'; + + const tempValue = Number.isFinite(Number(forecast?.temperature)) + ? `${Math.round(Number(forecast.temperature))}${temperatureUnit || ''}` + : null; + const windValue = Number.isFinite(Number(forecast?.wind_speed)) + ? `${Math.round(Number(forecast.wind_speed))}${windSpeedUnit || ''}` + : null; + const rainValue = Number.isFinite(Number(forecast?.precipitation_probability)) + ? `${Math.round(Number(forecast.precipitation_probability))}%` + : null; + const uvValue = Number.isFinite(Number(forecast?.uv_index)) + ? `${Math.round(Number(forecast.uv_index))}` + : null; + + detailGrid.appendChild(createMetricCell('\uf2c9', 'Temperature', tempValue)); + detailGrid.appendChild(createMetricCell('\uef16', 'Wind', windValue)); + detailGrid.appendChild(createMetricCell('\uf043', 'Rain', rainValue)); + detailGrid.appendChild(createMetricCell('\uf522', 'UV', uvValue)); + entry.appendChild(detailGrid); + + listEl.appendChild(entry); + } + }; + + const refresh = async () => { + const snapshot = { + kind: 'upcoming_conditions', + event_window_hours: eventWindowHours, + updated_at: null, + events: [], + errors: {}, + }; + + try { + const [toolConfig, forecastBundle] = await Promise.all([ + resolveCalendarToolConfig(), + resolveForecastBundle(), + ]); + const events = await resolveUpcomingEvents(toolConfig); + + const nwsSource = forecastBundle?.nws && typeof forecastBundle.nws === 'object' ? forecastBundle.nws : null; + const uvSource = forecastBundle?.uv && typeof forecastBundle.uv === 'object' ? forecastBundle.uv : null; + const temperatureUnit = String(nwsSource?.temperature_unit || uvSource?.temperature_unit || '°F'); + const windSpeedUnit = String(nwsSource?.wind_speed_unit || uvSource?.wind_speed_unit || 'mph'); + const mergedItems = events.map((event) => { + const nwsForecast = nearestForecast(nwsSource?.forecast, event._startTime); + const uvForecast = nearestForecast(uvSource?.forecast, event._startTime); + return { + event, + forecast: { + datetime: nwsForecast?.datetime || uvForecast?.datetime || null, + condition: nwsForecast?.condition || uvForecast?.condition || null, + temperature: nwsForecast?.temperature ?? uvForecast?.temperature ?? null, + apparent_temperature: uvForecast?.apparent_temperature ?? null, + precipitation_probability: nwsForecast?.precipitation_probability ?? uvForecast?.precipitation_probability ?? null, + wind_speed: nwsForecast?.wind_speed ?? uvForecast?.wind_speed ?? null, + uv_index: uvForecast?.uv_index ?? null, + }, + }; + }); + + renderEvents(mergedItems, temperatureUnit, windSpeedUnit); + + snapshot.events = mergedItems.map((item) => ({ + summary: String(item.event.summary || '(No title)'), + start: normalizeDateValue(item.event.start) || null, + end: normalizeDateValue(item.event.end) || null, + all_day: Boolean(item.event._allDay), + calendar_name: String(item.event._calendarName || ''), + forecast_time: item.forecast.datetime || null, + condition: item.forecast.condition || null, + temperature: Number.isFinite(Number(item.forecast.temperature)) ? Number(item.forecast.temperature) : null, + apparent_temperature: Number.isFinite(Number(item.forecast.apparent_temperature)) ? Number(item.forecast.apparent_temperature) : null, + precipitation_probability: Number.isFinite(Number(item.forecast.precipitation_probability)) ? Number(item.forecast.precipitation_probability) : null, + wind_speed: Number.isFinite(Number(item.forecast.wind_speed)) ? Number(item.forecast.wind_speed) : null, + uv_index: Number.isFinite(Number(item.forecast.uv_index)) ? Number(item.forecast.uv_index) : null, + })); + snapshot.score = computeUpcomingScore(events); + } catch (error) { + listEl.innerHTML = ''; + emptyEl.style.display = 'block'; + emptyEl.textContent = String(error); + snapshot.errors.load = String(error); + snapshot.score = 0; + } + + const updatedText = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + snapshot.updated_at = updatedText; + updateLiveContent(snapshot); + }; + + host.setRefreshHandler(() => { + void refresh(); + }); + void refresh(); + __setInterval(() => { void refresh(); }, refreshMs); + + return { + destroy() { + host.setRefreshHandler(null); + host.setLiveContent(null); + host.clearSelection(); + for (const cleanup of __cleanup.splice(0)) cleanup(); + }, + }; +} diff --git a/examples/cards/templates/upcoming-conditions-live/manifest.json b/examples/cards/templates/upcoming-conditions-live/manifest.json new file mode 100644 index 0000000..1ecd86a --- /dev/null +++ b/examples/cards/templates/upcoming-conditions-live/manifest.json @@ -0,0 +1,19 @@ +{ + "key": "upcoming-conditions-live", + "title": "Upcoming Events", + "notes": "Upcoming event card with raw event-time forecast context. Fill template_state with calendar_tool_name (defaults to calendar_get_events), calendar_names, forecast_tool_name (defaults to exec), forecast_command, event_window_hours, max_events, refresh_ms, and empty_text. The card joins calendar events to the nearest hourly forecast rows without generating suggestions.", + "example_state": { + "calendar_tool_name": "mcp_home_assistant_calendar_get_events", + "calendar_names": [ + "Family Calendar" + ], + "forecast_tool_name": "exec", + "forecast_command": "python3 /home/kacper/nanobot/scripts/card_upcoming_conditions.py --nws-entity weather.korh --uv-entity weather.openweathermap_2 --forecast-type hourly --limit 48", + "event_window_hours": 36, + "max_events": 3, + "refresh_ms": 900000, + "empty_text": "No upcoming events in the next 36 hours." + }, + "created_at": "2026-03-16T14:00:00+00:00", + "updated_at": "2026-03-16T14:00:00+00:00" +} diff --git a/examples/cards/templates/upcoming-conditions-live/template.html b/examples/cards/templates/upcoming-conditions-live/template.html new file mode 100644 index 0000000..67a68e7 --- /dev/null +++ b/examples/cards/templates/upcoming-conditions-live/template.html @@ -0,0 +1,23 @@ + +
      +
      Upcoming Events
      + +
      No upcoming events.
      +
        +
        diff --git a/examples/cards/templates/weather-live/card.js b/examples/cards/templates/weather-live/card.js new file mode 100644 index 0000000..d039eb3 --- /dev/null +++ b/examples/cards/templates/weather-live/card.js @@ -0,0 +1,356 @@ +export function mount({ root, state, host }) { + state = state || {}; + const __cleanup = []; + const __setInterval = (...args) => { + const id = window.setInterval(...args); + __cleanup.push(() => window.clearInterval(id)); + return id; + }; + const __setTimeout = (...args) => { + const id = window.setTimeout(...args); + __cleanup.push(() => window.clearTimeout(id)); + return id; + }; + if (!(root instanceof HTMLElement)) return; + + const subtitleEl = root.querySelector('[data-weather-subtitle]'); + const tempEl = root.querySelector('[data-weather-temp]'); + const unitEl = root.querySelector('[data-weather-unit]'); + const humidityEl = root.querySelector('[data-weather-humidity]'); + const windEl = root.querySelector('[data-weather-wind]'); + const rainEl = root.querySelector('[data-weather-rain]'); + const uvEl = root.querySelector('[data-weather-uv]'); + const statusEl = root.querySelector('[data-weather-status]'); + if (!(subtitleEl instanceof HTMLElement) || !(tempEl instanceof HTMLElement) || !(unitEl instanceof HTMLElement) || !(humidityEl instanceof HTMLElement) || !(windEl instanceof HTMLElement) || !(rainEl instanceof HTMLElement) || !(uvEl instanceof HTMLElement) || !(statusEl instanceof HTMLElement)) return; + + const subtitle = typeof state.subtitle === 'string' ? state.subtitle : ''; + const configuredToolName = typeof state.tool_name === 'string' ? state.tool_name.trim() : ''; + const configuredForecastToolName = typeof state.forecast_tool_name === 'string' ? state.forecast_tool_name.trim() : 'exec'; + const forecastCommand = typeof state.forecast_command === 'string' ? state.forecast_command.trim() : ''; + const providerPrefix = typeof state.provider_prefix === 'string' ? state.provider_prefix.trim() : ''; + const temperatureName = typeof state.temperature_name === 'string' ? state.temperature_name.trim() : ''; + const humidityName = typeof state.humidity_name === 'string' ? state.humidity_name.trim() : ''; + const uvName = typeof state.uv_name === 'string' ? state.uv_name.trim() : ''; + const morningStartHourRaw = Number(state.morning_start_hour); + const morningEndHourRaw = Number(state.morning_end_hour); + const morningScoreRaw = Number(state.morning_score); + const defaultScoreRaw = Number(state.default_score); + const refreshMsRaw = Number(state.refresh_ms); + const refreshMs = Number.isFinite(refreshMsRaw) && refreshMsRaw >= 60000 ? refreshMsRaw : 24 * 60 * 60 * 1000; + const morningStartHour = Number.isFinite(morningStartHourRaw) ? morningStartHourRaw : 6; + const morningEndHour = Number.isFinite(morningEndHourRaw) ? morningEndHourRaw : 11; + const morningScore = Number.isFinite(morningScoreRaw) ? morningScoreRaw : 84; + const defaultScore = Number.isFinite(defaultScoreRaw) ? defaultScoreRaw : 38; + + subtitleEl.textContent = subtitle || providerPrefix || 'Waiting for weather data'; + const updateLiveContent = (snapshot) => { + host.setLiveContent(snapshot); + }; + + const setStatus = (label, color) => { + statusEl.textContent = label; + statusEl.style.color = color; + }; + + const stripQuotes = (value) => { + const text = String(value ?? '').trim(); + if ((text.startsWith("'") && text.endsWith("'")) || (text.startsWith('"') && text.endsWith('"'))) { + return text.slice(1, -1); + } + return text; + }; + + const normalizeText = (value) => String(value || '').trim().toLowerCase(); + + const parseLiveContextEntries = (payloadText) => { + const text = String(payloadText || '').replace(/\r/g, ''); + const startIndex = text.indexOf('- names: '); + const relevant = startIndex >= 0 ? text.slice(startIndex) : text; + const entries = []; + let current = null; + let inAttributes = false; + + const pushCurrent = () => { + if (current) entries.push(current); + current = null; + inAttributes = false; + }; + + for (const rawLine of relevant.split('\n')) { + if (rawLine.startsWith('- names: ')) { + pushCurrent(); + current = { + name: stripQuotes(rawLine.slice(9)), + domain: '', + state: '', + areas: '', + attributes: {}, + }; + continue; + } + if (!current) continue; + const trimmed = rawLine.trim(); + if (!trimmed) continue; + if (trimmed === 'attributes:') { + inAttributes = true; + continue; + } + if (rawLine.startsWith(' domain:')) { + current.domain = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1)); + inAttributes = false; + continue; + } + if (rawLine.startsWith(' state:')) { + current.state = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1)); + inAttributes = false; + continue; + } + if (rawLine.startsWith(' areas:')) { + current.areas = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1)); + inAttributes = false; + continue; + } + if (inAttributes && rawLine.startsWith(' ')) { + const separatorIndex = rawLine.indexOf(':'); + if (separatorIndex >= 0) { + const key = rawLine.slice(4, separatorIndex).trim(); + const value = stripQuotes(rawLine.slice(separatorIndex + 1)); + current.attributes[key] = value; + } + continue; + } + inAttributes = false; + } + + pushCurrent(); + return entries; + }; + + const extractLiveContextText = (toolResult) => { + if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object') { + const parsed = toolResult.parsed; + if (typeof parsed.result === 'string') return parsed.result; + } + if (typeof toolResult?.content === 'string') { + try { + const parsed = JSON.parse(toolResult.content); + if (parsed && typeof parsed === 'object' && typeof parsed.result === 'string') { + return parsed.result; + } + } catch { + return toolResult.content; + } + return toolResult.content; + } + return ''; + }; + + const stripExecFooter = (value) => String(value || '').replace(/\n+\s*Exit code:\s*\d+\s*$/i, '').trim(); + + const extractExecJson = (toolResult) => { + const parsedText = stripExecFooter(toolResult?.content); + if (!parsedText) return null; + try { + return JSON.parse(parsedText); + } catch { + return null; + } + }; + + const resolveToolName = async () => { + if (configuredToolName) return configuredToolName; + if (!host.listTools) return 'mcp_home_assistant_GetLiveContext'; + try { + const tools = await host.listTools(); + const liveContextTool = Array.isArray(tools) + ? tools.find((tool) => /(^|_)GetLiveContext$/i.test(String(tool?.name || ''))) + : null; + return liveContextTool?.name || 'mcp_home_assistant_GetLiveContext'; + } catch { + return 'mcp_home_assistant_GetLiveContext'; + } + }; + + const findEntry = (entries, candidates) => { + const normalizedCandidates = candidates.map((value) => normalizeText(value)).filter(Boolean); + if (normalizedCandidates.length === 0) return null; + const exactMatch = entries.find((entry) => normalizedCandidates.includes(normalizeText(entry.name))); + if (exactMatch) return exactMatch; + return entries.find((entry) => { + const entryName = normalizeText(entry.name); + return normalizedCandidates.some((candidate) => entryName.includes(candidate)); + }) || null; + }; + + const resolveForecastBundle = async () => { + if (!forecastCommand) return null; + const toolResult = await host.callTool(configuredForecastToolName || 'exec', { + command: forecastCommand, + max_output_chars: 200000, + }); + const payload = extractExecJson(toolResult); + return payload && typeof payload === 'object' ? payload : null; + }; + + const firstForecastEntry = (bundle, key, metricKey = '') => { + const source = bundle && typeof bundle === 'object' ? bundle[key] : null; + const forecast = source && typeof source === 'object' && Array.isArray(source.forecast) ? source.forecast : []; + if (!metricKey) { + return forecast.length > 0 && forecast[0] && typeof forecast[0] === 'object' ? forecast[0] : null; + } + return forecast.find((entry) => entry && typeof entry === 'object' && entry[metricKey] !== null && entry[metricKey] !== undefined) || null; + }; + + const estimateUvIndex = (cloudCoverage) => { + const now = new Date(); + const hour = now.getHours() + now.getMinutes() / 60; + const daylightPhase = Math.sin(((hour - 6) / 12) * Math.PI); + if (!Number.isFinite(daylightPhase) || daylightPhase <= 0) return 0; + const normalizedCloudCoverage = Number.isFinite(cloudCoverage) + ? Math.min(Math.max(cloudCoverage, 0), 100) + : null; + const cloudFactor = normalizedCloudCoverage === null + ? 1 + : Math.max(0.2, 1 - normalizedCloudCoverage * 0.0065); + return Math.max(0, Math.round(7 * daylightPhase * cloudFactor)); + }; + + const computeWeatherScore = () => { + const now = new Date(); + const hour = now.getHours() + now.getMinutes() / 60; + if (hour >= morningStartHour && hour < morningEndHour) return morningScore; + return defaultScore; + }; + + const refresh = async () => { + const resolvedToolName = await resolveToolName(); + if (!resolvedToolName) { + const errorText = 'Missing tool_name'; + setStatus('No tool', 'var(--theme-status-danger)'); + updateLiveContent({ + kind: 'weather', + subtitle: subtitleEl.textContent || null, + tool_name: null, + temperature: null, + temperature_unit: String(state.unit || '°F'), + humidity: null, + wind: null, + rain: null, + uv: null, + status: 'No tool', + error: errorText, + }); + return; + } + + setStatus('Refreshing', 'var(--theme-status-muted)'); + try { + const [toolResult, forecastBundle] = await Promise.all([ + host.callTool(resolvedToolName, {}), + resolveForecastBundle(), + ]); + const entries = parseLiveContextEntries(extractLiveContextText(toolResult)).filter((entry) => normalizeText(entry.domain) === 'sensor'); + const prefix = providerPrefix || 'OpenWeatherMap'; + const temperatureEntry = findEntry(entries, [ + temperatureName, + `${prefix} Temperature`, + ]); + const humidityEntry = findEntry(entries, [ + humidityName, + `${prefix} Humidity`, + ]); + const uvSensorEntry = findEntry(entries, [ + uvName, + `${prefix} UV index`, + ]); + + const temperature = Number(temperatureEntry?.state); + tempEl.textContent = Number.isFinite(temperature) ? String(Math.round(temperature)) : '--'; + unitEl.textContent = String(temperatureEntry?.attributes?.unit_of_measurement || state.unit || '°F'); + + const humidity = Number(humidityEntry?.state); + humidityEl.textContent = Number.isFinite(humidity) ? `${Math.round(humidity)}%` : '--'; + + const nwsEntry = firstForecastEntry(forecastBundle, 'nws'); + const uvEntry = firstForecastEntry(forecastBundle, 'uv', 'uv_index'); + const nwsSource = forecastBundle && typeof forecastBundle === 'object' && forecastBundle.nws && typeof forecastBundle.nws === 'object' ? forecastBundle.nws : null; + const uvSource = forecastBundle && typeof forecastBundle === 'object' && forecastBundle.uv && typeof forecastBundle.uv === 'object' ? forecastBundle.uv : null; + + const windSpeed = Number(nwsEntry?.wind_speed); + const windUnit = String(nwsSource?.wind_speed_unit || 'mph'); + windEl.textContent = Number.isFinite(windSpeed) ? `${Math.round(windSpeed)} ${windUnit}` : '--'; + + const rainChance = Number(nwsEntry?.precipitation_probability); + rainEl.textContent = Number.isFinite(rainChance) ? `${Math.round(rainChance)}%` : '--'; + + const liveUvValue = Number(uvSensorEntry?.state); + const forecastUvValue = Number(uvEntry?.uv_index); + const cloudCoverage = Number.isFinite(Number(nwsEntry?.cloud_coverage)) + ? Number(nwsEntry?.cloud_coverage) + : Number(uvSource?.forecast?.[0]?.cloud_coverage); + const estimatedUvValue = estimateUvIndex(cloudCoverage); + const uvValue = Number.isFinite(liveUvValue) + ? liveUvValue + : (Number.isFinite(forecastUvValue) ? forecastUvValue : estimatedUvValue); + const uvEstimated = !Number.isFinite(liveUvValue) && !Number.isFinite(forecastUvValue); + uvEl.textContent = Number.isFinite(uvValue) + ? `${uvEstimated && uvValue > 0 ? '~' : ''}${Math.round(uvValue)}` + : '--'; + + subtitleEl.textContent = subtitle || prefix || 'Weather'; + setStatus('Live', 'var(--theme-status-live)'); + updateLiveContent({ + kind: 'weather', + subtitle: subtitleEl.textContent || null, + tool_name: resolvedToolName, + temperature: Number.isFinite(temperature) ? Math.round(temperature) : null, + temperature_unit: unitEl.textContent || null, + humidity: Number.isFinite(humidity) ? Math.round(humidity) : null, + wind: windEl.textContent || null, + rain: rainEl.textContent || null, + uv: Number.isFinite(uvValue) ? Math.round(uvValue) : null, + uv_estimated: uvEstimated, + score: computeWeatherScore(), + status: 'Live', + }); + } catch (error) { + const errorText = String(error); + setStatus('Unavailable', 'var(--theme-status-danger)'); + tempEl.textContent = '--'; + unitEl.textContent = String(state.unit || '°F'); + humidityEl.textContent = '--'; + windEl.textContent = '--'; + rainEl.textContent = '--'; + uvEl.textContent = '--'; + updateLiveContent({ + kind: 'weather', + subtitle: subtitleEl.textContent || null, + tool_name: resolvedToolName, + temperature: null, + temperature_unit: unitEl.textContent || null, + humidity: null, + wind: null, + rain: null, + uv: null, + score: computeWeatherScore(), + status: 'Unavailable', + error: errorText, + }); + } + }; + + host.setRefreshHandler(() => { + void refresh(); + }); + void refresh(); + __setInterval(() => { void refresh(); }, refreshMs); + + return { + destroy() { + host.setRefreshHandler(null); + host.setLiveContent(null); + host.clearSelection(); + for (const cleanup of __cleanup.splice(0)) cleanup(); + }, + }; +} diff --git a/examples/cards/templates/weather-live/manifest.json b/examples/cards/templates/weather-live/manifest.json index 40bcf75..9b53720 100644 --- a/examples/cards/templates/weather-live/manifest.json +++ b/examples/cards/templates/weather-live/manifest.json @@ -1,7 +1,7 @@ { "key": "weather-live", "title": "Live Weather", - "notes": "Live weather summary card. Fill template_state with subtitle, tool_name (defaults to Home Assistant GetLiveContext), provider_prefix or exact sensor names, optional condition_label, and refresh_ms. Wind and pressure render when matching sensors exist in the live context payload.", + "notes": "Live weather summary card. Fill template_state with subtitle, tool_name (defaults to Home Assistant GetLiveContext), provider_prefix or exact sensor names, optional uv_name, optional condition_label, optional morning_start_hour/morning_end_hour/morning_score/default_score, and refresh_ms. Wind and pressure render when matching sensors exist in the live context payload. If a live UV reading is unavailable, the card falls back to a clearly approximate current UV estimate.", "example_state": { "subtitle": "Weather", "tool_name": "mcp_home_assistant_GetLiveContext", @@ -10,8 +10,13 @@ "provider_prefix": "OpenWeatherMap", "temperature_name": "OpenWeatherMap Temperature", "humidity_name": "OpenWeatherMap Humidity", + "uv_name": "OpenWeatherMap UV index", "condition_label": "Weather", - "refresh_ms": 86400000 + "morning_start_hour": 6, + "morning_end_hour": 11, + "morning_score": 84, + "default_score": 38, + "refresh_ms": 300000 }, "created_at": "2026-03-11T04:12:48.601255+00:00", "updated_at": "2026-03-11T19:18:04.632189+00:00" diff --git a/examples/cards/templates/weather-live/template.html b/examples/cards/templates/weather-live/template.html index 925deb6..3010bc9 100644 --- a/examples/cards/templates/weather-live/template.html +++ b/examples/cards/templates/weather-live/template.html @@ -1,4 +1,4 @@ -
        +
        -
        Loading…
        - Loading… +
        Loading…
        + Loading…
        -- - °F + °F
        -
        󰖎
        -
        --
        +
        󰖎
        +
        --
        -
        -
        --
        +
        +
        --
        -
        -
        --
        +
        +
        --
        -
        -
        --
        +
        +
        --
        - diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d0eade0..a692716 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,512 +1,159 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks"; -import { AgentIndicator } from "./components/AgentIndicator"; -import { CardFeed } from "./components/CardFeed"; -import { ControlBar, VoiceStatus } from "./components/Controls"; -import { LogPanel } from "./components/LogPanel"; -import { useAudioMeter } from "./hooks/useAudioMeter"; -import { usePTT } from "./hooks/usePTT"; +import { useState } from "preact/hooks"; +import { + AgentWorkspace, + FeedWorkspace, + SwipeWorkspace, + useAppPresentation, + useSessionDrawerEdgeSwipe, +} from "./appShell/presentation"; +import { VoiceStatus } from "./components/Controls"; +import { SessionDrawer } from "./components/SessionDrawer"; +import { WorkbenchOverlay } from "./components/WorkbenchOverlay"; import { useWebRTC } from "./hooks/useWebRTC"; -import type { - AgentState, - CardItem, - CardMessageMetadata, - CardSelectionRange, - JsonValue, - LogLine, -} from "./types"; +import type { ThemeName, ThemeOption } from "./theme/themes"; +import { useThemePreference } from "./theme/useThemePreference"; -const SWIPE_THRESHOLD_PX = 64; -const SWIPE_DIRECTION_RATIO = 1.15; -const CARD_SELECTION_EVENT = "nanobot:card-selection-change"; -type WorkspaceView = "agent" | "feed"; - -interface AppRtcActions { - connect(): Promise; - sendJson( - msg: - | { type: "command"; command: string } - | { type: "card-response"; card_id: string; value: string } - | { type: "voice-ptt"; pressed: boolean; metadata?: CardMessageMetadata }, - ): void; - setTextOnly(enabled: boolean): void; - sendTextMessage(text: string, metadata?: CardMessageMetadata): Promise; - connected: boolean; - connecting: boolean; -} - -function toNullableNumber(value: JsonValue | undefined): number | null { - return typeof value === "number" && Number.isFinite(value) ? value : null; -} - -function readCardSelection(cardId: string | null | undefined): CardSelectionRange | null { - const raw = window.__nanobotGetCardSelection?.(cardId); - if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null; - const record = raw as Record; - if (record.kind !== "git_diff_range") return null; - if (typeof record.file_label !== "string" || typeof record.range_label !== "string") return null; - - const filePath = typeof record.file_path === "string" ? record.file_path : record.file_label; - const label = - typeof record.label === "string" - ? record.label - : `${record.file_label} · ${record.range_label}`; - - return { - kind: "git_diff_range", - file_path: filePath, - file_label: record.file_label, - range_label: record.range_label, - label, - old_start: toNullableNumber(record.old_start), - old_end: toNullableNumber(record.old_end), - new_start: toNullableNumber(record.new_start), - new_end: toNullableNumber(record.new_end), - }; -} - -function buildCardMetadata(card: CardItem): CardMessageMetadata { - const metadata: CardMessageMetadata = { - card_id: card.serverId, - card_slot: card.slot, - card_title: card.title, - card_lane: card.lane, - card_template_key: card.templateKey, - card_context_summary: card.contextSummary, - card_response_value: card.responseValue, - }; - const selection = card.serverId ? readCardSelection(card.serverId) : null; - if (selection) { - metadata.card_selection = selection as unknown as JsonValue; - metadata.card_selection_label = selection.label; - } - const liveContent = card.serverId - ? window.__nanobotGetCardLiveContent?.(card.serverId) - : undefined; - if (liveContent !== undefined) metadata.card_live_content = liveContent as JsonValue; - return metadata; -} - -function AgentCardContext({ - card, - selection, - onClear, -}: { - card: CardItem; - selection: CardSelectionRange | null; - onClear(): void; -}) { - const label = selection ? "Using diff context" : "Using card"; - const title = selection?.file_label || card.title; - const meta = selection?.range_label || ""; - - return ( -
        -
        {label}
        -
        -
        -
        {title}
        - {meta &&
        {meta}
        } -
        - -
        -
        - ); -} - -function useSwipeHandlers( - composing: boolean, - view: WorkspaceView, - setView: (view: WorkspaceView) => void, - isInteractiveTarget: (target: EventTarget | null) => boolean, -) { - const swipeStartRef = useRef<{ x: number; y: number } | null>(null); - - const onSwipeStart = useCallback( - (e: Event) => { - const pe = e as PointerEvent; - if (composing) return; - if (pe.pointerType === "mouse" && pe.button !== 0) return; - if (isInteractiveTarget(pe.target)) return; - swipeStartRef.current = { x: pe.clientX, y: pe.clientY }; - }, - [composing, isInteractiveTarget], - ); - - const onSwipeEnd = useCallback( - (e: Event) => { - const pe = e as PointerEvent; - const start = swipeStartRef.current; - swipeStartRef.current = null; - if (!start || composing) return; - const dx = pe.clientX - start.x; - const dy = pe.clientY - start.y; - if (Math.abs(dx) < SWIPE_THRESHOLD_PX) return; - if (Math.abs(dx) < Math.abs(dy) * SWIPE_DIRECTION_RATIO) return; - if (view === "agent" && dx < 0) setView("feed"); - if (view === "feed" && dx > 0) setView("agent"); - }, - [composing, view, setView], - ); - - return { onSwipeStart, onSwipeEnd }; -} - -function useCardActions( - setView: (view: WorkspaceView) => void, - setSelectedCardId: (cardId: string | null) => void, -) { - const handleAskCard = useCallback( - (card: CardItem) => { - if (!card.serverId) return; - setSelectedCardId(card.serverId); - setView("agent"); - }, - [setSelectedCardId, setView], - ); - - return { handleAskCard }; -} - -function useGlobalPointerBindings({ - handlePointerDown, - handlePointerMove, - handlePointerUp, -}: { - handlePointerDown: (event: Event) => void; - handlePointerMove: (event: Event) => void; - handlePointerUp: (event: Event) => void; -}) { - useEffect(() => { - document.addEventListener("pointerdown", handlePointerDown, { passive: false }); - document.addEventListener("pointermove", handlePointerMove, { passive: true }); - document.addEventListener("pointerup", handlePointerUp, { passive: false }); - document.addEventListener("pointercancel", handlePointerUp, { passive: false }); - return () => { - document.removeEventListener("pointerdown", handlePointerDown); - document.removeEventListener("pointermove", handlePointerMove); - document.removeEventListener("pointerup", handlePointerUp); - document.removeEventListener("pointercancel", handlePointerUp); - }; - }, [handlePointerDown, handlePointerMove, handlePointerUp]); -} - -function useControlActions(rtc: AppRtcActions) { - const handleReset = useCallback(async () => { - const confirmed = window.confirm("Clear the current conversation context and start fresh?"); - if (!confirmed) return; - await rtc.connect(); - rtc.sendJson({ type: "command", command: "reset" }); - }, [rtc]); - - const handleToggleTextOnly = useCallback( - async (enabled: boolean) => { - rtc.setTextOnly(enabled); - if (enabled && !rtc.connected && !rtc.connecting) await rtc.connect(); - }, - [rtc], - ); - - return { handleReset, handleToggleTextOnly }; -} - -function useSelectedCardActions({ +function AgentPanel({ + app, rtc, - selectedCardId, - setSelectedCardId, - selectedCardMetadata, + sessionDrawerOpen, + setSessionDrawerOpen, + sessionDrawerEdgeGesture, + activeTheme, + themeOptions, + onSelectTheme, }: { - rtc: AppRtcActions & { - sendTextMessage(text: string, metadata?: CardMessageMetadata): Promise; - }; - selectedCardId: string | null; - setSelectedCardId: (cardId: string | null) => void; - selectedCardMetadata: () => CardMessageMetadata | undefined; -}) { - const clearSelectedCardContext = useCallback(() => { - if (selectedCardId) window.__nanobotClearCardSelection?.(selectedCardId); - setSelectedCardId(null); - }, [selectedCardId, setSelectedCardId]); - - const handleCardChoice = useCallback( - (cardId: string, value: string) => { - rtc.sendJson({ type: "card-response", card_id: cardId, value }); - }, - [rtc], - ); - - const handleSendMessage = useCallback( - async (text: string) => { - await rtc.sendTextMessage(text, selectedCardMetadata()); - }, - [rtc, selectedCardMetadata], - ); - - const handleResetWithSelection = useCallback(async () => { - const confirmed = window.confirm("Clear the current conversation context and start fresh?"); - if (!confirmed) return; - clearSelectedCardContext(); - await rtc.connect(); - rtc.sendJson({ type: "command", command: "reset" }); - }, [clearSelectedCardContext, rtc]); - - return { - clearSelectedCardContext, - handleCardChoice, - handleSendMessage, - handleResetWithSelection, - }; -} - -function useSelectedCardContext( - cards: CardItem[], - selectedCardId: string | null, - selectionVersion: number, -) { - const selectedCard = useMemo( - () => - selectedCardId ? (cards.find((card) => card.serverId === selectedCardId) ?? null) : null, - [cards, selectedCardId], - ); - const selectedCardSelection = useMemo( - () => (selectedCardId ? readCardSelection(selectedCardId) : null), - [selectedCardId, selectionVersion], - ); - const selectedCardMetadata = useCallback( - () => (selectedCard ? buildCardMetadata(selectedCard) : undefined), - [selectedCard, selectionVersion], - ); - - return { selectedCard, selectedCardSelection, selectedCardMetadata }; -} - -function useCardSelectionLifecycle({ - cards, - selectedCardId, - setSelectedCardId, - setSelectionVersion, - setView, -}: { - cards: CardItem[]; - selectedCardId: string | null; - setSelectedCardId: (cardId: string | null) => void; - setSelectionVersion: (updater: (current: number) => number) => void; - setView: (view: WorkspaceView) => void; -}) { - const autoOpenedFeedRef = useRef(false); - - useEffect(() => { - if (autoOpenedFeedRef.current || cards.length === 0) return; - autoOpenedFeedRef.current = true; - setView("feed"); - }, [cards.length, setView]); - - useEffect(() => { - if (!selectedCardId) return; - if (cards.some((card) => card.serverId === selectedCardId)) return; - setSelectedCardId(null); - }, [cards, selectedCardId, setSelectedCardId]); - - useEffect(() => { - const handleSelectionChange = (event: Event) => { - const detail = (event as CustomEvent<{ cardId?: string; selection?: JsonValue | null }>) - .detail; - setSelectionVersion((current) => current + 1); - const cardId = typeof detail?.cardId === "string" ? detail.cardId : ""; - if (cardId && detail?.selection) setSelectedCardId(cardId); - }; - - window.addEventListener(CARD_SELECTION_EVENT, handleSelectionChange as EventListener); - return () => { - window.removeEventListener(CARD_SELECTION_EVENT, handleSelectionChange as EventListener); - }; - }, [setSelectedCardId, setSelectionVersion]); -} - -function AgentWorkspace({ - active, - selectedCard, - selectedCardSelection, - onClearSelectedCardContext, - onReset, - textOnly, - onToggleTextOnly, - logLines, - connected, - onSendMessage, - onExpandChange, - effectiveAgentState, - connecting, - audioLevel, -}: { - active: boolean; - selectedCard: CardItem | null; - selectedCardSelection: CardSelectionRange | null; - onClearSelectedCardContext(): void; - onReset(): Promise; - textOnly: boolean; - onToggleTextOnly(enabled: boolean): Promise; - logLines: LogLine[]; - connected: boolean; - onSendMessage(text: string): Promise; - onExpandChange(expanded: boolean): void; - effectiveAgentState: AgentState; - connecting: boolean; - audioLevel: number; + app: ReturnType; + rtc: ReturnType; + sessionDrawerOpen: boolean; + setSessionDrawerOpen(open: boolean): void; + sessionDrawerEdgeGesture: ReturnType; + activeTheme: ThemeName; + themeOptions: ThemeOption[]; + onSelectTheme(themeName: ThemeName): void; }) { return ( -
        - {active && ( - - )} - {active && selectedCard && ( - + ) : null + } + logLines={rtc.logLines} + connected={rtc.connected} + onSendMessage={app.handleSendMessage} + effectiveAgentState={app.effectiveAgentState} + textStreaming={rtc.textStreaming} + connecting={rtc.connecting} + audioLevel={app.audioLevel} + sessionDrawer={ + 0 && !sessionDrawerOpen} + sessions={rtc.sessions} + activeSessionId={rtc.activeSessionId} + busy={rtc.sessionLoading || rtc.textStreaming} + onClose={() => setSessionDrawerOpen(false)} + onCreate={async () => { + await rtc.createSession(); + setSessionDrawerOpen(false); + }} + onSelect={async (chatId) => { + await rtc.switchSession(chatId); + setSessionDrawerOpen(false); + }} + onRename={async (chatId, title) => { + await rtc.renameSession(chatId, title); + }} + onDelete={async (chatId) => { + await rtc.deleteSession(chatId); + setSessionDrawerOpen(false); + }} + activeTheme={activeTheme} + themeOptions={themeOptions} + onSelectTheme={onSelectTheme} /> - )} - {active && ( - - )} - {}} - onPointerUp={() => {}} - /> -
        + } + /> ); } -function FeedWorkspace({ - cards, - onDismiss, - onChoice, - onAskCard, +function FeedPanel({ + app, + rtc, }: { - cards: CardItem[]; - onDismiss(id: number): void; - onChoice(cardId: string, value: string): void; - onAskCard(card: CardItem): void; + app: ReturnType; + rtc: ReturnType; }) { return ( -
        - -
        + ); } export function App() { const rtc = useWebRTC(); - const remoteAudioLevel = useAudioMeter(rtc.remoteStream); - const audioLevel = rtc.textOnly ? 0 : remoteAudioLevel; - - const [view, setView] = useState("agent"); - const [composing, setComposing] = useState(false); - const [selectedCardId, setSelectedCardId] = useState(null); - const [selectionVersion, setSelectionVersion] = useState(0); - const { selectedCard, selectedCardSelection, selectedCardMetadata } = useSelectedCardContext( - rtc.cards, - selectedCardId, - selectionVersion, - ); - - const { agentStateOverride, handlePointerDown, handlePointerMove, handlePointerUp } = usePTT({ - connected: rtc.connected && !rtc.textOnly, - currentAgentState: rtc.agentState, - onSendPtt: (pressed) => - rtc.sendJson({ type: "voice-ptt", pressed, metadata: selectedCardMetadata() }), - onBootstrap: rtc.connect, - onInterrupt: () => rtc.sendJson({ type: "command", command: "reset" }), - }); - const effectiveAgentState = agentStateOverride ?? rtc.agentState; - - const isInteractiveTarget = useCallback((target: EventTarget | null): boolean => { - if (!(target instanceof Element)) return false; - return Boolean(target.closest("button,textarea,input,a,[data-no-swipe='1']")); - }, []); - const { onSwipeStart, onSwipeEnd } = useSwipeHandlers( - composing, - view, - setView, - isInteractiveTarget, - ); - useGlobalPointerBindings({ handlePointerDown, handlePointerMove, handlePointerUp }); - useCardSelectionLifecycle({ - cards: rtc.cards, - selectedCardId, - setSelectedCardId, - setSelectionVersion, - setView, - }); - - const { handleToggleTextOnly } = useControlActions(rtc); - const { handleAskCard } = useCardActions(setView, setSelectedCardId); - const { - clearSelectedCardContext, - handleCardChoice, - handleSendMessage, - handleResetWithSelection, - } = useSelectedCardActions({ - rtc, - selectedCardId, - setSelectedCardId, - selectedCardMetadata, + const app = useAppPresentation(rtc); + const theme = useThemePreference(); + const [sessionDrawerOpen, setSessionDrawerOpen] = useState(false); + const sessionDrawerEdgeGesture = useSessionDrawerEdgeSwipe({ + enabled: app.view === "agent" && !sessionDrawerOpen, + onOpen: () => setSessionDrawerOpen(true), }); return ( <> -
        -
        - - -
        -
        + } + feedWorkspace={} + /> ); diff --git a/frontend/src/appShell/presentation.tsx b/frontend/src/appShell/presentation.tsx new file mode 100644 index 0000000..a7da5e4 --- /dev/null +++ b/frontend/src/appShell/presentation.tsx @@ -0,0 +1,905 @@ +import type { ComponentChildren } from "preact"; +import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks"; +import { + clearCardSelection, + getCardLiveContent, + getCardSelection, + subscribeCardSelection, +} from "../cardRuntime/store"; +import { AgentIndicator } from "../components/AgentIndicator"; +import { CardFeed } from "../components/CardFeed"; +import { ControlBar } from "../components/Controls"; +import { LogPanel } from "../components/LogPanel"; +import { useAudioMeter } from "../hooks/useAudioMeter"; +import { usePTT } from "../hooks/usePTT"; +import type { WebRTCState } from "../hooks/webrtc/types"; +import type { + AgentState, + CardItem, + CardMessageMetadata, + CardSelectionRange, + JsonValue, + LogLine, +} from "../types"; + +const SWIPE_THRESHOLD_PX = 64; +const SWIPE_INTENT_PX = 12; +const SWIPE_COMMIT_RATIO = 0.18; +const SWIPE_DIRECTION_RATIO = 1.15; +const SESSION_DRAWER_OPEN_THRESHOLD_PX = 52; +const SESSION_DRAWER_MAX_WIDTH_PX = 336; +const SESSION_DRAWER_GUTTER_PX = 28; + +type WorkspaceView = "agent" | "feed"; + +interface AppRtcActions { + connect(): Promise; + sendJson( + msg: + | { type: "command"; command: string } + | { type: "card-response"; card_id: string; value: string } + | { type: "voice-ptt"; pressed: boolean; metadata?: CardMessageMetadata }, + ): void; + setTextOnly(enabled: boolean): void; + sendTextMessage(text: string, metadata?: CardMessageMetadata): Promise; + connected: boolean; + connecting: boolean; +} + +interface SwipeState { + pointerId: number; + x: number; + y: number; + dragging: boolean; +} + +interface EdgeSwipeState { + identifier: number; + x: number; + y: number; +} + +type EdgeSwipeOutcome = "idle" | "track" | "cancel" | "open"; + +function findTouchById(touches: TouchList, identifier: number): Touch | null { + for (let i = 0; i < touches.length; i += 1) { + const touch = touches.item(i); + if (touch && touch.identifier === identifier) return touch; + } + return null; +} + +function isSwipeInteractiveTarget(target: EventTarget | null): boolean { + if (!(target instanceof Element)) return false; + return Boolean( + target.closest("textarea,input,select,[contenteditable='true'],[data-no-swipe='1']"), + ); +} + +function getViewportWidth(): number { + return window.innerWidth || document.documentElement.clientWidth || 1; +} + +function toNullableNumber(value: JsonValue | undefined): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function readCardSelection(cardId: string | null | undefined): CardSelectionRange | null { + const raw = getCardSelection(cardId); + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null; + const record = raw as Record; + if (record.kind !== "git_diff_range") return null; + if (typeof record.file_label !== "string" || typeof record.range_label !== "string") return null; + + const filePath = typeof record.file_path === "string" ? record.file_path : record.file_label; + const label = + typeof record.label === "string" + ? record.label + : `${record.file_label} · ${record.range_label}`; + + return { + kind: "git_diff_range", + file_path: filePath, + file_label: record.file_label, + range_label: record.range_label, + label, + old_start: toNullableNumber(record.old_start), + old_end: toNullableNumber(record.old_end), + new_start: toNullableNumber(record.new_start), + new_end: toNullableNumber(record.new_end), + }; +} + +function buildCardMetadata(card: CardItem): CardMessageMetadata { + const metadata: CardMessageMetadata = { + card_id: card.serverId, + card_slot: card.slot, + card_title: card.title, + card_lane: card.lane, + card_template_key: card.templateKey, + card_context_summary: card.contextSummary, + card_response_value: card.responseValue, + }; + const selection = card.serverId ? readCardSelection(card.serverId) : null; + if (selection) { + metadata.card_selection = selection as unknown as JsonValue; + metadata.card_selection_label = selection.label; + } + const liveContent = card.serverId ? getCardLiveContent(card.serverId) : undefined; + if (liveContent !== undefined) metadata.card_live_content = liveContent as JsonValue; + return metadata; +} + +function formatCardContextLabel( + card: CardItem | null, + selection: CardSelectionRange | null, +): string | null { + if (!card) return null; + if (selection) { + return `diff: ${selection.file_label}${selection.range_label ? ` ${selection.range_label}` : ""}`; + } + const title = card.title?.trim(); + if (!title) return "card"; + if (card.templateKey === "todo-item-live") return `task: ${title}`; + if (card.templateKey === "upcoming-conditions-live") return `event: ${title}`; + if (card.templateKey === "list-total-live") return `tracker: ${title}`; + return `card: ${title}`; +} + +function AgentCardContext({ + card, + selection, + onClear, + textMode = false, +}: { + card: CardItem; + selection: CardSelectionRange | null; + onClear(): void; + textMode?: boolean; +}) { + const label = textMode + ? "Next message uses this context" + : selection + ? "Using diff context" + : "Using card"; + const title = selection?.file_label || card.title; + const meta = selection?.range_label || card.contextSummary || ""; + + return ( +
        +
        {label}
        +
        +
        +
        {title}
        + {meta &&
        {meta}
        } + {textMode ? ( +
        + Ask your follow-up and Nanobot will include this card in the conversation context. +
        + ) : null} +
        + +
        +
        + ); +} + +function getSwipeDelta(swipe: SwipeState, event: PointerEvent): { dx: number; dy: number } { + return { + dx: event.clientX - swipe.x, + dy: event.clientY - swipe.y, + }; +} + +function hasSwipeIntent(dx: number, dy: number): boolean { + return Math.abs(dx) >= SWIPE_INTENT_PX || Math.abs(dy) >= SWIPE_INTENT_PX; +} + +function isVerticalSwipeDominant(dx: number, dy: number): boolean { + return Math.abs(dx) < Math.abs(dy) * SWIPE_DIRECTION_RATIO; +} + +function clampSwipeOffset(view: WorkspaceView, dx: number, width: number): number { + const minOffset = view === "agent" ? -width : 0; + const maxOffset = view === "agent" ? 0 : width; + return Math.max(minOffset, Math.min(maxOffset, dx)); +} + +function getCommittedSwipeView( + view: WorkspaceView, + dx: number, + width: number, +): WorkspaceView | null { + const progress = width > 0 ? Math.abs(dx) / width : 0; + const crossedThreshold = Math.abs(dx) >= SWIPE_THRESHOLD_PX || progress >= SWIPE_COMMIT_RATIO; + if (!crossedThreshold) return null; + if (view === "agent" && dx < 0) return "feed"; + if (view === "feed" && dx > 0) return "agent"; + return null; +} + +function releaseCapturedPointer(currentTarget: EventTarget | null, pointerId: number): void { + const element = currentTarget as HTMLElement | null; + if (element?.hasPointerCapture?.(pointerId)) element.releasePointerCapture(pointerId); +} + +export function useSwipeTrackStyle(view: WorkspaceView, swipeOffsetPx: number, swiping: boolean) { + return useMemo(() => { + const base = view === "feed" ? "-50%" : "0%"; + return { + transform: + swipeOffsetPx === 0 + ? `translateX(${base})` + : `translateX(calc(${base} + ${swipeOffsetPx}px))`, + transition: swiping ? "none" : "transform 0.28s ease", + }; + }, [swipeOffsetPx, swiping, view]); +} + +function getEdgeSwipeOutcome(dx: number, dy: number): EdgeSwipeOutcome { + if (!hasSwipeIntent(dx, dy)) return "idle"; + if (isVerticalSwipeDominant(dx, dy) || dx <= 0) return "cancel"; + if (dx < SESSION_DRAWER_OPEN_THRESHOLD_PX) return "track"; + return "open"; +} + +function getEdgeSwipeTouch( + touches: TouchList, + swipe: EdgeSwipeState | null, +): { swipe: EdgeSwipeState; touch: Touch } | null { + if (!swipe) return null; + const touch = findTouchById(touches, swipe.identifier); + if (!touch) return null; + return { swipe, touch }; +} + +function getSessionDrawerWidth(viewportWidth: number): number { + return Math.max( + 1, + Math.min(SESSION_DRAWER_MAX_WIDTH_PX, viewportWidth - SESSION_DRAWER_GUTTER_PX), + ); +} + +function getSessionDrawerProgress(dx: number, viewportWidth: number): number { + return Math.max(0, Math.min(1, dx / getSessionDrawerWidth(viewportWidth))); +} + +export function useSessionDrawerEdgeSwipe({ + enabled, + onOpen, +}: { + enabled: boolean; + onOpen(): void; +}) { + const edgeSwipeRef = useRef(null); + const [progress, setProgress] = useState(0); + + const clearEdgeSwipe = useCallback(() => { + edgeSwipeRef.current = null; + setProgress(0); + }, []); + + const onTouchStart = useCallback( + (e: Event) => { + if (!enabled) return; + const te = e as TouchEvent; + if (te.touches.length !== 1) return; + const touch = te.touches.item(0); + if (!touch) return; + edgeSwipeRef.current = { + identifier: touch.identifier, + x: touch.clientX, + y: touch.clientY, + }; + }, + [enabled], + ); + + const onTouchMove = useCallback( + (e: Event) => { + const te = e as TouchEvent; + if (!enabled) return; + const trackedTouch = getEdgeSwipeTouch(te.touches, edgeSwipeRef.current); + if (!trackedTouch) return; + + const { swipe, touch } = trackedTouch; + const dx = touch.clientX - swipe.x; + const dy = touch.clientY - swipe.y; + const outcome = getEdgeSwipeOutcome(dx, dy); + if (outcome === "idle") return; + + if (outcome === "cancel") { + clearEdgeSwipe(); + return; + } + + if (te.cancelable) te.preventDefault(); + setProgress( + getSessionDrawerProgress( + dx, + window.innerWidth || document.documentElement.clientWidth || 1, + ), + ); + if (outcome === "track") return; + }, + [clearEdgeSwipe, enabled], + ); + + const onTouchEnd = useCallback( + (e: Event) => { + const te = e as TouchEvent; + const trackedTouch = getEdgeSwipeTouch(te.changedTouches, edgeSwipeRef.current); + if (!trackedTouch) { + clearEdgeSwipe(); + return; + } + + const { swipe, touch } = trackedTouch; + const dx = touch.clientX - swipe.x; + const shouldOpen = dx >= SESSION_DRAWER_OPEN_THRESHOLD_PX; + clearEdgeSwipe(); + if (shouldOpen) onOpen(); + }, + [clearEdgeSwipe, onOpen], + ); + + return { + onTouchStart, + onTouchMove, + onTouchEnd, + onTouchCancel: clearEdgeSwipe, + progress, + }; +} + +// biome-ignore lint/complexity/noExcessiveLinesPerFunction: touch and pointer swipe paths share one state machine here +export function useSwipeHandlers( + view: WorkspaceView, + setView: (view: WorkspaceView) => void, + isInteractiveTarget: (target: EventTarget | null) => boolean, + getViewportWidthFn: () => number, +) { + const swipeRef = useRef(null); + const [swipeOffsetPx, setSwipeOffsetPx] = useState(0); + const [swiping, setSwiping] = useState(false); + + const clearSwipe = useCallback(() => { + swipeRef.current = null; + setSwiping(false); + setSwipeOffsetPx(0); + }, []); + + const onSwipeStart = useCallback( + (e: Event) => { + const pe = e as PointerEvent; + if (pe.pointerType === "touch") return; + if (pe.pointerType === "mouse" && pe.button !== 0) return; + if (isInteractiveTarget(pe.target)) return; + swipeRef.current = { pointerId: pe.pointerId, x: pe.clientX, y: pe.clientY, dragging: false }; + (e.currentTarget as HTMLElement | null)?.setPointerCapture?.(pe.pointerId); + }, + [isInteractiveTarget], + ); + + const onSwipeMove = useCallback( + (e: Event) => { + const pe = e as PointerEvent; + if (pe.pointerType === "touch") return; + const swipe = swipeRef.current; + if (!swipe || swipe.pointerId !== pe.pointerId) return; + + const { dx, dy } = getSwipeDelta(swipe, pe); + + if (!swipe.dragging) { + if (!hasSwipeIntent(dx, dy)) return; + if (isVerticalSwipeDominant(dx, dy)) { + clearSwipe(); + return; + } + swipe.dragging = true; + setSwiping(true); + } + + setSwipeOffsetPx(clampSwipeOffset(view, dx, getViewportWidthFn())); + if (pe.cancelable) pe.preventDefault(); + }, + [clearSwipe, getViewportWidthFn, view], + ); + + const finishSwipe = useCallback( + (e: Event, commit: boolean) => { + const pe = e as PointerEvent; + if (pe.pointerType === "touch") return; + const swipe = swipeRef.current; + if (!swipe || swipe.pointerId !== pe.pointerId) return; + + releaseCapturedPointer(e.currentTarget, pe.pointerId); + if (commit) { + const { dx } = getSwipeDelta(swipe, pe); + const nextView = swipe.dragging + ? getCommittedSwipeView(view, dx, getViewportWidthFn()) + : null; + if (nextView) setView(nextView); + } + + clearSwipe(); + }, + [clearSwipe, getViewportWidthFn, setView, view], + ); + + const onSwipeEnd = useCallback((e: Event) => finishSwipe(e, true), [finishSwipe]); + const onSwipeCancel = useCallback((e: Event) => finishSwipe(e, false), [finishSwipe]); + + const onTouchStart = useCallback( + (e: Event) => { + const te = e as TouchEvent; + if (te.touches.length !== 1) return; + const touch = te.touches.item(0); + if (!touch) return; + if (isInteractiveTarget(te.target)) return; + swipeRef.current = { + pointerId: touch.identifier, + x: touch.clientX, + y: touch.clientY, + dragging: false, + }; + }, + [isInteractiveTarget], + ); + + const onTouchMove = useCallback( + (e: Event) => { + const te = e as TouchEvent; + const swipe = swipeRef.current; + if (!swipe) return; + const touch = findTouchById(te.touches, swipe.pointerId); + if (!touch) return; + + const dx = touch.clientX - swipe.x; + const dy = touch.clientY - swipe.y; + + if (!swipe.dragging) { + if (!hasSwipeIntent(dx, dy)) return; + if (isVerticalSwipeDominant(dx, dy)) { + clearSwipe(); + return; + } + swipe.dragging = true; + setSwiping(true); + } + + setSwipeOffsetPx(clampSwipeOffset(view, dx, getViewportWidthFn())); + if (te.cancelable) te.preventDefault(); + }, + [clearSwipe, getViewportWidthFn, view], + ); + + const onTouchEnd = useCallback( + (e: Event) => { + const te = e as TouchEvent; + const swipe = swipeRef.current; + if (!swipe) return; + const touch = findTouchById(te.changedTouches, swipe.pointerId); + if (!touch) return; + + if (swipe.dragging) { + const dx = touch.clientX - swipe.x; + const nextView = getCommittedSwipeView(view, dx, getViewportWidthFn()); + if (nextView) setView(nextView); + } + + clearSwipe(); + }, + [clearSwipe, getViewportWidthFn, setView, view], + ); + + const onTouchCancel = useCallback(() => { + clearSwipe(); + }, [clearSwipe]); + + return { + onSwipeStart, + onSwipeMove, + onSwipeEnd, + onSwipeCancel, + onTouchStart, + onTouchMove, + onTouchEnd, + onTouchCancel, + swipeOffsetPx, + swiping, + }; +} + +function useCardActions( + setView: (view: WorkspaceView) => void, + setSelectedCardId: (cardId: string | null) => void, +) { + const handleAskCard = useCallback( + (card: CardItem) => { + if (!card.serverId) return; + setSelectedCardId(card.serverId); + setView("agent"); + }, + [setSelectedCardId, setView], + ); + + return { handleAskCard }; +} + +function useGlobalPointerBindings({ + handlePointerDown, + handlePointerMove, + handlePointerUp, +}: { + handlePointerDown: (event: Event) => void; + handlePointerMove: (event: Event) => void; + handlePointerUp: (event: Event) => void; +}) { + useEffect(() => { + document.addEventListener("pointerdown", handlePointerDown, { passive: false }); + document.addEventListener("pointermove", handlePointerMove, { passive: true }); + document.addEventListener("pointerup", handlePointerUp, { passive: false }); + document.addEventListener("pointercancel", handlePointerUp, { passive: false }); + return () => { + document.removeEventListener("pointerdown", handlePointerDown); + document.removeEventListener("pointermove", handlePointerMove); + document.removeEventListener("pointerup", handlePointerUp); + document.removeEventListener("pointercancel", handlePointerUp); + }; + }, [handlePointerDown, handlePointerMove, handlePointerUp]); +} + +function useControlActions(rtc: AppRtcActions) { + const handleToggleTextOnly = useCallback( + async (enabled: boolean) => { + rtc.setTextOnly(enabled); + if (!enabled && !rtc.connected && !rtc.connecting) await rtc.connect(); + }, + [rtc], + ); + + return { handleToggleTextOnly }; +} + +function useSelectedCardActions({ + rtc, + selectedCardId, + setSelectedCardId, + selectedCardMetadata, +}: { + rtc: AppRtcActions & { + sendTextMessage(text: string, metadata?: CardMessageMetadata): Promise; + }; + selectedCardId: string | null; + setSelectedCardId: (cardId: string | null) => void; + selectedCardMetadata: () => CardMessageMetadata | undefined; +}) { + const clearSelectedCardContext = useCallback(() => { + if (selectedCardId) clearCardSelection(selectedCardId); + setSelectedCardId(null); + }, [selectedCardId, setSelectedCardId]); + + const handleCardChoice = useCallback( + (cardId: string, value: string) => { + rtc.sendJson({ type: "card-response", card_id: cardId, value }); + }, + [rtc], + ); + + const handleSendMessage = useCallback( + async (text: string) => { + await rtc.sendTextMessage(text, selectedCardMetadata()); + }, + [rtc, selectedCardMetadata], + ); + + return { + clearSelectedCardContext, + handleCardChoice, + handleSendMessage, + }; +} + +function useSelectedCardContext( + cards: CardItem[], + selectedCardId: string | null, + selectionVersion: number, +) { + const selectedCard = useMemo( + () => + selectedCardId ? (cards.find((card) => card.serverId === selectedCardId) ?? null) : null, + [cards, selectedCardId], + ); + const selectedCardSelection = useMemo( + () => (selectedCardId ? readCardSelection(selectedCardId) : null), + [selectedCardId, selectionVersion], + ); + const selectedCardMetadata = useCallback( + () => (selectedCard ? buildCardMetadata(selectedCard) : undefined), + [selectedCard, selectionVersion], + ); + + return { selectedCard, selectedCardSelection, selectedCardMetadata }; +} + +function useCardSelectionLifecycle({ + cards, + selectedCardId, + setSelectedCardId, + setSelectionVersion, +}: { + cards: CardItem[]; + selectedCardId: string | null; + setSelectedCardId: (cardId: string | null) => void; + setSelectionVersion: (updater: (current: number) => number) => void; +}) { + useEffect(() => { + if (!selectedCardId) return; + if (cards.some((card) => card.serverId === selectedCardId)) return; + setSelectedCardId(null); + }, [cards, selectedCardId, setSelectedCardId]); + + useEffect(() => { + const unsubscribe = subscribeCardSelection((cardId, selection) => { + setSelectionVersion((current) => current + 1); + if (cardId && selection) setSelectedCardId(cardId); + }); + return unsubscribe; + }, [setSelectedCardId, setSelectionVersion]); +} + +export function AgentWorkspace({ + active, + selectedCard, + selectedCardSelection, + selectedCardContextLabel, + onClearSelectedCardContext, + textOnly, + onToggleTextOnly, + sessionDrawerEdge, + logLines, + connected, + onSendMessage, + effectiveAgentState, + textStreaming, + connecting, + audioLevel, + sessionDrawer, + workbenchOverlay, +}: { + active: boolean; + selectedCard: CardItem | null; + selectedCardSelection: CardSelectionRange | null; + selectedCardContextLabel: string | null; + onClearSelectedCardContext(): void; + textOnly: boolean; + onToggleTextOnly(enabled: boolean): Promise; + sessionDrawerEdge: ComponentChildren; + logLines: LogLine[]; + connected: boolean; + onSendMessage(text: string): Promise; + effectiveAgentState: AgentState; + textStreaming: boolean; + connecting: boolean; + audioLevel: number; + sessionDrawer: ComponentChildren; + workbenchOverlay: ComponentChildren; +}) { + return ( +
        + {active && } + {active && sessionDrawerEdge} + {active && sessionDrawer} + {active && workbenchOverlay} + {active && selectedCard && !textOnly && ( + + )} + {active && ( + { + void onToggleTextOnly(false); + }} + contextLabel={selectedCardContextLabel} + onClearContext={selectedCard ? onClearSelectedCardContext : undefined} + agentState={effectiveAgentState} + textStreaming={textStreaming} + fullScreen={textOnly} + /> + )} + {!textOnly && ( + {}} + onPointerUp={() => {}} + /> + )} +
        + ); +} + +export function FeedWorkspace({ + cards, + onDismiss, + onChoice, + onAskCard, +}: { + cards: CardItem[]; + onDismiss(id: number): void; + onChoice(cardId: string, value: string): void; + onAskCard(card: CardItem): void; +}) { + return ( +
        + +
        + ); +} + +export function SwipeWorkspace({ + view, + trackStyle, + onSwipeStart, + onSwipeMove, + onSwipeEnd, + onSwipeCancel, + onTouchStart, + onTouchMove, + onTouchEnd, + onTouchCancel, + agentWorkspace, + feedWorkspace, +}: { + view: WorkspaceView; + trackStyle: Record; + onSwipeStart: (event: Event) => void; + onSwipeMove: (event: Event) => void; + onSwipeEnd: (event: Event) => void; + onSwipeCancel: (event: Event) => void; + onTouchStart: (event: Event) => void; + onTouchMove: (event: Event) => void; + onTouchEnd: (event: Event) => void; + onTouchCancel: (event: Event) => void; + agentWorkspace: ComponentChildren; + feedWorkspace: ComponentChildren; +}) { + return ( +
        +
        + {agentWorkspace} + {feedWorkspace} +
        +
        + ); +} + +export function useAppPresentation(rtc: WebRTCState) { + const remoteAudioLevel = useAudioMeter(rtc.remoteStream); + const audioLevel = rtc.textOnly ? 0 : remoteAudioLevel; + + const [view, setView] = useState("agent"); + const [selectedCardId, setSelectedCardId] = useState(null); + const [selectionVersion, setSelectionVersion] = useState(0); + const { selectedCard, selectedCardSelection, selectedCardMetadata } = useSelectedCardContext( + rtc.cards, + selectedCardId, + selectionVersion, + ); + const selectedCardContextLabel = useMemo( + () => formatCardContextLabel(selectedCard, selectedCardSelection), + [selectedCard, selectedCardSelection], + ); + + const { agentStateOverride, handlePointerDown, handlePointerMove, handlePointerUp } = usePTT({ + connected: rtc.connected && !rtc.textOnly, + currentAgentState: rtc.agentState, + onSendPtt: (pressed) => + rtc.sendJson({ type: "voice-ptt", pressed, metadata: selectedCardMetadata() }), + onBootstrap: rtc.textOnly ? async () => {} : rtc.connect, + onInterrupt: () => rtc.sendJson({ type: "command", command: "reset" }), + }); + const effectiveAgentState = agentStateOverride ?? rtc.agentState; + const { + onSwipeStart, + onSwipeMove, + onSwipeEnd, + onSwipeCancel, + onTouchStart, + onTouchMove, + onTouchEnd, + onTouchCancel, + swipeOffsetPx, + swiping, + } = useSwipeHandlers(view, setView, isSwipeInteractiveTarget, getViewportWidth); + useGlobalPointerBindings({ handlePointerDown, handlePointerMove, handlePointerUp }); + useCardSelectionLifecycle({ + cards: rtc.cards, + selectedCardId, + setSelectedCardId, + setSelectionVersion, + }); + + const { handleToggleTextOnly } = useControlActions(rtc); + const { handleAskCard } = useCardActions(setView, setSelectedCardId); + const { clearSelectedCardContext, handleCardChoice, handleSendMessage } = useSelectedCardActions({ + rtc, + selectedCardId, + setSelectedCardId, + selectedCardMetadata: useCallback(() => { + const metadata = selectedCardMetadata(); + if (!metadata) return metadata; + return { + ...metadata, + context_label: selectedCardContextLabel ?? undefined, + }; + }, [selectedCardContextLabel, selectedCardMetadata]), + }); + + const trackStyle = useSwipeTrackStyle(view, swipeOffsetPx, swiping); + + return { + audioLevel, + view, + selectedCard, + selectedCardSelection, + selectedCardContextLabel, + effectiveAgentState, + handleToggleTextOnly, + handleAskCard, + clearSelectedCardContext, + handleCardChoice, + handleSendMessage, + onSwipeStart, + onSwipeMove, + onSwipeEnd, + onSwipeCancel, + onTouchStart, + onTouchMove, + onTouchEnd, + onTouchCancel, + trackStyle, + }; +} diff --git a/frontend/src/cardRuntime/api.ts b/frontend/src/cardRuntime/api.ts new file mode 100644 index 0000000..b4e9671 --- /dev/null +++ b/frontend/src/cardRuntime/api.ts @@ -0,0 +1,363 @@ +import { streamSseResponse } from "../lib/sse"; +import type { JsonValue, WorkbenchItem } from "../types"; + +export interface ManualToolResult { + tool_name: string; + content: string; + parsed: JsonValue | null; + is_json: boolean; +} + +export interface ManualToolDefinition { + name: string; + description: string; + parameters: Record; + kind?: string; +} + +export interface ManualToolJob { + job_id: string; + tool_name: string; + status: "queued" | "running" | "completed" | "failed"; + created_at: string; + started_at: string | null; + finished_at: string | null; + result: ManualToolResult | null; + error: string | null; + error_code: number | null; +} + +export interface ManualToolAsyncOptions { + timeoutMs?: number; +} + +function cloneJsonValue(value: JsonValue | undefined): JsonValue | undefined { + if (value === undefined) return undefined; + try { + return JSON.parse(JSON.stringify(value)) as JsonValue; + } catch { + return value; + } +} + +export async function decodeJsonError(resp: Response): Promise { + try { + const payload = (await resp.json()) as { error?: unknown }; + if (payload && typeof payload === "object" && typeof payload.error === "string") { + return payload.error; + } + } catch { + // Fall back to status code. + } + return `request failed (${resp.status})`; +} + +function normalizeManualToolResult( + payload: Partial | null | undefined, + fallbackName: string, +): ManualToolResult { + return { + tool_name: typeof payload?.tool_name === "string" ? payload.tool_name : fallbackName, + content: typeof payload?.content === "string" ? payload.content : "", + parsed: + payload?.parsed === null || payload?.parsed === undefined + ? null + : (cloneJsonValue(payload.parsed as JsonValue) ?? null), + is_json: payload?.is_json === true, + }; +} + +function normalizeManualToolJob(payload: unknown, fallbackName: string): ManualToolJob { + const record = payload && typeof payload === "object" ? (payload as Record) : {}; + const toolName = typeof record.tool_name === "string" ? record.tool_name : fallbackName; + const statusValue = typeof record.status === "string" ? record.status : "queued"; + return { + job_id: typeof record.job_id === "string" ? record.job_id : "", + tool_name: toolName, + status: + statusValue === "running" || statusValue === "completed" || statusValue === "failed" + ? statusValue + : "queued", + created_at: typeof record.created_at === "string" ? record.created_at : "", + started_at: typeof record.started_at === "string" ? record.started_at : null, + finished_at: typeof record.finished_at === "string" ? record.finished_at : null, + result: normalizeManualToolJobResult(record.result, toolName), + error: typeof record.error === "string" ? record.error : null, + error_code: typeof record.error_code === "number" ? record.error_code : null, + }; +} + +function normalizeManualToolAsyncOptions( + options: ManualToolAsyncOptions, +): Required { + const timeoutMs = + typeof options.timeoutMs === "number" && + Number.isFinite(options.timeoutMs) && + options.timeoutMs >= 100 + ? Math.round(options.timeoutMs) + : 30000; + return { timeoutMs }; +} + +function normalizeManualToolJobResult( + payload: unknown, + fallbackName: string, +): ManualToolResult | null { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + return null; + } + const record = payload as Record; + const looksNormalized = + typeof record.tool_name === "string" || + typeof record.content === "string" || + record.parsed !== undefined || + typeof record.is_json === "boolean"; + if (looksNormalized) { + return normalizeManualToolResult(record as Partial, fallbackName); + } + return { + tool_name: fallbackName, + content: JSON.stringify(record), + parsed: cloneJsonValue(record as JsonValue) ?? null, + is_json: true, + }; +} + +function completedManualToolResult( + job: ManualToolJob, + toolName: string, +): ManualToolResult | undefined { + if (job.status !== "completed") return undefined; + return job.result ?? normalizeManualToolResult(null, toolName); +} + +function assertManualToolJobDidNotFail(job: ManualToolJob, toolName: string): void { + if (job.status === "failed") { + throw new Error(job.error || `${toolName} failed`); + } +} + +async function streamManualToolJobUpdates( + jobId: string, + toolName: string, + timeoutMs: number, +): Promise { + let current: ManualToolJob | null = null; + const controller = new AbortController(); + const timeoutId = window.setTimeout(() => controller.abort(), timeoutMs); + + try { + const resp = await fetch(`/tools/jobs/${encodeURIComponent(jobId)}/stream`, { + cache: "no-store", + signal: controller.signal, + }); + if (!resp.ok) throw new Error(await decodeJsonError(resp)); + + await streamSseResponse(resp, (raw) => { + let payload: unknown = null; + try { + payload = JSON.parse(raw); + } catch { + return; + } + if (!payload || typeof payload !== "object") return; + const record = payload as Record; + if (record.type !== "tool.job") return; + current = normalizeManualToolJob(record.job, toolName); + }); + } catch (error) { + if (controller.signal.aborted) { + throw new Error(`${toolName} timed out`); + } + throw error; + } finally { + window.clearTimeout(timeoutId); + } + + return current; +} + +async function waitForManualToolJob( + job: ManualToolJob, + toolName: string, + timeoutMs: number, +): Promise { + const immediate = completedManualToolResult(job, toolName); + if (immediate) return immediate; + assertManualToolJobDidNotFail(job, toolName); + + const streamed = await streamManualToolJobUpdates(job.job_id, toolName, timeoutMs); + const streamedResult = streamed ? completedManualToolResult(streamed, toolName) : undefined; + if (streamedResult) return streamedResult; + if (streamed) assertManualToolJobDidNotFail(streamed, toolName); + + const current = await getManualToolJob(job.job_id); + const finalResult = completedManualToolResult(current, toolName); + if (finalResult) return finalResult; + assertManualToolJobDidNotFail(current, toolName); + throw new Error(`${toolName} ended before completion`); +} + +export async function callManualTool( + toolName: string, + argumentsValue: Record = {}, +): Promise { + const name = toolName.trim(); + if (!name) throw new Error("tool name is required"); + + const cloned = cloneJsonValue(argumentsValue as JsonValue); + const safeArguments = + cloned && typeof cloned === "object" && !Array.isArray(cloned) + ? (cloned as Record) + : {}; + + const resp = await fetch("/tools/call", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ tool_name: name, arguments: safeArguments }), + }); + if (!resp.ok) throw new Error(await decodeJsonError(resp)); + + const payload = await resp.json(); + if (!payload || typeof payload !== "object") { + throw new Error("invalid tool response"); + } + + return normalizeManualToolResult(payload as Partial, name); +} + +export async function startManualToolCall( + toolName: string, + argumentsValue: Record = {}, +): Promise { + const name = toolName.trim(); + if (!name) throw new Error("tool name is required"); + + const cloned = cloneJsonValue(argumentsValue as JsonValue); + const safeArguments = + cloned && typeof cloned === "object" && !Array.isArray(cloned) + ? (cloned as Record) + : {}; + + const resp = await fetch("/tools/call", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ tool_name: name, arguments: safeArguments, async: true }), + }); + if (!resp.ok) throw new Error(await decodeJsonError(resp)); + + const payload = await resp.json(); + if (!payload || typeof payload !== "object") { + throw new Error("invalid tool job response"); + } + + const job = normalizeManualToolJob(payload, name); + if (!job.job_id) throw new Error("tool job id is required"); + return job; +} + +export async function getManualToolJob(jobId: string): Promise { + const key = jobId.trim(); + if (!key) throw new Error("tool job id is required"); + + const resp = await fetch(`/tools/jobs/${encodeURIComponent(key)}`, { cache: "no-store" }); + if (!resp.ok) throw new Error(await decodeJsonError(resp)); + + const payload = await resp.json(); + if (!payload || typeof payload !== "object") { + throw new Error("invalid tool job response"); + } + + return normalizeManualToolJob(payload, ""); +} + +export async function callManualToolAsync( + toolName: string, + argumentsValue: Record = {}, + options: ManualToolAsyncOptions = {}, +): Promise { + const { timeoutMs } = normalizeManualToolAsyncOptions(options); + const job = await startManualToolCall(toolName, argumentsValue); + return waitForManualToolJob(job, toolName, timeoutMs); +} + +export async function listManualTools(): Promise { + const resp = await fetch("/tools", { cache: "no-store" }); + if (!resp.ok) throw new Error(await decodeJsonError(resp)); + + const payload = (await resp.json()) as { tools?: unknown }; + const tools = Array.isArray(payload?.tools) ? payload.tools : []; + return tools + .filter((tool): tool is Record => !!tool && typeof tool === "object") + .map((tool) => ({ + name: typeof tool.name === "string" ? tool.name : "", + description: typeof tool.description === "string" ? tool.description : "", + parameters: + tool.parameters && typeof tool.parameters === "object" && !Array.isArray(tool.parameters) + ? (tool.parameters as Record) + : {}, + kind: typeof tool.kind === "string" ? tool.kind : undefined, + })) + .filter((tool) => tool.name); +} + +export async function updateCardTemplateState( + cardId: string, + templateState: Record, +): Promise { + const key = cardId.trim(); + if (!key) throw new Error("card id is required"); + const resp = await fetch(`/cards/${encodeURIComponent(key)}/state`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ template_state: templateState }), + }); + if (!resp.ok) throw new Error(await decodeJsonError(resp)); +} + +export async function updateWorkbenchTemplateState( + item: WorkbenchItem, + templateState: Record, +): Promise { + const resp = await fetch("/workbench", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + id: item.id, + chat_id: item.chatId, + kind: item.kind, + title: item.title, + content: item.content, + question: item.question || "", + choices: item.choices || [], + response_value: item.responseValue || "", + slot: item.slot || "", + template_key: item.templateKey || "", + template_state: templateState, + context_summary: item.contextSummary || "", + promotable: item.promotable, + source_card_id: item.sourceCardId || "", + }), + }); + if (!resp.ok) throw new Error(await decodeJsonError(resp)); +} + +export async function copyTextToClipboard(text: string): Promise { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return; + } + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.setAttribute("readonly", "true"); + textarea.style.position = "fixed"; + textarea.style.opacity = "0"; + document.body.appendChild(textarea); + textarea.focus(); + textarea.select(); + try { + document.execCommand("copy"); + } finally { + textarea.remove(); + } +} diff --git a/frontend/src/cardRuntime/runtimeAssets.ts b/frontend/src/cardRuntime/runtimeAssets.ts new file mode 100644 index 0000000..ced737f --- /dev/null +++ b/frontend/src/cardRuntime/runtimeAssets.ts @@ -0,0 +1,81 @@ +import type { JsonValue } from "../types"; +import type { RuntimeItem, RuntimeModule } from "./runtimeTypes"; +import { runtimeItemId } from "./runtimeUtils"; + +const runtimeAssetCache = new Map< + string, + Promise<{ html: string | null; module: RuntimeModule } | null> +>(); + +function runtimeTemplateUrl(templateKey: string, filename: string): string { + return `/card-templates/${encodeURIComponent(templateKey)}/${filename}`; +} + +function escapeAttribute(value: string): string { + return value + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(//g, ">"); +} + +function jsonScriptText(payload: Record): string { + return JSON.stringify(payload, null, 0).replace(/<\//g, "<\\/"); +} + +export function materializeRuntimeHtml( + templateHtml: string, + item: RuntimeItem, + state: Record, +): string { + const cardId = escapeAttribute(runtimeItemId(item)); + const templateKey = escapeAttribute(item.templateKey?.trim() || ""); + return [ + `
        `, + ``, + templateHtml, + "
        ", + ].join(""); +} + +export function syncRuntimeStateScript( + root: HTMLDivElement | null, + state: Record, +): void { + const stateEl = root?.querySelector('script[data-card-state][type="application/json"]'); + if (!(stateEl instanceof HTMLScriptElement)) return; + stateEl.textContent = jsonScriptText(state); +} + +export async function loadRuntimeAssets( + templateKey: string, +): Promise<{ html: string | null; module: RuntimeModule } | null> { + const cached = runtimeAssetCache.get(templateKey); + if (cached) return cached; + + const loader = (async () => { + const moduleUrl = runtimeTemplateUrl(templateKey, "card.js"); + const moduleProbe = await fetch(moduleUrl, { cache: "no-store" }); + if (!moduleProbe.ok) return null; + + const templatePromise = fetch(runtimeTemplateUrl(templateKey, "template.html"), { + cache: "no-store", + }) + .then(async (response) => (response.ok ? response.text() : null)) + .catch(() => null); + + const namespace = (await import(/* @vite-ignore */ `${moduleUrl}?runtime=1`)) as + | RuntimeModule + | { default?: RuntimeModule }; + const runtimeModule = ("default" in namespace ? namespace.default : namespace) as RuntimeModule; + if (!runtimeModule || typeof runtimeModule.mount !== "function") return null; + + return { + html: await templatePromise, + module: runtimeModule, + }; + })(); + + runtimeAssetCache.set(templateKey, loader); + return loader; +} diff --git a/frontend/src/cardRuntime/runtimeHost.ts b/frontend/src/cardRuntime/runtimeHost.ts new file mode 100644 index 0000000..6d936ab --- /dev/null +++ b/frontend/src/cardRuntime/runtimeHost.ts @@ -0,0 +1,98 @@ +import { marked } from "marked"; +import { useRef } from "preact/hooks"; +import type { JsonValue } from "../types"; +import { + callManualTool, + callManualToolAsync, + copyTextToClipboard, + getManualToolJob, + listManualTools, + startManualToolCall, + updateCardTemplateState, + updateWorkbenchTemplateState, +} from "./api"; +import type { RuntimeHost, RuntimeItem, RuntimeSurface } from "./runtimeTypes"; +import { normalizeTemplateState, runtimeItemId } from "./runtimeUtils"; +import { + clearCardSelection, + getCardLiveContent, + getCardSelection, + requestCardFeedRefresh, + runCardRefresh, + setCardLiveContent, + setCardRefreshHandler, + setCardSelection, +} from "./store"; + +export function useRuntimeHost( + surface: RuntimeSurface, + item: RuntimeItem, + stateRef: { current: Record }, + setState: (value: Record) => void, +) { + const itemRef = useRef(item); + itemRef.current = item; + + const hostRef = useRef(null); + if (!hostRef.current) { + hostRef.current = { + surface, + item, + getState: () => normalizeTemplateState(stateRef.current), + replaceState: async (nextState) => { + const normalized = normalizeTemplateState(nextState); + stateRef.current = normalized; + setState(normalized); + const currentItem = itemRef.current; + if ("serverId" in currentItem) { + if (!currentItem.serverId) throw new Error("card id is required"); + await updateCardTemplateState(currentItem.serverId, normalized); + } else if ("chatId" in currentItem) { + await updateWorkbenchTemplateState(currentItem, normalized); + } + return normalizeTemplateState(normalized); + }, + patchState: async (patch) => { + const nextState = { ...stateRef.current, ...normalizeTemplateState(patch) }; + return hostRef.current?.replaceState(nextState) ?? normalizeTemplateState(nextState); + }, + setLiveContent: (snapshot) => { + setCardLiveContent(runtimeItemId(itemRef.current), snapshot); + }, + getLiveContent: () => getCardLiveContent(runtimeItemId(itemRef.current)), + setSelection: (selection) => { + setCardSelection(runtimeItemId(itemRef.current), selection); + }, + getSelection: () => getCardSelection(runtimeItemId(itemRef.current)), + clearSelection: () => { + clearCardSelection(runtimeItemId(itemRef.current)); + }, + setRefreshHandler: (handler) => { + setCardRefreshHandler(runtimeItemId(itemRef.current), handler); + }, + runRefresh: () => runCardRefresh(runtimeItemId(itemRef.current)), + requestFeedRefresh: () => { + requestCardFeedRefresh(); + }, + callTool: callManualTool, + startToolCall: startManualToolCall, + getToolJob: getManualToolJob, + callToolAsync: callManualToolAsync, + listTools: listManualTools, + renderMarkdown: (markdown, options = {}) => + options.inline + ? (marked.parseInline(markdown) as string) + : (marked.parse(markdown) as string), + copyText: copyTextToClipboard, + getThemeName: () => document.documentElement.dataset.theme || "clay", + getThemeValue: (tokenName) => { + const normalized = tokenName.startsWith("--") ? tokenName : `--${tokenName}`; + return getComputedStyle(document.documentElement).getPropertyValue(normalized).trim(); + }, + }; + } + + hostRef.current.surface = surface; + hostRef.current.item = item; + return hostRef.current; +} diff --git a/frontend/src/cardRuntime/runtimeTypes.ts b/frontend/src/cardRuntime/runtimeTypes.ts new file mode 100644 index 0000000..8e4f044 --- /dev/null +++ b/frontend/src/cardRuntime/runtimeTypes.ts @@ -0,0 +1,58 @@ +import type { CardItem, JsonValue, WorkbenchItem } from "../types"; +import type { + ManualToolAsyncOptions, + ManualToolDefinition, + ManualToolJob, + ManualToolResult, +} from "./api"; + +export type RuntimeSurface = "feed" | "workbench"; +export type RuntimeItem = CardItem | WorkbenchItem; + +export interface RuntimeHost { + surface: RuntimeSurface; + item: RuntimeItem; + getState(): Record; + replaceState(nextState: Record): Promise>; + patchState(patch: Record): Promise>; + setLiveContent(snapshot: JsonValue | null | undefined): void; + getLiveContent(): JsonValue | undefined; + setSelection(selection: JsonValue | null | undefined): void; + getSelection(): JsonValue | undefined; + clearSelection(): void; + setRefreshHandler(handler: (() => void) | null | undefined): void; + runRefresh(): boolean; + requestFeedRefresh(): void; + callTool(toolName: string, argumentsValue?: Record): Promise; + startToolCall( + toolName: string, + argumentsValue?: Record, + ): Promise; + getToolJob(jobId: string): Promise; + callToolAsync( + toolName: string, + argumentsValue?: Record, + options?: ManualToolAsyncOptions, + ): Promise; + listTools(): Promise; + renderMarkdown(markdown: string, options?: { inline?: boolean }): string; + copyText(text: string): Promise; + getThemeName(): string; + getThemeValue(tokenName: string): string; +} + +export interface RuntimeContext { + root: HTMLElement; + item: RuntimeItem; + state: Record; + host: RuntimeHost; +} + +export interface MountedRuntimeCard { + update?(context: RuntimeContext): void; + destroy?(): void; +} + +export interface RuntimeModule { + mount(context: RuntimeContext): MountedRuntimeCard | undefined; +} diff --git a/frontend/src/cardRuntime/runtimeUtils.ts b/frontend/src/cardRuntime/runtimeUtils.ts new file mode 100644 index 0000000..ac42863 --- /dev/null +++ b/frontend/src/cardRuntime/runtimeUtils.ts @@ -0,0 +1,35 @@ +import type { CardItem, JsonValue } from "../types"; +import type { RuntimeItem } from "./runtimeTypes"; + +export function cloneJsonValue(value: T | null | undefined): T | undefined { + if (value === null || value === undefined) return undefined; + try { + return JSON.parse(JSON.stringify(value)) as T; + } catch { + return value ?? undefined; + } +} + +export function normalizeTemplateState( + value: Record | undefined, +): Record { + const cloned = cloneJsonValue((value || {}) as JsonValue); + return cloned && typeof cloned === "object" && !Array.isArray(cloned) + ? (cloned as Record) + : {}; +} + +export function runtimeItemId(item: RuntimeItem): string { + if (isCardItem(item)) { + return `card:${item.serverId || item.id}`; + } + return `workbench:${item.chatId}:${item.id}`; +} + +function isCardItem(item: RuntimeItem): item is CardItem { + return "lane" in item; +} + +export function looksLikeHtml(content: string): boolean { + return /^\s*<[a-zA-Z]/.test(content); +} diff --git a/frontend/src/cardRuntime/store.ts b/frontend/src/cardRuntime/store.ts new file mode 100644 index 0000000..389c6df --- /dev/null +++ b/frontend/src/cardRuntime/store.ts @@ -0,0 +1,192 @@ +import type { JsonValue } from "../types"; + +type LiveContentListener = (cardId: string, snapshot: JsonValue | undefined) => void; +type SelectionListener = (cardId: string, selection: JsonValue | undefined) => void; + +class CardRuntimeRegistry { + private readonly liveContentStore = new Map(); + private readonly selectionStore = new Map(); + private readonly refreshHandlers = new Map void>(); + private readonly liveContentListeners = new Set(); + private readonly selectionListeners = new Set(); + + private emitLiveContent(cardId: string, snapshot: JsonValue | undefined): void { + for (const listener of this.liveContentListeners) listener(cardId, cloneJsonValue(snapshot)); + } + + private emitSelection(cardId: string, selection: JsonValue | undefined): void { + for (const listener of this.selectionListeners) listener(cardId, cloneJsonValue(selection)); + } + + setCardLiveContent( + cardId: string | null | undefined, + snapshot: JsonValue | null | undefined, + ): void { + const key = String(cardId || "").trim(); + if (!key) return; + const cloned = cloneJsonValue(snapshot ?? undefined); + if (cloned === undefined) { + this.liveContentStore.delete(key); + this.emitLiveContent(key, undefined); + return; + } + this.liveContentStore.set(key, cloned); + this.emitLiveContent(key, cloned); + } + + getCardLiveContent(cardId: string | null | undefined): JsonValue | undefined { + const key = String(cardId || "").trim(); + if (!key) return undefined; + return cloneJsonValue(this.liveContentStore.get(key)); + } + + subscribeCardLiveContent(listener: LiveContentListener): () => void { + this.liveContentListeners.add(listener); + return () => { + this.liveContentListeners.delete(listener); + }; + } + + setCardSelection( + cardId: string | null | undefined, + selection: JsonValue | null | undefined, + ): void { + const key = String(cardId || "").trim(); + if (!key) return; + const cloned = cloneJsonValue(selection ?? undefined); + if (cloned === undefined) { + this.selectionStore.delete(key); + this.emitSelection(key, undefined); + return; + } + this.selectionStore.set(key, cloned); + this.emitSelection(key, cloned); + } + + getCardSelection(cardId: string | null | undefined): JsonValue | undefined { + const key = String(cardId || "").trim(); + if (!key) return undefined; + return cloneJsonValue(this.selectionStore.get(key)); + } + + clearCardSelection(cardId: string | null | undefined): void { + const key = String(cardId || "").trim(); + if (!key) return; + this.selectionStore.delete(key); + this.emitSelection(key, undefined); + } + + subscribeCardSelection(listener: SelectionListener): () => void { + this.selectionListeners.add(listener); + return () => { + this.selectionListeners.delete(listener); + }; + } + + setCardRefreshHandler( + cardId: string | null | undefined, + handler: (() => void) | null | undefined, + ): void { + const key = String(cardId || "").trim(); + if (!key) return; + if (typeof handler !== "function") { + this.refreshHandlers.delete(key); + return; + } + this.refreshHandlers.set(key, handler); + } + + hasCardRefreshHandler(cardId: string | null | undefined): boolean { + const key = String(cardId || "").trim(); + if (!key) return false; + return this.refreshHandlers.has(key); + } + + runCardRefresh(cardId: string | null | undefined): boolean { + const key = String(cardId || "").trim(); + if (!key) return false; + const handler = this.refreshHandlers.get(key); + if (!handler) return false; + handler(); + return true; + } + + disposeCardRuntimeEntry(cardId: string | null | undefined): void { + const key = String(cardId || "").trim(); + if (!key) return; + this.liveContentStore.delete(key); + this.selectionStore.delete(key); + this.refreshHandlers.delete(key); + this.emitLiveContent(key, undefined); + this.emitSelection(key, undefined); + } +} + +const registry = new CardRuntimeRegistry(); + +function cloneJsonValue(value: JsonValue | undefined): JsonValue | undefined { + if (value === undefined) return undefined; + try { + return JSON.parse(JSON.stringify(value)) as JsonValue; + } catch { + return value; + } +} + +export function setCardLiveContent( + cardId: string | null | undefined, + snapshot: JsonValue | null | undefined, +): void { + registry.setCardLiveContent(cardId, snapshot); +} + +export function getCardLiveContent(cardId: string | null | undefined): JsonValue | undefined { + return registry.getCardLiveContent(cardId); +} + +export function subscribeCardLiveContent(listener: LiveContentListener): () => void { + return registry.subscribeCardLiveContent(listener); +} + +export function setCardSelection( + cardId: string | null | undefined, + selection: JsonValue | null | undefined, +): void { + registry.setCardSelection(cardId, selection); +} + +export function getCardSelection(cardId: string | null | undefined): JsonValue | undefined { + return registry.getCardSelection(cardId); +} + +export function clearCardSelection(cardId: string | null | undefined): void { + registry.clearCardSelection(cardId); +} + +export function subscribeCardSelection(listener: SelectionListener): () => void { + return registry.subscribeCardSelection(listener); +} + +export function setCardRefreshHandler( + cardId: string | null | undefined, + handler: (() => void) | null | undefined, +): void { + registry.setCardRefreshHandler(cardId, handler); +} + +export function hasCardRefreshHandler(cardId: string | null | undefined): boolean { + return registry.hasCardRefreshHandler(cardId); +} + +export function runCardRefresh(cardId: string | null | undefined): boolean { + return registry.runCardRefresh(cardId); +} + +export function disposeCardRuntimeEntry(cardId: string | null | undefined): void { + registry.disposeCardRuntimeEntry(cardId); +} + +export function requestCardFeedRefresh(): void { + if (typeof window === "undefined") return; + window.dispatchEvent(new Event("nanobot:cards-refresh")); +} diff --git a/frontend/src/components/CardBodyRenderer.tsx b/frontend/src/components/CardBodyRenderer.tsx new file mode 100644 index 0000000..9644bf1 --- /dev/null +++ b/frontend/src/components/CardBodyRenderer.tsx @@ -0,0 +1,124 @@ +import { marked } from "marked"; +import { useEffect, useRef, useState } from "preact/hooks"; +import { + loadRuntimeAssets, + materializeRuntimeHtml, + syncRuntimeStateScript, +} from "../cardRuntime/runtimeAssets"; +import { useRuntimeHost } from "../cardRuntime/runtimeHost"; +import type { MountedRuntimeCard, RuntimeItem, RuntimeSurface } from "../cardRuntime/runtimeTypes"; +import { looksLikeHtml, normalizeTemplateState, runtimeItemId } from "../cardRuntime/runtimeUtils"; +import { disposeCardRuntimeEntry } from "../cardRuntime/store"; +import type { JsonValue } from "../types"; + +function StaticCardTextBody({ + content, + bodyClass = "card-body", +}: { + content: string; + bodyClass?: string; +}) { + const html = looksLikeHtml(content) ? content : (marked.parse(content) as string); + return
        ; +} + +export function DynamicCardBody({ + item, + surface, + bodyClass = "card-body", +}: { + item: RuntimeItem; + surface: RuntimeSurface; + bodyClass?: string; +}) { + const templateKey = item.templateKey?.trim() || ""; + const identity = runtimeItemId(item); + const rootRef = useRef(null); + const mountedRef = useRef(null); + const [runtimeAvailable, setRuntimeAvailable] = useState( + templateKey ? null : false, + ); + const [runtimeState, setRuntimeState] = useState>( + normalizeTemplateState(item.templateState), + ); + const runtimeStateRef = useRef(runtimeState); + runtimeStateRef.current = runtimeState; + const host = useRuntimeHost(surface, item, runtimeStateRef, setRuntimeState); + + useEffect(() => { + const nextState = normalizeTemplateState(item.templateState); + const activeElement = document.activeElement; + const editingInside = + activeElement instanceof Node && !!rootRef.current?.contains(activeElement); + if (editingInside) return; + const currentJson = JSON.stringify(runtimeStateRef.current); + const nextJson = JSON.stringify(nextState); + if (currentJson === nextJson) return; + runtimeStateRef.current = nextState; + setRuntimeState(nextState); + }, [item.updatedAt, item.templateState]); + + useEffect(() => { + let cancelled = false; + + const mountRuntime = async () => { + if (!templateKey) { + setRuntimeAvailable(false); + return; + } + + setRuntimeAvailable(null); + const assets = await loadRuntimeAssets(templateKey); + if (cancelled) return; + if (!assets || !rootRef.current) { + setRuntimeAvailable(false); + return; + } + + mountedRef.current?.destroy?.(); + rootRef.current.innerHTML = materializeRuntimeHtml( + assets.html || "", + item, + runtimeStateRef.current, + ); + mountedRef.current = + assets.module.mount({ + root: rootRef.current, + item, + state: runtimeStateRef.current, + host, + }) || null; + setRuntimeAvailable(true); + }; + + void mountRuntime(); + + return () => { + cancelled = true; + mountedRef.current?.destroy?.(); + mountedRef.current = null; + host.setRefreshHandler(null); + host.setLiveContent(undefined); + host.clearSelection(); + disposeCardRuntimeEntry(identity); + }; + }, [host, identity, templateKey]); + + useEffect(() => { + if (!runtimeAvailable || !rootRef.current) return; + syncRuntimeStateScript(rootRef.current, runtimeState); + if (!mountedRef.current?.update) return; + mountedRef.current.update({ + root: rootRef.current, + item, + state: runtimeState, + host, + }); + }, [host, item, runtimeAvailable, runtimeState]); + + if (!templateKey || runtimeAvailable === false) { + return ; + } + + return
        ; +} diff --git a/frontend/src/components/CardFeed.tsx b/frontend/src/components/CardFeed.tsx index 625c0fd..cefabe8 100644 --- a/frontend/src/components/CardFeed.tsx +++ b/frontend/src/components/CardFeed.tsx @@ -1,877 +1,15 @@ -import { marked } from "marked"; -import { useEffect, useRef, useState } from "preact/hooks"; +import { memo } from "preact/compat"; +import { useCallback, useEffect, useRef, useState } from "preact/hooks"; +import { + getCardLiveContent, + hasCardRefreshHandler, + runCardRefresh, + subscribeCardLiveContent, +} from "../cardRuntime/store"; import type { CardItem, JsonValue } from "../types"; - -const EXECUTABLE_SCRIPT_TYPES = new Set([ - "", - "text/javascript", - "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>(); -const cardSelectionStore = new Map(); - -interface ManualToolResult { - tool_name: string; - content: string; - parsed: JsonValue | null; - is_json: boolean; -} - -interface ManualToolDefinition { - name: string; - description: string; - parameters: Record; - kind?: string; -} - -interface ManualToolJob { - job_id: string; - tool_name: string; - status: "queued" | "running" | "completed" | "failed"; - created_at: string; - started_at: string | null; - finished_at: string | null; - result: ManualToolResult | null; - error: string | null; - error_code: number | null; -} - -interface ManualToolAsyncOptions { - pollMs?: number; - timeoutMs?: number; -} - -type TaskLane = "backlog" | "in-progress" | "blocked" | "done" | "canceled"; -interface ListTotalRow { - value: string; - name: string; -} - -interface ListTotalCardState { - leftLabel: string; - rightLabel: string; - totalLabel: string; - totalSuffix: string; - maxDigits: number; - score: number; - rows: ListTotalRow[]; -} - -interface TaskCardState { - taskPath: string; - taskKey: string; - title: string; - lane: TaskLane; - created: string; - updated: string; - due: string; - tags: string[]; - body: string; - metadata: Record; -} - -const TASK_LANES: TaskLane[] = ["backlog", "in-progress", "blocked", "done", "canceled"]; - -const TASK_LANE_LABELS: Record = { - backlog: "Backlog", - "in-progress": "In Progress", - blocked: "Blocked", - done: "Done", - canceled: "Canceled", -}; - -const TASK_ACTION_LABELS: Record = { - backlog: "Backlog", - "in-progress": "Start", - blocked: "Block", - done: "Done", - canceled: "Cancel", -}; - -const TASK_LANE_THEMES: Record< - TaskLane, - { accent: string; accentSoft: string; muted: string; buttonInk: string } -> = { - backlog: { - accent: "#5f7884", - accentSoft: "rgba(95, 120, 132, 0.13)", - muted: "#6b7e87", - buttonInk: "#294a57", - }, - "in-progress": { - accent: "#4f7862", - accentSoft: "rgba(79, 120, 98, 0.13)", - muted: "#5e7768", - buttonInk: "#214437", - }, - blocked: { - accent: "#a55f4b", - accentSoft: "rgba(165, 95, 75, 0.13)", - muted: "#906659", - buttonInk: "#6c2f21", - }, - done: { - accent: "#6d7f58", - accentSoft: "rgba(109, 127, 88, 0.12)", - muted: "#6b755d", - buttonInk: "#304121", - }, - canceled: { - accent: "#7b716a", - accentSoft: "rgba(123, 113, 106, 0.12)", - muted: "#7b716a", - buttonInk: "#433932", - }, -}; - -function isTaskLane(value: unknown): value is TaskLane { - return typeof value === "string" && TASK_LANES.includes(value as TaskLane); -} - -function parseTemplateStateFromContent(content: string): Record | undefined { - if (!content.includes("data-card-state")) return undefined; - try { - const parsed = new DOMParser().parseFromString(content, "text/html"); - const stateEl = parsed.querySelector('script[data-card-state][type="application/json"]'); - if (!(stateEl instanceof HTMLScriptElement)) return undefined; - const payload = JSON.parse(stateEl.textContent || "{}"); - if (!payload || typeof payload !== "object" || Array.isArray(payload)) return undefined; - return payload as Record; - } catch { - return undefined; - } -} - -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(); - 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 normalizeTaskMetadata(raw: unknown): Record { - if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {}; - return Object.fromEntries( - Object.entries(raw as Record).filter(([, value]) => value !== undefined), - ); -} - -function sanitizeListTotalValue(raw: unknown, maxDigits: number): string { - return String(raw || "") - .replace(/\D+/g, "") - .slice(0, maxDigits); -} - -function sanitizeListTotalName(raw: unknown): string { - return String(raw || "") - .replace(/\s+/g, " ") - .trimStart(); -} - -function isBlankListTotalRow(row: ListTotalRow | null | undefined): boolean { - if (!row) return true; - return !row.value.trim() && !row.name.trim(); -} - -function ensureTrailingBlankListTotalRow(rows: ListTotalRow[]): ListTotalRow[] { - const next = rows.map((row) => ({ value: row.value, name: row.name })); - if (!next.length || !isBlankListTotalRow(next[next.length - 1])) { - next.push({ value: "", name: "" }); - } - return next; -} - -function normalizeListTotalRows(raw: unknown, maxDigits: number): ListTotalRow[] { - if (!Array.isArray(raw)) return [{ value: "", name: "" }]; - return ensureTrailingBlankListTotalRow( - raw - .filter((row) => row && typeof row === "object" && !Array.isArray(row)) - .map((row) => { - const record = row as Record; - return { - value: sanitizeListTotalValue(record.value, maxDigits), - name: sanitizeListTotalName(record.name), - }; - }), - ); -} - -function normalizeListTotalCardState( - card: Pick, -): ListTotalCardState | null { - if (card.templateKey !== "list-total-live") return null; - const raw = card.templateState; - if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null; - const maxDigits = Math.max( - 1, - Math.min( - 4, - Number.isFinite(Number((raw as Record).max_digits)) - ? Number((raw as Record).max_digits) - : 4, - ), - ); - return { - leftLabel: - typeof (raw as Record).left_label === "string" && - String((raw as Record).left_label).trim() - ? String((raw as Record).left_label).trim() - : "Value", - rightLabel: - typeof (raw as Record).right_label === "string" && - String((raw as Record).right_label).trim() - ? String((raw as Record).right_label).trim() - : "Item", - totalLabel: - typeof (raw as Record).total_label === "string" && - String((raw as Record).total_label).trim() - ? String((raw as Record).total_label).trim() - : "Total", - totalSuffix: - typeof (raw as Record).total_suffix === "string" - ? String((raw as Record).total_suffix).trim() - : "", - maxDigits, - score: - typeof (raw as Record).score === "number" && - Number.isFinite((raw as Record).score as number) - ? Math.max(0, Math.min(100, (raw as Record).score as number)) - : 24, - rows: normalizeListTotalRows((raw as Record).rows, maxDigits), - }; -} - -function normalizeTaskCardState( - card: Pick, -): TaskCardState | null { - if (card.templateKey !== "todo-item-live") return null; - const raw = card.templateState ?? parseTemplateStateFromContent(card.content); - if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null; - const taskPath = typeof raw.task_path === "string" ? raw.task_path.trim() : ""; - const lane = isTaskLane(raw.lane) ? raw.lane : "backlog"; - return { - taskPath, - taskKey: typeof raw.task_key === "string" ? raw.task_key.trim() : "", - title: - typeof raw.title === "string" && raw.title.trim() - ? raw.title.trim() - : card.title || "(Untitled task)", - lane, - created: typeof raw.created === "string" ? raw.created.trim() : "", - updated: typeof raw.updated === "string" ? raw.updated.trim() : "", - due: typeof raw.due === "string" ? raw.due.trim() : "", - tags: normalizeTaskTags(raw.tags), - body: typeof raw.body === "string" ? raw.body : "", - metadata: normalizeTaskMetadata(raw.metadata), - }; -} - -function normalizeTaskFromPayload(raw: unknown, fallback: TaskCardState): TaskCardState { - if (!raw || typeof raw !== "object" || Array.isArray(raw)) return fallback; - const record = raw as Record; - return { - taskPath: typeof record.path === "string" ? record.path.trim() : fallback.taskPath, - taskKey: fallback.taskKey, - title: - typeof record.title === "string" && record.title.trim() - ? record.title.trim() - : fallback.title, - lane: isTaskLane(record.lane) ? record.lane : fallback.lane, - created: typeof record.created === "string" ? record.created.trim() : fallback.created, - updated: typeof record.updated === "string" ? record.updated.trim() : fallback.updated, - due: typeof record.due === "string" ? record.due.trim() : "", - tags: normalizeTaskTags(record.tags), - body: typeof record.body === "string" ? record.body : fallback.body, - metadata: normalizeTaskMetadata(record.metadata), - }; -} - -function dueScore(hoursUntilDue: number): number { - if (hoursUntilDue <= 0) return 100; - if (hoursUntilDue <= 6) return 96; - if (hoursUntilDue <= 24) return 92; - if (hoursUntilDue <= 72) return 82; - if (hoursUntilDue <= 168) return 72; - return 62; -} - -function ageScore(ageDays: number): number { - if (ageDays >= 30) return 80; - if (ageDays >= 21) return 76; - if (ageDays >= 14) return 72; - if (ageDays >= 7) return 68; - if (ageDays >= 3) return 62; - if (ageDays >= 1) return 58; - return 54; -} - -function computeTaskScore(task: TaskCardState): number { - const now = Date.now(); - const rawDue = task.due ? (task.due.includes("T") ? task.due : `${task.due}T12:00:00`) : ""; - const dueMs = rawDue ? new Date(rawDue).getTime() : Number.NaN; - let score = 54; - if (Number.isFinite(dueMs)) { - const hoursUntilDue = (dueMs - now) / (60 * 60 * 1000); - score = dueScore(hoursUntilDue); - } else { - const createdMs = task.created ? new Date(task.created).getTime() : Number.NaN; - if (Number.isFinite(createdMs)) { - const ageDays = Math.max(0, (now - createdMs) / (24 * 60 * 60 * 1000)); - score = ageScore(ageDays); - } - } - if (task.lane === "blocked") return Math.min(100, score + 4); - if (task.lane === "in-progress") return Math.min(100, score + 2); - return score; -} - -function summarizeTaskBody(task: TaskCardState): string { - const trimmed = task.body.trim(); - if (!trimmed || /^##\s+Imported\b/i.test(trimmed)) return ""; - return trimmed; -} - -function renderTaskBodyMarkdown(body: string): string { - if (!body) return ""; - return body - .replace(/\r\n?/g, "\n") - .trim() - .split("\n") - .map((line) => { - const trimmed = line.trim(); - if (!trimmed) return ''; - - let className = "task-card-ui__md-line"; - let content = trimmed; - let prefix = ""; - - const headingMatch = trimmed.match(/^(#{1,6})\s+(.*)$/); - if (headingMatch) { - className += " task-card-ui__md-line--heading"; - content = headingMatch[2]; - } else if (/^[-*]\s+/.test(trimmed)) { - className += " task-card-ui__md-line--bullet"; - content = trimmed.replace(/^[-*]\s+/, ""); - prefix = "\u2022 "; - } else if (/^\d+\.\s+/.test(trimmed)) { - className += " task-card-ui__md-line--bullet"; - content = trimmed.replace(/^\d+\.\s+/, ""); - prefix = "\u2022 "; - } else if (/^>\s+/.test(trimmed)) { - className += " task-card-ui__md-line--quote"; - content = trimmed.replace(/^>\s+/, ""); - prefix = "> "; - } - - const html = marked.parseInline(content, { gfm: true, breaks: true }) as string; - return `${ - prefix ? `${prefix}` : "" - }${html}`; - }) - .join(""); -} - -function formatTaskDue(task: TaskCardState): string { - if (!task.due) return ""; - const raw = task.due.includes("T") ? task.due : `${task.due}T00:00:00`; - const parsed = new Date(raw); - if (Number.isNaN(parsed.getTime())) return task.due; - if (task.due.includes("T")) { - const label = parsed.toLocaleString([], { - month: "short", - day: "numeric", - hour: "numeric", - minute: "2-digit", - }); - return label.replace(/\s([AP]M)$/i, "$1"); - } - return parsed.toLocaleDateString([], { month: "short", day: "numeric" }); -} - -function taskMoveOptions(lane: TaskLane): Array<{ lane: TaskLane; label: string }> { - return TASK_LANES.filter((targetLane) => targetLane !== lane).map((targetLane) => ({ - lane: targetLane, - label: TASK_ACTION_LABELS[targetLane], - })); -} - -function taskCardLiveContent(task: TaskCardState, errorText: string): Record { - return { - kind: "file_task", - exists: true, - task_path: task.taskPath || null, - task_key: task.taskKey || null, - title: task.title || null, - lane: task.lane, - created: task.created || null, - updated: task.updated || null, - due: task.due || null, - tags: task.tags, - metadata: task.metadata, - score: computeTaskScore(task), - status: task.lane, - error: errorText || null, - }; -} - -function cloneJsonValue(value: T | null | undefined): T | undefined { - if (value === null || value === undefined) return undefined; - try { - return JSON.parse(JSON.stringify(value)) as T; - } catch { - return undefined; - } -} - -function parseToolPayload( - result: ManualToolResult | null | undefined, -): Record | null { - const payload = result?.parsed; - if (payload && typeof payload === "object" && !Array.isArray(payload)) { - return payload as Record; - } - const raw = typeof result?.content === "string" ? result.content : ""; - if (!raw.trim()) return null; - try { - const parsed = JSON.parse(raw); - return parsed && typeof parsed === "object" && !Array.isArray(parsed) - ? (parsed as Record) - : null; - } catch { - return null; - } -} - -function readCardState(script: HTMLScriptElement | null): Record { - const root = script?.closest("[data-nanobot-card-root]"); - if (!(root instanceof HTMLElement)) return {}; - - const stateEl = root.querySelector('script[data-card-state][type="application/json"]'); - if (!(stateEl instanceof HTMLScriptElement)) return {}; - - try { - const parsed = JSON.parse(stateEl.textContent || "{}"); - return typeof parsed === "object" && parsed !== null ? (parsed as Record) : {}; - } catch { - return {}; - } -} - -function resolveCardRoot(target: HTMLScriptElement | HTMLElement | null): HTMLElement | null { - if (!(target instanceof HTMLElement)) return null; - if (target.matches("[data-nanobot-card-root]")) return target; - 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, -): void { - const root = resolveCardRoot(target); - const cardId = root?.dataset.cardId?.trim(); - if (!cardId) return; - 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 { - const key = (cardId || "").trim(); - if (!key) return undefined; - return cloneJsonValue(cardLiveContentStore.get(key)); -} - -function setCardRefreshHandler( - target: HTMLScriptElement | HTMLElement | null, - handler: (() => void) | null | undefined, -): void { - const root = resolveCardRoot(target); - const cardId = root?.dataset.cardId?.trim(); - if (!cardId) return; - if (typeof handler !== "function") { - cardRefreshHandlers.delete(cardId); - return; - } - cardRefreshHandlers.set(cardId, handler); -} - -function runCardRefresh(cardId: string | null | undefined): boolean { - const key = (cardId || "").trim(); - if (!key) return false; - const handler = cardRefreshHandlers.get(key); - if (!handler) return false; - handler(); - return true; -} - -function dispatchCardSelectionChange(cardId: string, selection: JsonValue | undefined): void { - window.dispatchEvent( - new CustomEvent(CARD_SELECTION_EVENT, { - detail: { cardId, selection: selection ?? null }, - }), - ); -} - -function setCardSelection( - target: HTMLScriptElement | HTMLElement | null, - selection: JsonValue | null | undefined, -): void { - const root = resolveCardRoot(target); - const cardId = root?.dataset.cardId?.trim(); - if (!cardId) return; - const cloned = cloneJsonValue(selection ?? undefined); - if (cloned === undefined) { - cardSelectionStore.delete(cardId); - dispatchCardSelectionChange(cardId, undefined); - return; - } - cardSelectionStore.set(cardId, cloned); - dispatchCardSelectionChange(cardId, cloned); -} - -function getCardSelection(cardId: string | null | undefined): JsonValue | undefined { - const key = (cardId || "").trim(); - if (!key) return undefined; - return cloneJsonValue(cardSelectionStore.get(key)); -} - -function clearCardSelection(cardId: string | null | undefined): void { - const key = (cardId || "").trim(); - if (!key) return; - cardSelectionStore.delete(key); - dispatchCardSelectionChange(key, undefined); -} - -function nextLocalMidnightIso(): string { - const tomorrow = new Date(); - tomorrow.setHours(24, 0, 0, 0); - return tomorrow.toISOString(); -} - -async function snoozeCardUntilTomorrow(cardId: string | null | undefined): Promise { - const key = (cardId || "").trim(); - if (!key) throw new Error("card id is required"); - const resp = await fetch(`/cards/${encodeURIComponent(key)}/snooze`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ until: nextLocalMidnightIso() }), - }); - if (!resp.ok) throw new Error(await decodeJsonError(resp)); - window.dispatchEvent(new Event("nanobot:cards-refresh")); -} - -async function decodeJsonError(resp: Response): Promise { - try { - const payload = (await resp.json()) as { error?: unknown }; - if (payload && typeof payload === "object" && typeof payload.error === "string") { - return payload.error; - } - } catch { - // Ignore invalid error bodies and fall back to the status code. - } - return `request failed (${resp.status})`; -} - -async function updateCardTemplateState( - cardId: string, - templateState: Record, -): Promise { - const key = cardId.trim(); - if (!key) throw new Error("card id is required"); - const resp = await fetch(`/cards/${encodeURIComponent(key)}/state`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ template_state: templateState }), - }); - if (!resp.ok) throw new Error(await decodeJsonError(resp)); -} - -function normalizeManualToolResult( - payload: Partial | null | undefined, - fallbackName: string, -): ManualToolResult { - return { - tool_name: typeof payload?.tool_name === "string" ? payload.tool_name : fallbackName, - content: typeof payload?.content === "string" ? payload.content : "", - parsed: - payload?.parsed === null || payload?.parsed === undefined - ? null - : (cloneJsonValue(payload.parsed as JsonValue) ?? null), - is_json: payload?.is_json === true, - }; -} - -function normalizeManualToolJob(payload: unknown, fallbackName: string): ManualToolJob { - const record = payload && typeof payload === "object" ? (payload as Record) : {}; - const toolName = typeof record.tool_name === "string" ? record.tool_name : fallbackName; - const statusValue = typeof record.status === "string" ? record.status : "queued"; - return { - job_id: typeof record.job_id === "string" ? record.job_id : "", - tool_name: toolName, - status: - statusValue === "running" || statusValue === "completed" || statusValue === "failed" - ? statusValue - : "queued", - created_at: typeof record.created_at === "string" ? record.created_at : "", - started_at: typeof record.started_at === "string" ? record.started_at : null, - finished_at: typeof record.finished_at === "string" ? record.finished_at : null, - result: - record.result && typeof record.result === "object" - ? normalizeManualToolResult(record.result as Partial, toolName) - : null, - error: typeof record.error === "string" ? record.error : null, - error_code: typeof record.error_code === "number" ? record.error_code : null, - }; -} - -function normalizeManualToolAsyncOptions(options: ManualToolAsyncOptions): { - pollMs: number; - timeoutMs: number; -} { - const pollMs = - typeof options.pollMs === "number" && Number.isFinite(options.pollMs) && options.pollMs >= 100 - ? options.pollMs - : 400; - const timeoutMs = - typeof options.timeoutMs === "number" && - Number.isFinite(options.timeoutMs) && - options.timeoutMs >= 1000 - ? options.timeoutMs - : 120000; - return { pollMs, timeoutMs }; -} - -async function waitForManualToolJob( - initialJob: ManualToolJob, - toolName: string, - timeoutMs: number, - pollMs: number, -): Promise { - const deadline = Date.now() + timeoutMs; - let job = initialJob; - - while (true) { - if (job.status === "completed") { - if (!job.result) throw new Error("tool job completed without a result"); - return job.result; - } - if (job.status === "failed") { - throw new Error(job.error || `tool job failed (${job.tool_name || toolName})`); - } - if (Date.now() >= deadline) { - throw new Error(`tool job timed out after ${Math.round(timeoutMs / 1000)}s`); - } - await new Promise((resolve) => window.setTimeout(resolve, pollMs)); - job = await getManualToolJob(job.job_id); - } -} - -async function callManualTool( - toolName: string, - argumentsValue: Record = {}, -): Promise { - const name = toolName.trim(); - if (!name) throw new Error("tool name is required"); - - const cloned = cloneJsonValue(argumentsValue as JsonValue); - const safeArguments = - cloned && typeof cloned === "object" && !Array.isArray(cloned) - ? (cloned as Record) - : {}; - - const resp = await fetch("/tools/call", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ tool_name: name, arguments: safeArguments }), - }); - if (!resp.ok) throw new Error(await decodeJsonError(resp)); - - const payload = await resp.json(); - if (!payload || typeof payload !== "object") { - throw new Error("invalid tool response"); - } - - return normalizeManualToolResult(payload as Partial, name); -} - -async function startManualToolCall( - toolName: string, - argumentsValue: Record = {}, -): Promise { - const name = toolName.trim(); - if (!name) throw new Error("tool name is required"); - - const cloned = cloneJsonValue(argumentsValue as JsonValue); - const safeArguments = - cloned && typeof cloned === "object" && !Array.isArray(cloned) - ? (cloned as Record) - : {}; - - const resp = await fetch("/tools/call", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ tool_name: name, arguments: safeArguments, async: true }), - }); - if (!resp.ok) throw new Error(await decodeJsonError(resp)); - - const payload = await resp.json(); - if (!payload || typeof payload !== "object") { - throw new Error("invalid tool job response"); - } - - const job = normalizeManualToolJob(payload, name); - if (!job.job_id) throw new Error("tool job id is required"); - return job; -} - -async function getManualToolJob(jobId: string): Promise { - const key = jobId.trim(); - if (!key) throw new Error("tool job id is required"); - - const resp = await fetch(`/tools/jobs/${encodeURIComponent(key)}`, { cache: "no-store" }); - if (!resp.ok) throw new Error(await decodeJsonError(resp)); - - const payload = await resp.json(); - if (!payload || typeof payload !== "object") { - throw new Error("invalid tool job response"); - } - - return normalizeManualToolJob(payload, ""); -} - -async function callManualToolAsync( - toolName: string, - argumentsValue: Record = {}, - options: ManualToolAsyncOptions = {}, -): Promise { - const { pollMs, timeoutMs } = normalizeManualToolAsyncOptions(options); - const job = await startManualToolCall(toolName, argumentsValue); - return waitForManualToolJob(job, toolName, timeoutMs, pollMs); -} - -async function listManualTools(): Promise { - const resp = await fetch("/tools", { cache: "no-store" }); - if (!resp.ok) throw new Error(await decodeJsonError(resp)); - - const payload = (await resp.json()) as { tools?: unknown }; - const tools = Array.isArray(payload?.tools) ? payload.tools : []; - return tools - .filter((tool): tool is Record => !!tool && typeof tool === "object") - .map((tool) => ({ - name: typeof tool.name === "string" ? tool.name : "", - description: typeof tool.description === "string" ? tool.description : "", - parameters: - tool.parameters && typeof tool.parameters === "object" && !Array.isArray(tool.parameters) - ? (tool.parameters as Record) - : {}, - kind: typeof tool.kind === "string" ? tool.kind : undefined, - })) - .filter((tool) => tool.name); -} - -function ensureCardStateHelper(): void { - if (!window.__nanobotGetCardState) { - window.__nanobotGetCardState = readCardState; - } - if (!window.__nanobotSetCardLiveContent) { - window.__nanobotSetCardLiveContent = setCardLiveContent; - } - if (!window.__nanobotGetCardLiveContent) { - window.__nanobotGetCardLiveContent = getCardLiveContent; - } - if (!window.__nanobotSetCardRefresh) { - window.__nanobotSetCardRefresh = setCardRefreshHandler; - } - if (!window.__nanobotRefreshCard) { - window.__nanobotRefreshCard = runCardRefresh; - } - if (!window.__nanobotSetCardSelection) { - window.__nanobotSetCardSelection = setCardSelection; - } - if (!window.__nanobotGetCardSelection) { - window.__nanobotGetCardSelection = getCardSelection; - } - if (!window.__nanobotClearCardSelection) { - window.__nanobotClearCardSelection = clearCardSelection; - } - if (!window.__nanobotCallTool) { - window.__nanobotCallTool = callManualTool; - } - if (!window.__nanobotStartToolCall) { - window.__nanobotStartToolCall = startManualToolCall; - } - if (!window.__nanobotGetToolJob) { - window.__nanobotGetToolJob = getManualToolJob; - } - if (!window.__nanobotCallToolAsync) { - window.__nanobotCallToolAsync = callManualToolAsync; - } - if (!window.__nanobotListTools) { - window.__nanobotListTools = listManualTools; - } -} - -declare global { - interface Window { - __nanobotGetCardState?: (script: HTMLScriptElement | null) => Record; - __nanobotSetCardLiveContent?: ( - target: HTMLScriptElement | HTMLElement | null, - snapshot: JsonValue | null | undefined, - ) => void; - __nanobotGetCardLiveContent?: (cardId: string | null | undefined) => JsonValue | undefined; - __nanobotSetCardRefresh?: ( - target: HTMLScriptElement | HTMLElement | null, - handler: (() => void) | null | undefined, - ) => void; - __nanobotRefreshCard?: (cardId: string | null | undefined) => boolean; - __nanobotSetCardSelection?: ( - target: HTMLScriptElement | HTMLElement | null, - selection: JsonValue | null | undefined, - ) => void; - __nanobotGetCardSelection?: (cardId: string | null | undefined) => JsonValue | undefined; - __nanobotClearCardSelection?: (cardId: string | null | undefined) => void; - __nanobotCallTool?: ( - toolName: string, - argumentsValue?: Record, - ) => Promise; - __nanobotStartToolCall?: ( - toolName: string, - argumentsValue?: Record, - ) => Promise; - __nanobotGetToolJob?: (jobId: string) => Promise; - __nanobotCallToolAsync?: ( - toolName: string, - argumentsValue?: Record, - options?: ManualToolAsyncOptions, - ) => Promise; - __nanobotListTools?: () => Promise; - } -} +import { DynamicCardBody } from "./CardBodyRenderer"; +import { captureInboxItem, InboxQuickAdd, InboxReviewCard } from "./cardFeed/inbox"; +import { snoozeCardUntilTomorrow } from "./cardFeed/snooze"; interface CardProps { card: CardItem; @@ -890,35 +28,6 @@ function MoreIcon() { ); } -function CardTextBody({ card }: { card: CardItem }) { - const bodyRef = useRef(null); - - useEffect(() => { - ensureCardStateHelper(); - - const root = bodyRef.current; - if (!root) return; - const scripts = Array.from(root.querySelectorAll("script")); - for (const oldScript of scripts) { - const type = (oldScript.getAttribute("type") || "").trim().toLowerCase(); - if (!EXECUTABLE_SCRIPT_TYPES.has(type)) continue; - const runtimeScript = document.createElement("script"); - for (const attr of oldScript.attributes) runtimeScript.setAttribute(attr.name, attr.value); - runtimeScript.text = oldScript.textContent || ""; - oldScript.replaceWith(runtimeScript); - } - return () => { - window.__nanobotSetCardLiveContent?.(bodyRef.current, null); - window.__nanobotSetCardRefresh?.(bodyRef.current, null); - window.__nanobotSetCardSelection?.(bodyRef.current, null); - }; - }, [card.id, card.content]); - - const looksLikeHtml = /^\s*<[a-zA-Z]/.test(card.content); - const html = looksLikeHtml ? card.content : (marked.parse(card.content) as string); - return
        ; -} - function CardQuestionBody({ card, responding, @@ -956,1073 +65,7 @@ function CardQuestionBody({ ); } -type TaskEditField = "title" | "body"; -type TaskStatusKind = "error" | "neutral"; - -function autosizeTaskEditor(editor: HTMLTextAreaElement | null): void { - if (!editor) return; - editor.style.height = "0px"; - editor.style.height = `${Math.max(editor.scrollHeight, 20)}px`; -} - -async function callTaskBoardAction( - argumentsValue: Record, -): Promise | null> { - const result = await callManualTool("task_board", argumentsValue); - const payload = parseToolPayload(result); - if (payload && typeof payload.error === "string" && payload.error.trim()) { - throw new Error(payload.error); - } - return payload; -} - -function useTaskCardState(card: CardItem) { - const initialTask = normalizeTaskCardState(card); - const [task, setTask] = useState(initialTask); - const [busy, setBusy] = useState(false); - const [statusLabel, setStatusLabel] = useState(""); - const [statusKind, setStatusKind] = useState("neutral"); - const [errorText, setErrorText] = useState(""); - const [laneMenuOpen, setLaneMenuOpen] = useState(false); - const [editingField, setEditingField] = useState(null); - const [draftTitle, setDraftTitle] = useState(initialTask?.title ?? ""); - const [draftBody, setDraftBody] = useState(initialTask?.body ?? ""); - const [holdingTag, setHoldingTag] = useState(null); - - useEffect(() => { - const nextTask = normalizeTaskCardState(card); - setTask(nextTask); - setBusy(false); - setStatusLabel(""); - setStatusKind("neutral"); - setErrorText(""); - setLaneMenuOpen(false); - setEditingField(null); - setDraftTitle(nextTask?.title ?? ""); - setDraftBody(nextTask?.body ?? ""); - }, [card.serverId, card.updatedAt]); - - return { - task, - setTask, - busy, - setBusy, - statusLabel, - setStatusLabel, - statusKind, - setStatusKind, - errorText, - setErrorText, - laneMenuOpen, - setLaneMenuOpen, - editingField, - setEditingField, - draftTitle, - setDraftTitle, - draftBody, - setDraftBody, - holdingTag, - setHoldingTag, - }; -} - -function useTaskCardEffects({ - rootRef, - laneMenuRef, - titleEditorRef, - bodyEditorRef, - holdTimerRef, - task, - errorText, - laneMenuOpen, - editingField, - closeLaneMenu, -}: { - rootRef: { current: HTMLDivElement | null }; - laneMenuRef: { current: HTMLDivElement | null }; - titleEditorRef: { current: HTMLTextAreaElement | null }; - bodyEditorRef: { current: HTMLTextAreaElement | null }; - holdTimerRef: { current: number | null }; - task: TaskCardState | null; - errorText: string; - laneMenuOpen: boolean; - editingField: TaskEditField | null; - closeLaneMenu(): void; -}) { - useEffect(() => { - ensureCardStateHelper(); - }, []); - - useEffect(() => { - return () => { - if (holdTimerRef.current !== null) { - window.clearTimeout(holdTimerRef.current); - } - window.__nanobotSetCardLiveContent?.(rootRef.current, null); - window.__nanobotSetCardSelection?.(rootRef.current, null); - window.__nanobotSetCardRefresh?.(rootRef.current, null); - }; - }, []); - - useEffect(() => { - if (!laneMenuOpen) return; - const handlePointerDown = (event: PointerEvent) => { - if (event.target instanceof Node && !laneMenuRef.current?.contains(event.target)) { - closeLaneMenu(); - } - }; - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") closeLaneMenu(); - }; - document.addEventListener("pointerdown", handlePointerDown); - document.addEventListener("keydown", handleKeyDown); - return () => { - document.removeEventListener("pointerdown", handlePointerDown); - document.removeEventListener("keydown", handleKeyDown); - }; - }, [laneMenuOpen, closeLaneMenu]); - - useEffect(() => { - const editor = editingField === "title" ? titleEditorRef.current : bodyEditorRef.current; - if (!editor) return; - autosizeTaskEditor(editor); - editor.focus(); - const end = editor.value.length; - editor.setSelectionRange(end, end); - }, [editingField]); - - useEffect(() => { - window.__nanobotSetCardLiveContent?.( - rootRef.current, - task ? taskCardLiveContent(task, errorText) : null, - ); - }, [task, errorText]); -} - -function createTagHoldHandlers({ - setHoldingTag, - holdTimerRef, - removeTag, -}: { - setHoldingTag: (value: string | null) => void; - holdTimerRef: { current: number | null }; - removeTag: (tag: string) => Promise; -}) { - const clearTagHold = () => { - if (holdTimerRef.current !== null) { - window.clearTimeout(holdTimerRef.current); - holdTimerRef.current = null; - } - setHoldingTag(null); - }; - - const beginTagHold = (tag: string, busy: boolean) => { - if (busy) return; - clearTagHold(); - setHoldingTag(tag); - holdTimerRef.current = window.setTimeout(() => { - holdTimerRef.current = null; - setHoldingTag(null); - if (window.confirm(`Remove ${tag} from this task?`)) void removeTag(tag); - }, 650); - }; - - return { beginTagHold, clearTagHold }; -} - -async function runTaskBusyAction( - setBusy: (value: boolean) => void, - setStatusLabel: (value: string) => void, - setStatusKind: (value: TaskStatusKind) => void, - setErrorText: (value: string) => void, - action: () => Promise, -) { - setBusy(true); - setStatusKind("neutral"); - setStatusLabel("Saving"); - setErrorText(""); - try { - await action(); - setBusy(false); - setStatusLabel(""); - } catch (error) { - console.error("Task card action failed", error); - setBusy(false); - setStatusKind("error"); - setStatusLabel("Unavailable"); - setErrorText(error instanceof Error ? error.message : String(error)); - } -} - -function createTaskCardActions({ - task, - setTask, - setBusy, - setStatusLabel, - setStatusKind, - setErrorText, - setLaneMenuOpen, - setEditingField, - setDraftTitle, - setDraftBody, - setHoldingTag, - holdTimerRef, - titleEditorRef, -}: { - task: TaskCardState; - setTask: ( - value: TaskCardState | ((current: TaskCardState | null) => TaskCardState | null), - ) => void; - setBusy: (value: boolean) => void; - setStatusLabel: (value: string) => void; - setStatusKind: (value: TaskStatusKind) => void; - setErrorText: (value: string) => void; - setLaneMenuOpen: (value: boolean | ((current: boolean) => boolean)) => void; - setEditingField: (value: TaskEditField | null) => void; - setDraftTitle: (value: string) => void; - setDraftBody: (value: string) => void; - setHoldingTag: (value: string | null) => void; - holdTimerRef: { current: number | null }; - titleEditorRef: { current: HTMLTextAreaElement | null }; -}) { - const closeLaneMenu = () => setLaneMenuOpen(false); - const refreshCards = () => { - closeLaneMenu(); - window.dispatchEvent(new Event("nanobot:cards-refresh")); - }; - - const moveTask = async (lane: TaskLane) => - runTaskBusyAction(setBusy, setStatusLabel, setStatusKind, setErrorText, async () => { - const payload = await callTaskBoardAction({ action: "move", task: task.taskPath, lane }); - const nextPath = - typeof payload?.task_path === "string" ? payload.task_path.trim() : task.taskPath; - setTask((current) => - current - ? { ...current, taskPath: nextPath, lane, updated: new Date().toISOString() } - : current, - ); - refreshCards(); - }); - - const editField = async (field: TaskEditField, rawValue: string) => { - const nextValue = rawValue.trim(); - if (field === "title" && !nextValue) { - titleEditorRef.current?.focus(); - return; - } - const currentValue = field === "title" ? task.title : task.body; - if (nextValue === currentValue) { - setEditingField(null); - return; - } - await runTaskBusyAction(setBusy, setStatusLabel, setStatusKind, setErrorText, async () => { - const payload = await callTaskBoardAction({ - action: "edit", - task: task.taskPath, - ...(field === "title" ? { title: nextValue } : { description: nextValue }), - }); - const nextTask = normalizeTaskFromPayload(payload?.task, { - ...task, - ...(field === "title" ? { title: nextValue } : { body: nextValue }), - }); - setTask(nextTask); - setDraftTitle(nextTask.title); - setDraftBody(nextTask.body); - setEditingField(null); - refreshCards(); - }); - }; - - const addTag = async () => { - const raw = window.prompt("Add tag to task", ""); - const tag = raw == null ? "" : normalizeTaskTag(raw); - if (!tag) return; - await runTaskBusyAction(setBusy, setStatusLabel, setStatusKind, setErrorText, async () => { - const payload = await callTaskBoardAction({ - action: "add_tag", - task: task.taskPath, - tags: [tag], - }); - const nextTask = normalizeTaskFromPayload(payload?.task, { - ...task, - tags: Array.from(new Set([...task.tags, tag])), - }); - setTask(nextTask); - refreshCards(); - }); - }; - - const removeTag = async (tag: string) => - runTaskBusyAction(setBusy, setStatusLabel, setStatusKind, setErrorText, async () => { - const payload = await callTaskBoardAction({ - action: "remove_tag", - task: task.taskPath, - tags: [tag], - }); - const nextTask = normalizeTaskFromPayload(payload?.task, { - ...task, - tags: task.tags.filter((value) => value !== tag), - }); - setTask(nextTask); - refreshCards(); - }); - - const { beginTagHold, clearTagHold } = createTagHoldHandlers({ - setHoldingTag, - holdTimerRef, - removeTag, - }); - - return { addTag, beginTagHold, clearTagHold, closeLaneMenu, editField, moveTask, refreshCards }; -} - -function useTaskCardRefs() { - return { - rootRef: useRef(null), - laneMenuRef: useRef(null), - titleEditorRef: useRef(null), - bodyEditorRef: useRef(null), - holdTimerRef: useRef(null), - }; -} - -function TaskCardHeaderRow({ - lane, - taskPath, - laneMenuOpen, - statusLabel, - statusKind, - busy, - laneMenuRef, - onToggleMenu, - onMove, -}: { - lane: TaskLane; - taskPath: string; - laneMenuOpen: boolean; - statusLabel: string; - statusKind: TaskStatusKind; - busy: boolean; - laneMenuRef: { current: HTMLDivElement | null }; - onToggleMenu(): void; - onMove(lane: TaskLane): void; -}) { - return ( -
        -
        - - {laneMenuOpen && ( - - )} -
        - {statusLabel ? ( - - {statusLabel} - - ) : null} -
        - ); -} - -function TaskCardTitleField({ - task, - busy, - editingField, - draftTitle, - titleEditorRef, - onDraftTitle, - onStartEdit, - onSave, - onCancel, -}: { - task: TaskCardState; - busy: boolean; - editingField: TaskEditField | null; - draftTitle: string; - titleEditorRef: { current: HTMLTextAreaElement | null }; - onDraftTitle(value: string): void; - onStartEdit(): void; - onSave(): void; - onCancel(): void; -}) { - if (editingField === "title") { - return ( -