diff --git a/app.py b/app.py
index 8201ccb..f881b64 100644
--- a/app.py
+++ b/app.py
@@ -38,6 +38,10 @@ _JSONRPC_VERSION = "2.0"
_TOOL_JOB_TIMEOUT_SECONDS = 300.0
_TOOL_JOB_RETENTION_SECONDS = 15 * 60
_NANOBOT_API_STREAM_LIMIT = 2 * 1024 * 1024
+_TTS_SENTENCE_BREAK_RE = re.compile(r"(?<=[.!?])\s+")
+_TTS_CLAUSE_BREAK_RE = re.compile(r"(?<=[,;:])\s+")
+_TTS_SEGMENT_TARGET_CHARS = 180
+_TTS_SEGMENT_MAX_CHARS = 260
CARD_INSTANCES_DIR.mkdir(parents=True, exist_ok=True)
CARD_TEMPLATES_DIR.mkdir(parents=True, exist_ok=True)
@@ -104,6 +108,19 @@ def _decode_object(raw: str) -> dict[str, Any] | None:
return payload if isinstance(payload, dict) else None
+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)
+
+
async def _read_json_request(request: Request) -> dict[str, Any]:
try:
payload = await request.json()
@@ -114,6 +131,76 @@ async def _read_json_request(request: Request) -> dict[str, Any]:
return payload
+def _wrap_tts_words(text: str, max_chars: int) -> list[str]:
+ words = text.split()
+ if not words:
+ return []
+
+ chunks: list[str] = []
+ current = words[0]
+ for word in words[1:]:
+ candidate = f"{current} {word}"
+ if len(candidate) <= max_chars:
+ current = candidate
+ continue
+ chunks.append(current)
+ current = word
+ chunks.append(current)
+ return chunks
+
+
+def _chunk_tts_text(text: str) -> list[str]:
+ normalized = text.replace("\r\n", "\n").strip()
+ if not normalized:
+ return []
+
+ chunks: list[str] = []
+ paragraphs = [part.strip() for part in re.split(r"\n{2,}", normalized) if part.strip()]
+ for paragraph in paragraphs:
+ compact = re.sub(r"\s+", " ", paragraph).strip()
+ if not compact:
+ continue
+
+ sentences = [
+ sentence.strip()
+ for sentence in _TTS_SENTENCE_BREAK_RE.split(compact)
+ if sentence.strip()
+ ]
+ if not sentences:
+ sentences = [compact]
+
+ current = ""
+ for sentence in sentences:
+ parts = [sentence]
+ if len(sentence) > _TTS_SEGMENT_MAX_CHARS:
+ parts = [
+ clause.strip()
+ for clause in _TTS_CLAUSE_BREAK_RE.split(sentence)
+ if clause.strip()
+ ] or [sentence]
+
+ for part in parts:
+ if len(part) > _TTS_SEGMENT_MAX_CHARS:
+ if current:
+ chunks.append(current)
+ current = ""
+ chunks.extend(_wrap_tts_words(part, _TTS_SEGMENT_MAX_CHARS))
+ continue
+
+ candidate = part if not current else f"{current} {part}"
+ if len(candidate) <= _TTS_SEGMENT_TARGET_CHARS:
+ current = candidate
+ continue
+ if current:
+ chunks.append(current)
+ current = part
+
+ if current:
+ chunks.append(current)
+
+ return chunks or [re.sub(r"\s+", " ", normalized).strip()]
+
+
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:
@@ -158,6 +245,7 @@ def _coerce_card_record(raw: dict[str, Any]) -> dict[str, Any] | None:
"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", "")),
}
@@ -287,6 +375,7 @@ def _sort_cards(cards: list[dict[str, Any]]) -> list[dict[str, Any]]:
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
@@ -295,6 +384,9 @@ def _load_cards() -> list[dict[str, Any]]:
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)
@@ -817,6 +909,62 @@ async def delete_card(card_id: str) -> JSONResponse:
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())
@@ -975,8 +1123,15 @@ async def _sender_loop(
) -> 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":
- await voice_session.queue_output_text(event.text)
+ 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:
diff --git a/examples/cards/instances/live-calorie-tracker/card.json b/examples/cards/instances/live-calorie-tracker/card.json
new file mode 100644
index 0000000..21ab6b0
--- /dev/null
+++ b/examples/cards/instances/live-calorie-tracker/card.json
@@ -0,0 +1,17 @@
+{
+ "id": "live-calorie-tracker",
+ "kind": "text",
+ "title": "Calories",
+ "question": "",
+ "choices": [],
+ "response_value": "",
+ "slot": "live-calorie-tracker",
+ "lane": "context",
+ "priority": 76,
+ "state": "active",
+ "template_key": "list-total-live",
+ "context_summary": "",
+ "chat_id": "web",
+ "created_at": "2026-03-21T00:00:00+00:00",
+ "updated_at": "2026-03-21T00:00:00+00:00"
+}
diff --git a/examples/cards/instances/live-calorie-tracker/state.json b/examples/cards/instances/live-calorie-tracker/state.json
new file mode 100644
index 0000000..4786c11
--- /dev/null
+++ b/examples/cards/instances/live-calorie-tracker/state.json
@@ -0,0 +1,9 @@
+{
+ "left_label": "Cal",
+ "right_label": "Food",
+ "total_label": "Total",
+ "total_suffix": "cal",
+ "max_digits": 4,
+ "score": 76,
+ "rows": []
+}
diff --git a/examples/cards/instances/live-weather-01545/state.json b/examples/cards/instances/live-weather-01545/state.json
index df80a06..27db94e 100644
--- a/examples/cards/instances/live-weather-01545/state.json
+++ b/examples/cards/instances/live-weather-01545/state.json
@@ -1,10 +1,12 @@
{
"title": "Weather 01545",
- "subtitle": "OpenWeatherMap live context",
+ "subtitle": "Weather",
"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",
- "condition_label": "OpenWeatherMap live context",
+ "condition_label": "Weather",
"refresh_ms": 86400000
}
diff --git a/examples/cards/templates/list-total-live/manifest.json b/examples/cards/templates/list-total-live/manifest.json
new file mode 100644
index 0000000..990d4cc
--- /dev/null
+++ b/examples/cards/templates/list-total-live/manifest.json
@@ -0,0 +1,24 @@
+{
+ "key": "list-total-live",
+ "title": "List Total",
+ "notes": "Generic editable two-column list card with a numeric left column, freeform right column, and a running total persisted in template_state. Configure left_label, right_label, total_label, total_suffix, max_digits, and rows.",
+ "example_state": {
+ "left_label": "Cal",
+ "right_label": "Food",
+ "total_label": "Total",
+ "total_suffix": "cal",
+ "max_digits": 4,
+ "rows": [
+ {
+ "value": "420",
+ "name": "Lunch"
+ },
+ {
+ "value": "180",
+ "name": "Snack"
+ }
+ ]
+ },
+ "created_at": "2026-03-21T00:00:00+00:00",
+ "updated_at": "2026-03-21T00:00:00+00:00"
+}
diff --git a/examples/cards/templates/list-total-live/template.html b/examples/cards/templates/list-total-live/template.html
new file mode 100644
index 0000000..34465c2
--- /dev/null
+++ b/examples/cards/templates/list-total-live/template.html
@@ -0,0 +1,336 @@
+
+
+
+
+
diff --git a/examples/cards/templates/todo-item-live/template.html b/examples/cards/templates/todo-item-live/template.html
index 9b3ab52..f96f326 100644
--- a/examples/cards/templates/todo-item-live/template.html
+++ b/examples/cards/templates/todo-item-live/template.html
@@ -235,6 +235,8 @@
letter-spacing: 0.005em;
color: #624d40;
opacity: 0.95;
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
cursor: pointer;
touch-action: manipulation;
}
@@ -324,175 +326,43 @@
text-align: left;
}
- .task-card__editor-overlay {
- display: none;
- position: fixed;
- inset: 0;
- z-index: 10001;
- padding:
- max(18px, env(safe-area-inset-top))
- max(16px, env(safe-area-inset-right))
- max(18px, env(safe-area-inset-bottom))
- max(16px, env(safe-area-inset-left));
- background: rgba(38, 27, 21, 0.42);
- backdrop-filter: blur(8px);
- -webkit-backdrop-filter: blur(8px);
- box-sizing: border-box;
+ .task-card__title--editing,
+ .task-card__body--editing {
+ cursor: text;
}
- .task-card__editor-sheet {
+ .task-card__inline-editor {
+ display: block;
width: 100%;
- max-width: 100%;
min-width: 0;
- height: 100%;
- display: grid;
- grid-template-rows: auto 1fr auto;
- gap: 14px;
- border-radius: 22px;
- border: 1px solid rgba(87, 65, 50, 0.16);
- background:
- radial-gradient(circle at top right, rgba(255, 255, 255, 0.74), transparent 28%),
- linear-gradient(160deg, rgba(254, 246, 237, 0.985), rgba(240, 226, 210, 0.985));
- color: var(--task-ink);
- box-shadow:
- 0 22px 48px rgba(48, 32, 24, 0.24),
- inset 0 1px 0 rgba(255, 255, 255, 0.7);
- padding: 18px 16px 16px;
box-sizing: border-box;
- overflow: hidden;
- }
-
- .task-card__editor-head {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 10px;
- min-width: 0;
- }
-
- .task-card__editor-kicker {
- font-size: 0.66rem;
- line-height: 1.1;
- letter-spacing: 0.12em;
- text-transform: uppercase;
- font-weight: 700;
- color: var(--task-muted);
- }
-
- .task-card__editor-title {
- margin-top: 3px;
- font-family: 'IBM Plex Sans Condensed', 'Arial Narrow', sans-serif;
- font-size: 1.08rem;
- line-height: 1.04;
- font-weight: 700;
- letter-spacing: -0.012em;
- color: var(--task-ink);
- min-width: 0;
- overflow-wrap: anywhere;
- }
-
- .task-card__editor-close {
- appearance: none;
+ margin: 0;
+ padding: 0;
border: 0;
- background: transparent;
- color: var(--task-muted);
- font: 700 0.86rem/1 'M-1m Code', var(--card-font, 'SF Mono', ui-monospace, Menlo, Consolas, monospace);
- letter-spacing: 0.02em;
- padding: 6px 4px;
- cursor: pointer;
- flex: 0 0 auto;
- }
-
- .task-card__editor-fields {
- min-height: 0;
- min-width: 0;
- display: grid;
- grid-template-rows: auto auto 1fr;
- gap: 12px;
- }
-
- .task-card__editor-group {
- display: grid;
- gap: 5px;
- min-height: 0;
- min-width: 0;
- }
-
- .task-card__editor-label {
- font-size: 0.66rem;
- line-height: 1.1;
- letter-spacing: 0.12em;
- text-transform: uppercase;
- font-weight: 700;
- color: var(--task-muted);
- }
-
- .task-card__editor-input,
- .task-card__editor-textarea {
- width: 100%;
- max-width: 100%;
- min-width: 0;
- box-sizing: border-box;
- border-radius: 16px;
- border: 1px solid rgba(87, 65, 50, 0.14);
- background: rgba(255, 251, 246, 0.92);
- color: var(--task-ink);
- box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.78);
outline: none;
- }
-
- .task-card__editor-input {
- min-height: 52px;
- padding: 12px 13px;
- font-family: 'IBM Plex Sans Condensed', 'Arial Narrow', sans-serif;
- font-size: 1.02rem;
- line-height: 1.1;
- font-weight: 700;
- letter-spacing: -0.012em;
- }
-
- .task-card__editor-textarea {
- min-height: 0;
- height: 100%;
- max-height: 100%;
- padding: 12px 13px;
resize: none;
- font-family: 'IBM Plex Sans Condensed', 'Arial Narrow', sans-serif;
- font-size: 0.94rem;
- line-height: 1.36;
- font-weight: 400;
- letter-spacing: 0.004em;
+ overflow: hidden;
+ background: transparent;
+ color: inherit;
+ font: inherit;
+ line-height: inherit;
+ letter-spacing: inherit;
+ border-radius: 0;
+ box-shadow: none;
}
- .task-card__editor-input:focus,
- .task-card__editor-textarea:focus {
- border-color: rgba(88, 112, 111, 0.48);
- box-shadow:
- inset 0 1px 0 rgba(255, 255, 255, 0.82),
- 0 0 0 3px rgba(88, 112, 111, 0.12);
+ .task-card__inline-editor::placeholder {
+ color: rgba(98, 77, 64, 0.6);
+ opacity: 1;
+ font-style: italic;
}
- .task-card__editor-actions {
- display: flex;
- align-items: center;
- justify-content: flex-end;
- gap: 10px;
- min-width: 0;
- flex-wrap: wrap;
+ .task-card__inline-editor--title {
+ min-height: 1.2em;
}
- .task-card__editor-action-row {
- display: flex;
- align-items: center;
- gap: 8px;
- min-width: 0;
- flex-wrap: wrap;
- justify-content: flex-end;
- margin-left: auto;
- }
-
- .task-card__editor-action-row > .task-card__button {
- flex: 0 0 auto;
+ .task-card__inline-editor--body {
+ min-height: 1.34em;
}
@@ -759,49 +629,7 @@
}
};
- const editorOverlayEl = doc.createElement('div');
- editorOverlayEl.className = 'task-card__editor-overlay';
- editorOverlayEl.innerHTML = `
-
-
-
-
${laneLabels[lane] || 'Task'}
-
Edit task
-
-
-
-
-
- `;
- const editorFormEl = editorOverlayEl.querySelector('[data-task-editor-form]');
- const editorTitleInputEl = editorOverlayEl.querySelector('[data-task-editor-title-input]');
- const editorBodyInputEl = editorOverlayEl.querySelector('[data-task-editor-body-input]');
- const editorCloseEl = editorOverlayEl.querySelector('[data-task-editor-close]');
- const editorCancelEl = editorOverlayEl.querySelector('[data-task-editor-cancel]');
- const editorSaveEl = editorOverlayEl.querySelector('[data-task-editor-save]');
- if (
- !(editorFormEl instanceof HTMLFormElement) ||
- !(editorTitleInputEl instanceof HTMLInputElement) ||
- !(editorBodyInputEl instanceof HTMLTextAreaElement) ||
- !(editorCloseEl instanceof HTMLButtonElement) ||
- !(editorCancelEl instanceof HTMLButtonElement) ||
- !(editorSaveEl instanceof HTMLButtonElement)
- ) return;
+ let activeInlineEdit = null;
const setBusy = (busy) => {
laneToggleEl.disabled = busy || !taskPath;
@@ -811,11 +639,11 @@
for (const button of tagsEl.querySelectorAll('button')) {
if (button instanceof HTMLButtonElement) button.disabled = busy;
}
- editorTitleInputEl.disabled = busy;
- editorBodyInputEl.disabled = busy;
- editorCloseEl.disabled = busy;
- editorCancelEl.disabled = busy;
- editorSaveEl.disabled = busy;
+ summaryEl.style.pointerEvents = busy ? 'none' : '';
+ descriptionEl.style.pointerEvents = busy ? 'none' : '';
+ if (activeInlineEdit?.input instanceof HTMLTextAreaElement) {
+ activeInlineEdit.input.disabled = busy;
+ }
};
const closeMoveMenu = () => {
@@ -858,26 +686,97 @@
window.dispatchEvent(new Event('nanobot:cards-refresh'));
};
- const closeEditor = () => {
- editorOverlayEl.style.display = 'none';
+ const autosizeInlineEditor = (editor, minHeight = 0) => {
+ editor.style.height = '0px';
+ const nextHeight = Math.max(Math.ceil(minHeight), editor.scrollHeight);
+ editor.style.height = `${Math.max(nextHeight, 20)}px`;
};
- const openEditor = (focusField = 'title') => {
- if (!taskPath) return;
+ const beginInlineEdit = (field) => {
+ if (!taskPath || activeInlineEdit) return;
closeMoveMenu();
- editorTitleInputEl.value = title;
- editorBodyInputEl.value = body;
- if (editorOverlayEl.parentElement !== doc.body) {
- doc.body.appendChild(editorOverlayEl);
- }
- editorOverlayEl.style.display = 'block';
- view.requestAnimationFrame(() => {
- const target = focusField === 'description' ? editorBodyInputEl : editorTitleInputEl;
- target.focus();
- if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) {
- const end = target.value.length;
- target.setSelectionRange(end, end);
+ const host = field === 'title' ? summaryEl : descriptionEl;
+ const currentValue = field === 'title' ? title : body;
+ const editor = document.createElement('textarea');
+ const minHeight = host.getBoundingClientRect().height;
+
+ editor.className = `task-card__inline-editor ${
+ field === 'title' ? 'task-card__inline-editor--title' : 'task-card__inline-editor--body'
+ }`;
+ editor.rows = 1;
+ editor.value = currentValue;
+ editor.placeholder = field === 'description' ? 'Add description' : '';
+ editor.setAttribute('aria-label', field === 'title' ? 'Edit task title' : 'Edit task description');
+
+ host.textContent = '';
+ host.classList.remove('task-card__body--placeholder');
+ host.classList.add(field === 'title' ? 'task-card__title--editing' : 'task-card__body--editing');
+ host.appendChild(editor);
+ autosizeInlineEditor(editor, minHeight);
+
+ const cancel = () => {
+ if (activeInlineEdit?.input !== editor) return;
+ activeInlineEdit = null;
+ render();
+ };
+
+ const commit = async () => {
+ if (activeInlineEdit?.input !== editor) return;
+ const nextValue = editor.value.trim();
+ if (field === 'title' && !nextValue) {
+ editor.focus();
+ return;
}
+ activeInlineEdit = null;
+ if (nextValue === currentValue) {
+ render();
+ return;
+ }
+ const ok = await runTaskEdit(field === 'title' ? { title: nextValue } : { description: nextValue });
+ if (!ok) render();
+ };
+
+ activeInlineEdit = {
+ field,
+ input: editor,
+ cancel,
+ commit,
+ };
+
+ editor.addEventListener('input', () => {
+ autosizeInlineEditor(editor, minHeight);
+ });
+
+ editor.addEventListener('click', (event) => {
+ event.stopPropagation();
+ });
+
+ 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 (activeInlineEdit?.input !== editor) return;
+ void commit();
+ });
+
+ view.requestAnimationFrame(() => {
+ editor.focus();
+ const end = editor.value.length;
+ editor.setSelectionRange(end, end);
});
};
@@ -958,13 +857,14 @@
if (payload && typeof payload === 'object' && payload.error) {
throw new Error(String(payload.error));
}
- closeEditor();
refreshCards();
+ return true;
} catch (error) {
console.error('Task edit failed', error);
setBusy(false);
setStatus('Unavailable', '#8e3023', '#f3d3cc');
publishLiveContent(lane, true, String(error));
+ return false;
}
};
@@ -1094,48 +994,16 @@
doc.body.appendChild(moveMenuEl);
}
- editorCloseEl.addEventListener('click', () => {
- closeEditor();
- });
-
- editorCancelEl.addEventListener('click', () => {
- closeEditor();
- });
-
- editorOverlayEl.addEventListener('pointerdown', (event) => {
- if (event.target === editorOverlayEl) {
- closeEditor();
- }
- });
-
- editorFormEl.addEventListener('submit', (event) => {
- event.preventDefault();
- const nextTitle = editorTitleInputEl.value.trim();
- const nextDescription = editorBodyInputEl.value.trim();
- if (!nextTitle) {
- editorTitleInputEl.focus();
- return;
- }
- if (nextTitle === title && nextDescription === body) {
- closeEditor();
- return;
- }
- void runTaskEdit({
- title: nextTitle,
- description: nextDescription,
- });
- });
-
summaryEl.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
- openEditor('title');
+ beginInlineEdit('title');
});
descriptionEl.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
- openEditor('description');
+ beginInlineEdit('description');
});
const render = () => {
diff --git a/examples/cards/templates/weather-live/manifest.json b/examples/cards/templates/weather-live/manifest.json
index 21ab6b1..40bcf75 100644
--- a/examples/cards/templates/weather-live/manifest.json
+++ b/examples/cards/templates/weather-live/manifest.json
@@ -3,12 +3,14 @@
"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.",
"example_state": {
- "subtitle": "OpenWeatherMap live context",
+ "subtitle": "Weather",
"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",
- "condition_label": "OpenWeatherMap live context",
+ "condition_label": "Weather",
"refresh_ms": 86400000
},
"created_at": "2026-03-11T04:12:48.601255+00:00",
diff --git a/examples/cards/templates/weather-live/template.html b/examples/cards/templates/weather-live/template.html
index 7dc4f6f..925deb6 100644
--- a/examples/cards/templates/weather-live/template.html
+++ b/examples/cards/templates/weather-live/template.html
@@ -1,4 +1,13 @@
+
Loading…
Loading…
@@ -9,24 +18,22 @@
°F
-
--
-
@@ -40,22 +47,20 @@
const subtitleEl = root.querySelector('[data-weather-subtitle]');
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 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) || !(condEl instanceof HTMLElement) || !(humidityEl instanceof HTMLElement) || !(windEl instanceof HTMLElement) || !(pressureEl instanceof HTMLElement) || !(updatedEl instanceof HTMLElement) || !(statusEl instanceof HTMLElement)) return;
+ 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 pressureName = typeof state.pressure_name === 'string' ? state.pressure_name.trim() : '';
- const windName = typeof state.wind_name === 'string' ? state.wind_name.trim() : '';
- const conditionLabel = typeof state.condition_label === 'string' ? state.condition_label.trim() : '';
const refreshMsRaw = Number(state.refresh_ms);
const refreshMs = Number.isFinite(refreshMsRaw) && refreshMsRaw >= 60000 ? refreshMsRaw : 24 * 60 * 60 * 1000;
@@ -162,6 +167,18 @@
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 (!window.__nanobotListTools) return 'mcp_home_assistant_GetLiveContext';
@@ -187,24 +204,41 @@
}) || null;
};
+ const resolveForecastBundle = async () => {
+ if (!forecastCommand) return null;
+ const toolResult = await window.__nanobotCallTool?.(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 refresh = async () => {
const resolvedToolName = await resolveToolName();
if (!resolvedToolName) {
const errorText = 'Missing tool_name';
setStatus('No tool', '#b91c1c');
- updatedEl.textContent = errorText;
updateLiveContent({
kind: 'weather',
subtitle: subtitleEl.textContent || null,
tool_name: null,
temperature: null,
temperature_unit: String(state.unit || '°F'),
- condition: null,
humidity: null,
wind: null,
- pressure: null,
+ rain: null,
+ uv: null,
status: 'No tool',
- updated_at: errorText,
error: errorText,
});
return;
@@ -212,7 +246,10 @@
setStatus('Refreshing', '#6b7280');
try {
- const toolResult = await window.__nanobotCallTool?.(resolvedToolName, {});
+ const [toolResult, forecastBundle] = await Promise.all([
+ window.__nanobotCallTool?.(resolvedToolName, {}),
+ resolveForecastBundle(),
+ ]);
const entries = parseLiveContextEntries(extractLiveContextText(toolResult)).filter((entry) => normalizeText(entry.domain) === 'sensor');
const prefix = providerPrefix || 'OpenWeatherMap';
const temperatureEntry = findEntry(entries, [
@@ -223,32 +260,29 @@
humidityName,
`${prefix} Humidity`,
]);
- const pressureEntry = findEntry(entries, [
- pressureName,
- `${prefix} Pressure`,
- ]);
- const windEntry = findEntry(entries, [
- windName,
- `${prefix} Wind speed`,
- `${prefix} Wind`,
- ]);
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');
- condEl.textContent = conditionLabel || `${prefix || 'Weather'} live context`;
+
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 pressure = Number(pressureEntry?.state);
- pressureEl.textContent = Number.isFinite(pressure)
- ? `${pressure} ${String(pressureEntry?.attributes?.unit_of_measurement || '').trim()}`.trim()
- : '--';
- const updatedText = new Date().toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
- updatedEl.textContent = updatedText;
- subtitleEl.textContent = subtitle || prefix || 'Home Assistant live context';
+
+ 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 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 uvValue = Number(uvEntry?.uv_index);
+ uvEl.textContent = Number.isFinite(uvValue) ? String(Math.round(uvValue)) : '--';
+
+ subtitleEl.textContent = subtitle || prefix || 'Weather';
setStatus('Live', '#047857');
updateLiveContent({
kind: 'weather',
@@ -256,29 +290,32 @@
tool_name: resolvedToolName,
temperature: Number.isFinite(temperature) ? Math.round(temperature) : null,
temperature_unit: unitEl.textContent || null,
- condition: condEl.textContent || null,
humidity: Number.isFinite(humidity) ? Math.round(humidity) : null,
wind: windEl.textContent || null,
- pressure: pressureEl.textContent || null,
+ rain: rainEl.textContent || null,
+ uv: Number.isFinite(uvValue) ? Math.round(uvValue) : null,
status: 'Live',
- updated_at: updatedText,
});
} catch (error) {
const errorText = String(error);
setStatus('Unavailable', '#b91c1c');
- updatedEl.textContent = errorText;
+ 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,
- condition: null,
humidity: null,
wind: null,
- pressure: null,
+ rain: null,
+ uv: null,
status: 'Unavailable',
- updated_at: errorText,
error: errorText,
});
}
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 379d0ff..d0eade0 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -438,9 +438,11 @@ export function App() {
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;
diff --git a/frontend/src/components/CardFeed.tsx b/frontend/src/components/CardFeed.tsx
index adaa9c6..625c0fd 100644
--- a/frontend/src/components/CardFeed.tsx
+++ b/frontend/src/components/CardFeed.tsx
@@ -1,6 +1,6 @@
import { marked } from "marked";
-import { useEffect, useMemo, useRef, useState } from "preact/hooks";
-import type { CardItem, CardLane, JsonValue } from "../types";
+import { useEffect, useRef, useState } from "preact/hooks";
+import type { CardItem, JsonValue } from "../types";
const EXECUTABLE_SCRIPT_TYPES = new Set([
"",
@@ -45,6 +45,391 @@ interface ManualToolAsyncOptions {
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 {
@@ -54,6 +439,25 @@ function cloneJsonValue(value: T | null | undefined): T | u
}
}
+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 {};
@@ -163,6 +567,24 @@ function clearCardSelection(cardId: string | null | undefined): void {
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 };
@@ -175,6 +597,20 @@ async function decodeJsonError(resp: Response): Promise {
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,
@@ -437,30 +873,6 @@ declare global {
}
}
-const LANE_TITLES: Record = {
- attention: "Attention",
- work: "Work",
- context: "Context",
- history: "History",
-};
-const LANE_ORDER: CardLane[] = ["attention", "work", "context", "history"];
-const LANE_RANK: Record = {
- attention: 0,
- work: 1,
- context: 2,
- history: 3,
-};
-
-function readCardScore(card: CardItem): number {
- if (!card.serverId) return card.priority;
- const liveContent = window.__nanobotGetCardLiveContent?.(card.serverId);
- if (!liveContent || typeof liveContent !== "object" || Array.isArray(liveContent)) {
- return card.priority;
- }
- const score = (liveContent as Record).score;
- return typeof score === "number" && Number.isFinite(score) ? score : card.priority;
-}
-
interface CardProps {
card: CardItem;
onDismiss(id: number): void;
@@ -544,18 +956,1088 @@ 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 (
+