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

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

View file

@ -1,23 +1,29 @@
{
"key": "todo-item-live",
"title": "Todo Item",
"notes": "Source-generated card for a single Home Assistant todo item. Do not use a live fetch URL. A card source script writes one card instance per todo uid and fills template_state with the current item fields plus source_id for refresh. The card completes the task by calling the Home Assistant HassListCompleteItem MCP tool with list_name and the task summary.",
"title": "Task Item",
"notes": "File-backed kanban task card. The template_state is written from ~/.nanobot/workspace/tasks via scripts/task_cards.py. The card shows one primary action plus a compact move menu and persists lane transitions by calling the exec tool to run scripts/task_cards.py move.",
"example_state": {
"source_id": "ha-todo-kacpers-to-do",
"entity_id": "todo.kacpers_to_do",
"list_name": "Kacper's To-Do",
"uid": "55be123e-1ef3-11f1-b5e6-001e06480aef",
"summary": "Get sneakers",
"complete_tool_name": "mcp_home_assistant_HassListCompleteItem",
"complete_item": "Get sneakers",
"status": "needs_action",
"completed": false,
"kind": "file_task",
"task_path": "/home/kacper/.nanobot/workspace/tasks/backlog/20260316-pack-for-japan.md",
"task_key": "b9b79f12-1ef3-11f1-b5e6-001e06480aef",
"title": "Pack for Japan",
"lane": "backlog",
"created": "2026-03-16T18:10:00-04:00",
"updated": "2026-03-16T18:10:00-04:00",
"due": null,
"due_datetime": null,
"description": null,
"generated_at": "2026-03-13T16:03:26+00:00",
"can_complete": true
"tags": [
"home-assistant",
"imported"
],
"body": "## Imported\n\n- Source: Home Assistant\n- List: Kacper's To-Do\n- UID: b9b79f12-1ef3-11f1-b5e6-001e06480aef",
"metadata": {
"source": "home_assistant",
"source_entity_id": "todo.kacpers_to_do",
"source_list": "Kacper's To-Do",
"source_uid": "b9b79f12-1ef3-11f1-b5e6-001e06480aef",
"imported_at": "2026-03-16T22:10:00+00:00"
}
},
"created_at": "2026-03-13T00:00:00+00:00",
"updated_at": "2026-03-13T00:00:00+00:00"
"updated_at": "2026-03-16T00:00:00+00:00"
}

View file

@ -1,19 +1,260 @@
<div data-todo-item-card style="font-family: var(--card-font, 'Iosevka', 'SF Mono', ui-monospace, Menlo, Consolas, monospace); background:#f7ecdf; color:#65483a; padding:12px 14px;">
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:10px;">
<div style="min-width:0; flex:1 1 auto;">
<div data-todo-subtitle style="font-size:0.78rem; line-height:1.25; color:#9a7b68; font-weight:700; text-transform:uppercase; letter-spacing:0.08em;">Todo</div>
<div data-todo-summary style="margin-top:6px; font-size:1.08rem; line-height:1.15; font-weight:800; letter-spacing:-0.02em; color:#65483a; word-break:break-word;">Loading...</div>
</div>
<span data-todo-status style="display:none; font-size:0.78rem; line-height:1.2; font-weight:800; color:#9a6a2f; background:#f4e2b8; white-space:nowrap; border-radius:999px; padding:4px 8px;"></span>
<style>
@font-face {
font-family: 'M-1m Code';
src: url('/card-templates/todo-item-live/assets/mplus-1m-regular-sub.ttf') format('truetype');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'M-1m Code';
src: url('/card-templates/todo-item-live/assets/mplus-1m-bold-sub.ttf') format('truetype');
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'IBM Plex Sans Condensed';
src: url('/card-templates/todo-item-live/assets/ibm-plex-sans-condensed-400.ttf') format('truetype');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'IBM Plex Sans Condensed';
src: url('/card-templates/todo-item-live/assets/ibm-plex-sans-condensed-600.ttf') format('truetype');
font-weight: 600;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'IBM Plex Sans Condensed';
src: url('/card-templates/todo-item-live/assets/ibm-plex-sans-condensed-700.ttf') format('truetype');
font-weight: 700;
font-style: normal;
font-display: swap;
}
.task-card {
--task-accent: #58706f;
--task-accent-soft: rgba(88, 112, 111, 0.12);
--task-ink: #2f241e;
--task-muted: #7e6659;
--task-surface: rgba(255, 248, 239, 0.92);
--task-surface-strong: rgba(250, 238, 221, 0.95);
--task-border: rgba(87, 65, 50, 0.14);
--task-button-ink: #214240;
position: relative;
overflow: hidden;
border-radius: 20px;
border: 1px solid var(--task-border);
background:
radial-gradient(circle at top right, rgba(255, 255, 255, 0.72), transparent 32%),
linear-gradient(145deg, rgba(253, 245, 235, 0.98), rgba(242, 227, 211, 0.97));
color: var(--task-ink);
font-family: 'M-1m Code', var(--card-font, 'SF Mono', ui-monospace, Menlo, Consolas, monospace);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.68),
0 18px 36px rgba(79, 56, 43, 0.12);
}
.task-card::before {
content: '';
position: absolute;
inset: 0;
background:
repeating-linear-gradient(
135deg,
rgba(122, 97, 78, 0.035) 0,
rgba(122, 97, 78, 0.035) 2px,
transparent 2px,
transparent 10px
);
pointer-events: none;
opacity: 0.55;
}
.task-card__inner {
position: relative;
display: grid;
gap: 10px;
padding: 15px 14px 13px 14px;
}
.task-card__topline {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
}
.task-card__lane {
min-width: 0;
font-size: 0.69rem;
line-height: 1.1;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--task-muted);
font-weight: 700;
}
.task-card__stamp {
display: none;
flex: 0 0 auto;
align-items: center;
justify-content: center;
white-space: nowrap;
border-radius: 999px;
padding: 5px 8px;
font-size: 0.68rem;
line-height: 1;
font-weight: 700;
border: 1px solid rgba(0, 0, 0, 0.04);
}
.task-card__title {
font-family: 'IBM Plex Sans Condensed', 'Arial Narrow', sans-serif;
font-size: 1.08rem;
line-height: 1.1;
font-weight: 700;
letter-spacing: -0.01em;
color: var(--task-ink);
text-wrap: balance;
word-break: break-word;
}
.task-card__tags {
display: none;
flex-wrap: wrap;
gap: 6px;
}
.task-card__tag {
display: inline-flex;
align-items: center;
min-height: 24px;
max-width: 100%;
border-radius: 999px;
padding: 4px 9px;
background: var(--task-accent-soft);
color: var(--task-button-ink);
font-size: 0.71rem;
line-height: 1;
font-weight: 700;
border: 1px solid rgba(0, 0, 0, 0.035);
overflow: hidden;
text-overflow: ellipsis;
}
.task-card__body {
display: none;
font-family: 'IBM Plex Sans Condensed', 'Arial Narrow', sans-serif;
font-size: 0.86rem;
line-height: 1.34;
font-weight: 400;
letter-spacing: 0.005em;
color: #624d40;
opacity: 0.95;
}
.task-card__meta {
display: flex;
flex-wrap: wrap;
gap: 7px;
}
.task-card__meta-chip {
display: none;
align-items: center;
min-height: 25px;
border-radius: 999px;
padding: 5px 9px;
background: var(--task-surface);
color: var(--task-muted);
font-size: 0.69rem;
line-height: 1;
font-weight: 700;
border: 1px solid rgba(0, 0, 0, 0.04);
}
.task-card__actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 1px;
}
.task-card__button {
appearance: none;
border: 0;
border-radius: 999px;
padding: 8px 11px;
background: var(--task-accent);
color: #f9f4ed;
font: 700 0.74rem/1 'M-1m Code', var(--card-font, 'SF Mono', ui-monospace, Menlo, Consolas, monospace);
letter-spacing: 0.02em;
cursor: pointer;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.18);
}
.task-card__button--secondary {
background: var(--task-surface-strong);
color: var(--task-button-ink);
}
.task-card__button:disabled {
cursor: default;
opacity: 0.55;
}
.task-card__move {
display: none;
flex-wrap: wrap;
gap: 6px;
}
.task-card__move-button {
appearance: none;
border: 0;
border-radius: 999px;
padding: 7px 10px;
background: rgba(255, 248, 239, 0.78);
color: var(--task-button-ink);
font: 700 0.7rem/1 'M-1m Code', var(--card-font, 'SF Mono', ui-monospace, Menlo, Consolas, monospace);
cursor: pointer;
border: 1px solid rgba(0, 0, 0, 0.05);
}
</style>
<div data-task-item-card class="task-card">
<div class="task-card__inner">
<div class="task-card__topline">
<div data-task-subtitle class="task-card__lane">Task</div>
<span data-task-status class="task-card__stamp"></span>
</div>
<div data-todo-due style="display:none; margin-top:8px; font-size:0.82rem; line-height:1.35; color:#947662; font-weight:700;"></div>
<div data-todo-description style="display:none; margin-top:8px; font-size:0.82rem; line-height:1.45; color:#7d5f4e;"></div>
<div data-task-summary class="task-card__title">Loading...</div>
<div data-task-tags class="task-card__tags"></div>
<div data-task-description class="task-card__body"></div>
<div style="margin-top:10px; display:flex; align-items:center; gap:10px;">
<button data-todo-complete type="button" style="display:none; border:none; border-radius:999px; padding:6px 10px; background:#dfe9d8; color:#35562c; font:800 0.76rem/1 var(--card-font, 'Iosevka', 'SF Mono', ui-monospace, Menlo, Consolas, monospace); cursor:pointer;">Done</button>
<div class="task-card__meta">
<div data-task-due class="task-card__meta-chip"></div>
<div data-task-age class="task-card__meta-chip"></div>
</div>
<div class="task-card__actions">
<button data-task-primary type="button" class="task-card__button" style="display:none;">Start</button>
<button data-task-move-toggle type="button" class="task-card__button task-card__button--secondary" style="display:none;">Move</button>
</div>
<div data-task-move-menu class="task-card__move"></div>
</div>
</div>
<script>
(() => {
const script = document.currentScript;
@ -21,27 +262,120 @@
const state = window.__nanobotGetCardState?.(script) || {};
if (!(root instanceof HTMLElement)) return;
const subtitleEl = root.querySelector('[data-todo-subtitle]');
const statusEl = root.querySelector('[data-todo-status]');
const summaryEl = root.querySelector('[data-todo-summary]');
const dueEl = root.querySelector('[data-todo-due]');
const descriptionEl = root.querySelector('[data-todo-description]');
const completeEl = root.querySelector('[data-todo-complete]');
if (!(subtitleEl instanceof HTMLElement) || !(statusEl instanceof HTMLElement) || !(summaryEl instanceof HTMLElement) || !(dueEl instanceof HTMLElement) || !(descriptionEl instanceof HTMLElement) || !(completeEl instanceof HTMLButtonElement)) return;
const cardEl = root.querySelector('[data-task-item-card]');
const subtitleEl = root.querySelector('[data-task-subtitle]');
const statusEl = root.querySelector('[data-task-status]');
const summaryEl = root.querySelector('[data-task-summary]');
const tagsEl = root.querySelector('[data-task-tags]');
const dueEl = root.querySelector('[data-task-due]');
const ageEl = root.querySelector('[data-task-age]');
const descriptionEl = root.querySelector('[data-task-description]');
const primaryEl = root.querySelector('[data-task-primary]');
const moveToggleEl = root.querySelector('[data-task-move-toggle]');
const moveMenuEl = root.querySelector('[data-task-move-menu]');
if (
!(cardEl instanceof HTMLElement) ||
!(subtitleEl instanceof HTMLElement) ||
!(statusEl instanceof HTMLElement) ||
!(summaryEl instanceof HTMLElement) ||
!(tagsEl instanceof HTMLElement) ||
!(dueEl instanceof HTMLElement) ||
!(ageEl instanceof HTMLElement) ||
!(descriptionEl instanceof HTMLElement) ||
!(primaryEl instanceof HTMLButtonElement) ||
!(moveToggleEl instanceof HTMLButtonElement) ||
!(moveMenuEl instanceof HTMLElement)
) return;
const sourceId = typeof state.source_id === 'string' ? state.source_id.trim() : '';
const entityId = typeof state.entity_id === 'string' ? state.entity_id.trim() : '';
const uid = typeof state.uid === 'string' ? state.uid.trim() : '';
const summary = typeof state.summary === 'string' ? state.summary.trim() : '';
const listName = typeof state.list_name === 'string' ? state.list_name.trim() : '';
const rawStatus = typeof state.status === 'string' ? state.status.trim() : 'needs_action';
const completed = Boolean(state.completed) || rawStatus === 'completed';
const taskPath = typeof state.task_path === 'string' ? state.task_path.trim() : '';
const taskKey = typeof state.task_key === 'string' ? state.task_key.trim() : '';
const title = typeof state.title === 'string' ? state.title.trim() : '';
const lane = typeof state.lane === 'string' ? state.lane.trim() : 'backlog';
const created = typeof state.created === 'string' ? state.created.trim() : '';
const updated = typeof state.updated === 'string' ? state.updated.trim() : '';
const due = typeof state.due === 'string' ? state.due.trim() : '';
const dueDateTime = typeof state.due_datetime === 'string' ? state.due_datetime.trim() : '';
const description = typeof state.description === 'string' ? state.description.trim() : '';
const configuredCompleteTool = typeof state.complete_tool_name === 'string' ? state.complete_tool_name.trim() : '';
const completeItem = typeof state.complete_item === 'string' ? state.complete_item.trim() : summary;
const canComplete = Boolean(state.can_complete) && Boolean(listName) && Boolean(completeItem) && !completed;
const body = typeof state.body === 'string' ? state.body.trim() : '';
const tags = Array.isArray(state.tags)
? state.tags.map((value) => String(value || '').trim()).filter(Boolean)
: [];
const metadata = state && typeof state.metadata === 'object' && state.metadata && !Array.isArray(state.metadata)
? state.metadata
: {};
const laneLabels = {
backlog: 'Backlog',
'in-progress': 'In Progress',
blocked: 'Blocked',
done: 'Done',
canceled: 'Canceled',
};
const laneThemes = {
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',
},
};
const actionLabels = {
backlog: 'Backlog',
'in-progress': 'Start',
blocked: 'Block',
done: 'Done',
canceled: 'Cancel',
};
const primaryActionForLane = (value) => {
if (value === 'backlog') return { lane: 'in-progress', label: 'Start' };
if (value === 'in-progress') return { lane: 'done', label: 'Done' };
if (value === 'blocked') return { lane: 'in-progress', label: 'Resume' };
return null;
};
const moveOptionsForLane = (value) => {
const primary = primaryActionForLane(value);
return ['backlog', 'in-progress', 'blocked', 'done', 'canceled']
.filter((targetLane) => targetLane !== value)
.filter((targetLane) => !primary || targetLane !== primary.lane)
.map((targetLane) => ({
lane: targetLane,
label: actionLabels[targetLane] || targetLane,
}));
};
const applyTheme = () => {
const theme = laneThemes[lane] || laneThemes.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 setStatus = (label, fg, bg) => {
statusEl.textContent = label;
@ -50,127 +384,268 @@
statusEl.style.display = label ? 'inline-flex' : 'none';
};
const parseDueTimeMs = () => {
if (!due) return null;
const raw = due.includes('T') ? due : `${due}T12:00:00`;
const parsed = new Date(raw).getTime();
return Number.isFinite(parsed) ? parsed : null;
};
const parseCreatedTimeMs = () => {
if (!created) return null;
const parsed = new Date(created).getTime();
return Number.isFinite(parsed) ? parsed : null;
};
const computeScore = () => {
const now = Date.now();
const dueMs = parseDueTimeMs();
let score = 70;
if (Number.isFinite(dueMs)) {
const hoursUntilDue = (dueMs - now) / (60 * 60 * 1000);
if (hoursUntilDue <= 0) score = 100;
else if (hoursUntilDue <= 6) score = 96;
else if (hoursUntilDue <= 24) score = 92;
else if (hoursUntilDue <= 72) score = 82;
else if (hoursUntilDue <= 168) score = 72;
else score = 62;
} else {
const createdMs = parseCreatedTimeMs();
if (Number.isFinite(createdMs)) {
const ageDays = Math.max(0, (now - createdMs) / (24 * 60 * 60 * 1000));
if (ageDays >= 30) score = 94;
else if (ageDays >= 21) score = 90;
else if (ageDays >= 14) score = 86;
else if (ageDays >= 7) score = 82;
else if (ageDays >= 3) score = 78;
else if (ageDays >= 1) score = 74;
}
}
if (lane === 'blocked') return Math.min(100, score + 4);
if (lane === 'in-progress') return Math.min(100, score + 2);
return score;
};
const formatDue = () => {
if (dueDateTime) {
const parsed = new Date(dueDateTime);
if (!Number.isNaN(parsed.getTime())) {
if (!due) return '';
const raw = due.includes('T') ? due : `${due}T00:00:00`;
const parsed = new Date(raw);
if (Number.isNaN(parsed.getTime())) return `Due ${due}`;
if (due.includes('T')) {
return `Due ${parsed.toLocaleString([], { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })}`;
}
return `Due ${dueDateTime}`;
}
if (due) {
const parsed = new Date(`${due}T00:00:00`);
if (!Number.isNaN(parsed.getTime())) {
return `Due ${parsed.toLocaleDateString([], { month: 'short', day: 'numeric' })}`;
}
return `Due ${due}`;
}
return completed ? 'Completed' : '';
};
const formatAge = () => {
const createdMs = parseCreatedTimeMs();
if (!Number.isFinite(createdMs)) return '';
const days = Math.floor((Date.now() - createdMs) / (24 * 60 * 60 * 1000));
if (days <= 0) return 'New today';
if (days < 7) return `${days}d old`;
if (days < 30) return `${Math.floor(days / 7)}w old`;
return `${Math.floor(days / 30)}mo old`;
};
const summarizeBody = () => {
if (!body) return '';
const trimmed = body.trim();
if (!trimmed || /^##\s+Imported\b/i.test(trimmed)) return '';
const [firstSection] = trimmed.split(/\n##\s+/);
const summary = firstSection
.split(/\n{2,}/)
.map((part) => part.trim())
.find(Boolean);
if (!summary) return '';
if (summary.toLowerCase() === title.toLowerCase()) return '';
return summary;
};
const publishLiveContent = (statusValue, exists = true, error = '') => {
window.__nanobotSetCardLiveContent?.(script, {
kind: 'ha_todo_item',
kind: 'file_task',
exists,
source_id: sourceId || null,
entity_id: entityId || null,
list_name: listName || null,
uid: uid || null,
status: statusValue,
summary: summary || null,
task_path: taskPath || null,
task_key: taskKey || null,
title: title || null,
lane,
created: created || null,
updated: updated || null,
due: due || null,
due_datetime: dueDateTime || null,
description: description || null,
complete_tool_name: configuredCompleteTool || null,
can_complete: canComplete,
tags,
metadata,
score: computeScore(),
status: statusValue,
error: error || null,
});
};
const resolveCompleteToolName = async () => {
if (configuredCompleteTool) return configuredCompleteTool;
if (!window.__nanobotListTools) return 'mcp_home_assistant_HassListCompleteItem';
const shellQuote = (value) => `'${String(value).replace(/'/g, `'\"'\"'`)}'`;
const parseExecPayload = (result) => {
if (result && typeof result === 'object' && result.parsed && typeof result.parsed === 'object') {
return result.parsed;
}
const raw = typeof result?.content === 'string' ? result.content : '';
const cleaned = raw.replace(/\nExit code:\s*\d+\s*$/i, '').trim();
if (!cleaned) return null;
try {
const tools = await window.__nanobotListTools();
const completeTool = Array.isArray(tools)
? tools.find((tool) => /(^|_)HassListCompleteItem$/i.test(String(tool?.name || '')))
: null;
return completeTool?.name || 'mcp_home_assistant_HassListCompleteItem';
return JSON.parse(cleaned);
} catch {
return 'mcp_home_assistant_HassListCompleteItem';
return null;
}
};
const render = () => {
subtitleEl.textContent = listName || 'Todo';
summaryEl.textContent = summary || '(Untitled task)';
const dueText = formatDue();
dueEl.textContent = dueText;
dueEl.style.display = dueText ? 'block' : 'none';
descriptionEl.textContent = description;
descriptionEl.style.display = description ? 'block' : 'none';
if (completed) {
setStatus('Done', '#6c8b63', '#dfe9d8');
summaryEl.style.textDecoration = 'line-through';
summaryEl.style.color = '#7d5f4e';
completeEl.style.display = 'none';
completeEl.disabled = true;
} else {
setStatus('', '', 'transparent');
summaryEl.style.textDecoration = 'none';
summaryEl.style.color = '#65483a';
if (canComplete) {
completeEl.style.display = 'inline-flex';
completeEl.disabled = false;
} else {
completeEl.style.display = 'none';
completeEl.disabled = true;
const setBusy = (busy) => {
primaryEl.disabled = busy;
moveToggleEl.disabled = busy;
for (const button of moveMenuEl.querySelectorAll('button')) {
if (button instanceof HTMLButtonElement) button.disabled = busy;
}
}
publishLiveContent(completed ? 'completed' : rawStatus, true, '');
};
const requestCardResync = async () => {
if (!sourceId) return;
await fetch('/cards/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source_id: sourceId }),
});
const refreshCards = () => {
moveMenuEl.style.display = 'none';
moveToggleEl.textContent = 'Move';
moveToggleEl.setAttribute('aria-expanded', 'false');
window.dispatchEvent(new Event('nanobot:cards-refresh'));
};
completeEl.addEventListener('click', async (event) => {
const runTransition = async (targetLane) => {
if (!taskPath) return;
setBusy(true);
setStatus('Saving', '#6e5b4d', 'rgba(255, 244, 227, 0.9)');
try {
const command = [
'python3',
'/home/kacper/nanobot/scripts/task_cards.py',
'move',
'--task',
shellQuote(taskPath),
'--lane',
shellQuote(targetLane),
].join(' ');
const result = await window.__nanobotCallToolAsync?.(
'exec',
{
command,
max_output_chars: 50000,
},
{
pollMs: 350,
timeoutMs: 30000,
},
);
const payload = parseExecPayload(result);
if (payload && typeof payload === 'object' && payload.sync && typeof payload.sync === 'object') {
publishLiveContent(targetLane, targetLane !== 'done' && targetLane !== 'canceled', '');
}
refreshCards();
} catch (error) {
console.error('Task transition failed', error);
setBusy(false);
setStatus('Unavailable', '#8e3023', '#f3d3cc');
publishLiveContent(lane, true, String(error));
}
};
const renderTags = () => {
tagsEl.innerHTML = '';
if (!tags.length) {
tagsEl.style.display = 'none';
return;
}
const visibleTags = tags.slice(0, 4);
for (const tag of visibleTags) {
const chip = document.createElement('span');
chip.className = 'task-card__tag';
chip.textContent = tag;
tagsEl.appendChild(chip);
}
if (tags.length > visibleTags.length) {
const overflow = document.createElement('span');
overflow.className = 'task-card__tag';
overflow.textContent = `+${tags.length - visibleTags.length}`;
tagsEl.appendChild(overflow);
}
tagsEl.style.display = 'flex';
};
const renderMoveMenu = () => {
moveMenuEl.innerHTML = '';
const options = moveOptionsForLane(lane);
if (!options.length || !taskPath) {
moveToggleEl.style.display = 'none';
return;
}
moveToggleEl.style.display = 'inline-flex';
for (const option of options) {
const button = document.createElement('button');
button.type = 'button';
button.className = 'task-card__move-button';
button.textContent = option.label;
button.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
if (!canComplete) return;
completeEl.disabled = true;
setStatus('Saving', '#9a7b68', 'rgba(231, 220, 209, 0.92)');
try {
const completeToolName = await resolveCompleteToolName();
await window.__nanobotCallTool?.(completeToolName, {
name: listName,
item: completeItem,
runTransition(option.lane);
});
setStatus('Done', '#6c8b63', '#dfe9d8');
summaryEl.style.textDecoration = 'line-through';
summaryEl.style.color = '#7d5f4e';
dueEl.textContent = 'Completed';
dueEl.style.display = 'block';
completeEl.style.display = 'none';
publishLiveContent('completed', true, '');
await requestCardResync();
} catch (error) {
setStatus('Unavailable', '#a14d43', '#f3d8d2');
console.error('Todo completion failed', error);
completeEl.disabled = false;
publishLiveContent(rawStatus, true, String(error));
moveMenuEl.appendChild(button);
}
};
const renderPrimaryAction = () => {
const primary = primaryActionForLane(lane);
if (!primary || !taskPath) {
primaryEl.style.display = 'none';
primaryEl.disabled = true;
return;
}
primaryEl.textContent = primary.label;
primaryEl.style.display = 'inline-flex';
primaryEl.disabled = false;
primaryEl.onclick = (event) => {
event.preventDefault();
event.stopPropagation();
runTransition(primary.lane);
};
};
moveToggleEl.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
const open = moveMenuEl.style.display === 'flex';
moveMenuEl.style.display = open ? 'none' : 'flex';
moveToggleEl.textContent = open ? 'Move' : 'Close';
moveToggleEl.setAttribute('aria-expanded', open ? 'false' : 'true');
});
const render = () => {
applyTheme();
subtitleEl.textContent = laneLabels[lane] || 'Task';
summaryEl.textContent = title || '(Untitled task)';
const dueText = formatDue();
dueEl.textContent = dueText;
dueEl.style.display = dueText ? 'inline-flex' : 'none';
const ageText = formatAge();
ageEl.textContent = ageText;
ageEl.style.display = ageText ? 'inline-flex' : 'none';
const bodySummary = summarizeBody();
descriptionEl.textContent = bodySummary;
descriptionEl.style.display = bodySummary ? 'block' : 'none';
renderTags();
setStatus('', '', 'transparent');
renderPrimaryAction();
renderMoveMenu();
moveMenuEl.style.display = 'none';
moveToggleEl.textContent = 'Move';
moveToggleEl.setAttribute('aria-expanded', 'false');
publishLiveContent(lane, true, '');
};
render();
})();
</script>

View file

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

View file

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

View file

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