feat: polish life os card stack
This commit is contained in:
parent
22b4a2be4f
commit
980dfb9e0e
13 changed files with 692 additions and 151 deletions
BIN
examples/cards/templates/todo-item-live/assets/dosis-400.ttf
Normal file
BIN
examples/cards/templates/todo-item-live/assets/dosis-400.ttf
Normal file
Binary file not shown.
BIN
examples/cards/templates/todo-item-live/assets/dosis-600.ttf
Normal file
BIN
examples/cards/templates/todo-item-live/assets/dosis-600.ttf
Normal file
Binary file not shown.
BIN
examples/cards/templates/todo-item-live/assets/dosis-700.ttf
Normal file
BIN
examples/cards/templates/todo-item-live/assets/dosis-700.ttf
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -1,23 +1,29 @@
|
||||||
{
|
{
|
||||||
"key": "todo-item-live",
|
"key": "todo-item-live",
|
||||||
"title": "Todo Item",
|
"title": "Task 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.",
|
"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": {
|
"example_state": {
|
||||||
"source_id": "ha-todo-kacpers-to-do",
|
"kind": "file_task",
|
||||||
"entity_id": "todo.kacpers_to_do",
|
"task_path": "/home/kacper/.nanobot/workspace/tasks/backlog/20260316-pack-for-japan.md",
|
||||||
"list_name": "Kacper's To-Do",
|
"task_key": "b9b79f12-1ef3-11f1-b5e6-001e06480aef",
|
||||||
"uid": "55be123e-1ef3-11f1-b5e6-001e06480aef",
|
"title": "Pack for Japan",
|
||||||
"summary": "Get sneakers",
|
"lane": "backlog",
|
||||||
"complete_tool_name": "mcp_home_assistant_HassListCompleteItem",
|
"created": "2026-03-16T18:10:00-04:00",
|
||||||
"complete_item": "Get sneakers",
|
"updated": "2026-03-16T18:10:00-04:00",
|
||||||
"status": "needs_action",
|
|
||||||
"completed": false,
|
|
||||||
"due": null,
|
"due": null,
|
||||||
"due_datetime": null,
|
"tags": [
|
||||||
"description": null,
|
"home-assistant",
|
||||||
"generated_at": "2026-03-13T16:03:26+00:00",
|
"imported"
|
||||||
"can_complete": true
|
],
|
||||||
|
"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",
|
"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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;">
|
<style>
|
||||||
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:10px;">
|
@font-face {
|
||||||
<div style="min-width:0; flex:1 1 auto;">
|
font-family: 'M-1m Code';
|
||||||
<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>
|
src: url('/card-templates/todo-item-live/assets/mplus-1m-regular-sub.ttf') format('truetype');
|
||||||
<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>
|
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>
|
||||||
<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>
|
|
||||||
</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-task-summary class="task-card__title">Loading...</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-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;">
|
<div class="task-card__meta">
|
||||||
<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 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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(() => {
|
(() => {
|
||||||
const script = document.currentScript;
|
const script = document.currentScript;
|
||||||
|
|
@ -21,27 +262,120 @@
|
||||||
const state = window.__nanobotGetCardState?.(script) || {};
|
const state = window.__nanobotGetCardState?.(script) || {};
|
||||||
if (!(root instanceof HTMLElement)) return;
|
if (!(root instanceof HTMLElement)) return;
|
||||||
|
|
||||||
const subtitleEl = root.querySelector('[data-todo-subtitle]');
|
const cardEl = root.querySelector('[data-task-item-card]');
|
||||||
const statusEl = root.querySelector('[data-todo-status]');
|
const subtitleEl = root.querySelector('[data-task-subtitle]');
|
||||||
const summaryEl = root.querySelector('[data-todo-summary]');
|
const statusEl = root.querySelector('[data-task-status]');
|
||||||
const dueEl = root.querySelector('[data-todo-due]');
|
const summaryEl = root.querySelector('[data-task-summary]');
|
||||||
const descriptionEl = root.querySelector('[data-todo-description]');
|
const tagsEl = root.querySelector('[data-task-tags]');
|
||||||
const completeEl = root.querySelector('[data-todo-complete]');
|
const dueEl = root.querySelector('[data-task-due]');
|
||||||
if (!(subtitleEl instanceof HTMLElement) || !(statusEl instanceof HTMLElement) || !(summaryEl instanceof HTMLElement) || !(dueEl instanceof HTMLElement) || !(descriptionEl instanceof HTMLElement) || !(completeEl instanceof HTMLButtonElement)) return;
|
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 taskPath = typeof state.task_path === 'string' ? state.task_path.trim() : '';
|
||||||
const entityId = typeof state.entity_id === 'string' ? state.entity_id.trim() : '';
|
const taskKey = typeof state.task_key === 'string' ? state.task_key.trim() : '';
|
||||||
const uid = typeof state.uid === 'string' ? state.uid.trim() : '';
|
const title = typeof state.title === 'string' ? state.title.trim() : '';
|
||||||
const summary = typeof state.summary === 'string' ? state.summary.trim() : '';
|
const lane = typeof state.lane === 'string' ? state.lane.trim() : 'backlog';
|
||||||
const listName = typeof state.list_name === 'string' ? state.list_name.trim() : '';
|
const created = typeof state.created === 'string' ? state.created.trim() : '';
|
||||||
const rawStatus = typeof state.status === 'string' ? state.status.trim() : 'needs_action';
|
const updated = typeof state.updated === 'string' ? state.updated.trim() : '';
|
||||||
const completed = Boolean(state.completed) || rawStatus === 'completed';
|
|
||||||
const due = typeof state.due === 'string' ? state.due.trim() : '';
|
const due = typeof state.due === 'string' ? state.due.trim() : '';
|
||||||
const dueDateTime = typeof state.due_datetime === 'string' ? state.due_datetime.trim() : '';
|
const body = typeof state.body === 'string' ? state.body.trim() : '';
|
||||||
const description = typeof state.description === 'string' ? state.description.trim() : '';
|
const tags = Array.isArray(state.tags)
|
||||||
const configuredCompleteTool = typeof state.complete_tool_name === 'string' ? state.complete_tool_name.trim() : '';
|
? state.tags.map((value) => String(value || '').trim()).filter(Boolean)
|
||||||
const completeItem = typeof state.complete_item === 'string' ? state.complete_item.trim() : summary;
|
: [];
|
||||||
const canComplete = Boolean(state.can_complete) && Boolean(listName) && Boolean(completeItem) && !completed;
|
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) => {
|
const setStatus = (label, fg, bg) => {
|
||||||
statusEl.textContent = label;
|
statusEl.textContent = label;
|
||||||
|
|
@ -50,127 +384,268 @@
|
||||||
statusEl.style.display = label ? 'inline-flex' : 'none';
|
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 = () => {
|
const formatDue = () => {
|
||||||
if (dueDateTime) {
|
if (!due) return '';
|
||||||
const parsed = new Date(dueDateTime);
|
const raw = due.includes('T') ? due : `${due}T00:00:00`;
|
||||||
if (!Number.isNaN(parsed.getTime())) {
|
const parsed = new Date(raw);
|
||||||
return `Due ${parsed.toLocaleString([], { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })}`;
|
if (Number.isNaN(parsed.getTime())) return `Due ${due}`;
|
||||||
}
|
if (due.includes('T')) {
|
||||||
return `Due ${dueDateTime}`;
|
return `Due ${parsed.toLocaleString([], { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })}`;
|
||||||
}
|
}
|
||||||
if (due) {
|
return `Due ${parsed.toLocaleDateString([], { month: 'short', day: 'numeric' })}`;
|
||||||
const parsed = new Date(`${due}T00:00:00`);
|
};
|
||||||
if (!Number.isNaN(parsed.getTime())) {
|
|
||||||
return `Due ${parsed.toLocaleDateString([], { month: 'short', day: 'numeric' })}`;
|
const formatAge = () => {
|
||||||
}
|
const createdMs = parseCreatedTimeMs();
|
||||||
return `Due ${due}`;
|
if (!Number.isFinite(createdMs)) return '';
|
||||||
}
|
const days = Math.floor((Date.now() - createdMs) / (24 * 60 * 60 * 1000));
|
||||||
return completed ? 'Completed' : '';
|
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 = '') => {
|
const publishLiveContent = (statusValue, exists = true, error = '') => {
|
||||||
window.__nanobotSetCardLiveContent?.(script, {
|
window.__nanobotSetCardLiveContent?.(script, {
|
||||||
kind: 'ha_todo_item',
|
kind: 'file_task',
|
||||||
exists,
|
exists,
|
||||||
source_id: sourceId || null,
|
task_path: taskPath || null,
|
||||||
entity_id: entityId || null,
|
task_key: taskKey || null,
|
||||||
list_name: listName || null,
|
title: title || null,
|
||||||
uid: uid || null,
|
lane,
|
||||||
status: statusValue,
|
created: created || null,
|
||||||
summary: summary || null,
|
updated: updated || null,
|
||||||
due: due || null,
|
due: due || null,
|
||||||
due_datetime: dueDateTime || null,
|
tags,
|
||||||
description: description || null,
|
metadata,
|
||||||
complete_tool_name: configuredCompleteTool || null,
|
score: computeScore(),
|
||||||
can_complete: canComplete,
|
status: statusValue,
|
||||||
error: error || null,
|
error: error || null,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolveCompleteToolName = async () => {
|
const shellQuote = (value) => `'${String(value).replace(/'/g, `'\"'\"'`)}'`;
|
||||||
if (configuredCompleteTool) return configuredCompleteTool;
|
|
||||||
if (!window.__nanobotListTools) return 'mcp_home_assistant_HassListCompleteItem';
|
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 {
|
try {
|
||||||
const tools = await window.__nanobotListTools();
|
return JSON.parse(cleaned);
|
||||||
const completeTool = Array.isArray(tools)
|
|
||||||
? tools.find((tool) => /(^|_)HassListCompleteItem$/i.test(String(tool?.name || '')))
|
|
||||||
: null;
|
|
||||||
return completeTool?.name || 'mcp_home_assistant_HassListCompleteItem';
|
|
||||||
} catch {
|
} catch {
|
||||||
return 'mcp_home_assistant_HassListCompleteItem';
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const render = () => {
|
const setBusy = (busy) => {
|
||||||
subtitleEl.textContent = listName || 'Todo';
|
primaryEl.disabled = busy;
|
||||||
summaryEl.textContent = summary || '(Untitled task)';
|
moveToggleEl.disabled = busy;
|
||||||
const dueText = formatDue();
|
for (const button of moveMenuEl.querySelectorAll('button')) {
|
||||||
dueEl.textContent = dueText;
|
if (button instanceof HTMLButtonElement) button.disabled = busy;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
publishLiveContent(completed ? 'completed' : rawStatus, true, '');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const requestCardResync = async () => {
|
const refreshCards = () => {
|
||||||
if (!sourceId) return;
|
moveMenuEl.style.display = 'none';
|
||||||
await fetch('/cards/sync', {
|
moveToggleEl.textContent = 'Move';
|
||||||
method: 'POST',
|
moveToggleEl.setAttribute('aria-expanded', 'false');
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ source_id: sourceId }),
|
|
||||||
});
|
|
||||||
window.dispatchEvent(new Event('nanobot:cards-refresh'));
|
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();
|
||||||
|
runTransition(option.lane);
|
||||||
|
});
|
||||||
|
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.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
if (!canComplete) return;
|
const open = moveMenuEl.style.display === 'flex';
|
||||||
completeEl.disabled = true;
|
moveMenuEl.style.display = open ? 'none' : 'flex';
|
||||||
setStatus('Saving', '#9a7b68', 'rgba(231, 220, 209, 0.92)');
|
moveToggleEl.textContent = open ? 'Move' : 'Close';
|
||||||
try {
|
moveToggleEl.setAttribute('aria-expanded', open ? 'false' : 'true');
|
||||||
const completeToolName = await resolveCompleteToolName();
|
|
||||||
await window.__nanobotCallTool?.(completeToolName, {
|
|
||||||
name: listName,
|
|
||||||
item: completeItem,
|
|
||||||
});
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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();
|
render();
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ const EXECUTABLE_SCRIPT_TYPES = new Set([
|
||||||
"application/javascript",
|
"application/javascript",
|
||||||
"module",
|
"module",
|
||||||
]);
|
]);
|
||||||
|
const CARD_LIVE_CONTENT_EVENT = "nanobot:card-live-content-change";
|
||||||
const CARD_SELECTION_EVENT = "nanobot:card-selection-change";
|
const CARD_SELECTION_EVENT = "nanobot:card-selection-change";
|
||||||
const cardLiveContentStore = new Map<string, JsonValue>();
|
const cardLiveContentStore = new Map<string, JsonValue>();
|
||||||
const cardRefreshHandlers = new Map<string, () => void>();
|
const cardRefreshHandlers = new Map<string, () => void>();
|
||||||
|
|
@ -74,6 +75,10 @@ function resolveCardRoot(target: HTMLScriptElement | HTMLElement | null): HTMLEl
|
||||||
return target.closest("[data-nanobot-card-root]");
|
return target.closest("[data-nanobot-card-root]");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function dispatchCardLiveContentChange(cardId: string): void {
|
||||||
|
window.dispatchEvent(new CustomEvent(CARD_LIVE_CONTENT_EVENT, { detail: { cardId } }));
|
||||||
|
}
|
||||||
|
|
||||||
function setCardLiveContent(
|
function setCardLiveContent(
|
||||||
target: HTMLScriptElement | HTMLElement | null,
|
target: HTMLScriptElement | HTMLElement | null,
|
||||||
snapshot: JsonValue | null | undefined,
|
snapshot: JsonValue | null | undefined,
|
||||||
|
|
@ -84,9 +89,11 @@ function setCardLiveContent(
|
||||||
const cloned = cloneJsonValue(snapshot ?? undefined);
|
const cloned = cloneJsonValue(snapshot ?? undefined);
|
||||||
if (cloned === undefined) {
|
if (cloned === undefined) {
|
||||||
cardLiveContentStore.delete(cardId);
|
cardLiveContentStore.delete(cardId);
|
||||||
|
dispatchCardLiveContentChange(cardId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
cardLiveContentStore.set(cardId, cloned);
|
cardLiveContentStore.set(cardId, cloned);
|
||||||
|
dispatchCardLiveContentChange(cardId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCardLiveContent(cardId: string | null | undefined): JsonValue | undefined {
|
function getCardLiveContent(cardId: string | null | undefined): JsonValue | undefined {
|
||||||
|
|
@ -437,6 +444,22 @@ const LANE_TITLES: Record<CardLane, string> = {
|
||||||
history: "History",
|
history: "History",
|
||||||
};
|
};
|
||||||
const LANE_ORDER: CardLane[] = ["attention", "work", "context", "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 {
|
interface CardProps {
|
||||||
card: CardItem;
|
card: CardItem;
|
||||||
|
|
@ -690,7 +713,13 @@ export function CardFeed({ cards, viewActive, onDismiss, onChoice, onAskCard }:
|
||||||
lane,
|
lane,
|
||||||
title: LANE_TITLES[lane],
|
title: LANE_TITLES[lane],
|
||||||
cards: cards.filter((card) => card.lane === 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],
|
[cards],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import type {
|
||||||
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL ?? "";
|
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL ?? "";
|
||||||
const WEBRTC_STUN_URL = import.meta.env.VITE_WEBRTC_STUN_URL?.trim() ?? "";
|
const WEBRTC_STUN_URL = import.meta.env.VITE_WEBRTC_STUN_URL?.trim() ?? "";
|
||||||
const LOCAL_ICE_GATHER_TIMEOUT_MS = 350;
|
const LOCAL_ICE_GATHER_TIMEOUT_MS = 350;
|
||||||
|
const CARD_LIVE_CONTENT_EVENT = "nanobot:card-live-content-change";
|
||||||
|
|
||||||
let cardIdCounter = 0;
|
let cardIdCounter = 0;
|
||||||
let logIdCounter = 0;
|
let logIdCounter = 0;
|
||||||
|
|
@ -80,11 +81,23 @@ interface RTCCallbacks {
|
||||||
closePC: () => void;
|
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 {
|
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];
|
const stateDiff = STATE_RANK[a.state] - STATE_RANK[b.state];
|
||||||
if (stateDiff !== 0) return stateDiff;
|
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;
|
if (a.priority !== b.priority) return b.priority - a.priority;
|
||||||
const updatedDiff = b.updatedAt.localeCompare(a.updatedAt);
|
const updatedDiff = b.updatedAt.localeCompare(a.updatedAt);
|
||||||
if (updatedDiff !== 0) return updatedDiff;
|
if (updatedDiff !== 0) return updatedDiff;
|
||||||
|
|
@ -182,7 +195,7 @@ function handleTypedMessage(
|
||||||
): void {
|
): void {
|
||||||
if (msg.type === "agent_state") {
|
if (msg.type === "agent_state") {
|
||||||
idleFallback.clear();
|
idleFallback.clear();
|
||||||
setAgentState((prev) => (prev === "listening" ? prev : msg.state));
|
setAgentState(() => msg.state);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (msg.type === "message") {
|
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 };
|
return { cards, upsertCard, dismissCard, loadPersistedCards };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -638,7 +638,8 @@ body {
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: rgba(255, 200, 140, 0.25) transparent;
|
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 {
|
#card-feed::-webkit-scrollbar {
|
||||||
width: 4px;
|
width: 4px;
|
||||||
|
|
@ -688,7 +689,7 @@ body {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
padding: 0 8px;
|
padding: 0;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
.card.dismissing {
|
.card.dismissing {
|
||||||
|
|
@ -878,12 +879,9 @@ body {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
.card.kind-text .card-body > [data-nanobot-card-root] > :not(script) {
|
.card.kind-text .card-body > [data-nanobot-card-root] > :not(script) {
|
||||||
border-radius: 16px;
|
border-radius: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow:
|
box-shadow: none;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
.card-question {
|
.card-question {
|
||||||
color: rgba(255, 245, 235, 0.95);
|
color: rgba(255, 245, 235, 0.95);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue