This commit is contained in:
Jax Lee 2026-01-30 19:07:16 +08:00
parent 5f4715acfc
commit 83e2f2299c
14 changed files with 1140 additions and 91 deletions

View File

@ -18,6 +18,7 @@ import { sessionsHandlers } from "./server-methods/sessions.js";
import { skillsHandlers } from "./server-methods/skills.js";
import { systemHandlers } from "./server-methods/system.js";
import { talkHandlers } from "./server-methods/talk.js";
import { tasksHandlers } from "./server-methods/tasks.js";
import { ttsHandlers } from "./server-methods/tts.js";
import type { GatewayRequestHandlers, GatewayRequestOptions } from "./server-methods/types.js";
import { updateHandlers } from "./server-methods/update.js";
@ -72,6 +73,7 @@ const READ_METHODS = new Set([
"node.list",
"node.describe",
"chat.history",
"tasks.get",
]);
const WRITE_METHODS = new Set([
"send",
@ -88,6 +90,7 @@ const WRITE_METHODS = new Set([
"chat.send",
"chat.abort",
"browser.request",
"tasks.save",
]);
function authorizeGatewayMethod(method: string, client: GatewayRequestOptions["client"]) {
@ -160,6 +163,7 @@ export const coreGatewayHandlers: GatewayRequestHandlers = {
...configHandlers,
...wizardHandlers,
...talkHandlers,
...tasksHandlers,
...ttsHandlers,
...skillsHandlers,
...sessionsHandlers,

View File

@ -0,0 +1,320 @@
import fs from "node:fs/promises";
import path from "node:path";
import { ErrorCodes, errorShape } from "../protocol/index.js";
import type { GatewayRequestHandlers } from "./types.js";
import { loadConfig } from "../../config/io.js";
type TaskColumnId = "todo" | "doing" | "done" | "blocked";
type TaskChecklistItem = {
id: string;
text: string;
done: boolean;
};
type TaskEntity = {
id: string;
name: string;
description: string;
createdAtMs: number;
updatedAtMs: number;
priority?: number;
dueAtMs?: number | null;
tags?: string[];
links?: string[];
checklist?: TaskChecklistItem[];
blockReason?: string;
extras?: Record<string, unknown>;
};
export type TaskBoardV3 = {
version: 3;
updatedAtMs: number;
tasksById: Record<string, TaskEntity>;
columns: Record<TaskColumnId, string[]>;
};
type TaskBoardV2 = {
version: 2;
updatedAtMs: number;
tasksById: Record<string, TaskEntity>;
columns: Record<"todo" | "doing" | "done" | "later", string[]>;
};
type TaskBoardV1 = {
version: 1;
updatedAtMs: number;
columns: Record<
"todo" | "doing" | "done" | "later",
Array<{ id: string; title: string; createdAtMs: number; updatedAtMs: number }>
>;
};
const DEFAULT_BOARD: TaskBoardV3 = {
version: 3,
updatedAtMs: 0,
tasksById: {},
columns: { todo: [], doing: [], done: [], blocked: [] },
};
function resolveBoardPath(): string {
const cfg = loadConfig();
const workspace = cfg.agents?.defaults?.workspace;
const base = typeof workspace === "string" && workspace.trim() ? workspace.trim() : process.cwd();
return path.join(base, "tasks", "board.json");
}
async function readBoardFile(filePath: string): Promise<TaskBoardV3> {
try {
const raw = await fs.readFile(filePath, "utf-8");
const parsed = JSON.parse(raw) as unknown;
const v3 = coerceBoardV3(parsed);
if (v3) return v3;
const migrated = migrateToV3(parsed);
if (migrated) {
await writeBoardFile(filePath, migrated);
return migrated;
}
return { ...DEFAULT_BOARD };
} catch (err: any) {
if (err?.code === "ENOENT") return { ...DEFAULT_BOARD };
// If corrupted, return default rather than failing the UI.
return { ...DEFAULT_BOARD };
}
}
async function writeBoardFile(filePath: string, board: TaskBoardV3): Promise<void> {
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, JSON.stringify(board, null, 2) + "\n", "utf-8");
}
function isPlainObject(v: unknown): v is Record<string, unknown> {
return Boolean(v) && typeof v === "object" && !Array.isArray(v);
}
function coerceStringArray(v: unknown): string[] {
if (!Array.isArray(v)) return [];
return v
.filter((x) => typeof x === "string")
.map((x) => x.trim())
.filter(Boolean)
.slice(0, 2000);
}
function coerceTaskEntity(idHint: string, v: unknown): TaskEntity | null {
if (!isPlainObject(v)) return null;
const id = (typeof v.id === "string" ? v.id : idHint).trim();
const name = typeof v.name === "string" ? v.name.trim() : "";
const description = typeof v.description === "string" ? v.description : "";
const createdAtMs = typeof v.createdAtMs === "number" ? v.createdAtMs : Date.now();
const updatedAtMs = typeof v.updatedAtMs === "number" ? v.updatedAtMs : createdAtMs;
if (!id || !name) return null;
const priority = typeof v.priority === "number" ? v.priority : undefined;
const dueAtMs = typeof v.dueAtMs === "number" ? v.dueAtMs : v.dueAtMs === null ? null : undefined;
const tags = Array.isArray(v.tags)
? v.tags
.filter((x) => typeof x === "string")
.map((x) => x.trim())
.filter(Boolean)
.slice(0, 50)
: undefined;
const links = Array.isArray(v.links)
? v.links
.filter((x) => typeof x === "string")
.map((x) => x.trim())
.filter(Boolean)
.slice(0, 200)
: undefined;
const checklist = Array.isArray(v.checklist)
? v.checklist
.map((x) => {
if (!isPlainObject(x)) return null;
const cid = typeof x.id === "string" ? x.id.trim() : "";
const text = typeof x.text === "string" ? x.text : "";
const done = Boolean(x.done);
if (!cid) return null;
return { id: cid, text, done } satisfies TaskChecklistItem;
})
.filter((x): x is TaskChecklistItem => Boolean(x))
.slice(0, 500)
: undefined;
const blockReason = typeof v.blockReason === "string" ? v.blockReason : undefined;
const extras = isPlainObject(v.extras) ? (v.extras as Record<string, unknown>) : undefined;
return {
id: id.slice(0, 128),
name: name.slice(0, 300),
description: description.slice(0, 20_000),
createdAtMs,
updatedAtMs,
priority,
dueAtMs,
tags,
links,
checklist,
blockReason: typeof blockReason === "string" ? blockReason.slice(0, 20_000) : undefined,
extras,
};
}
function coerceBoardV3(v: unknown): TaskBoardV3 | null {
if (!isPlainObject(v)) return null;
if (v.version !== 3) return null;
if (!isPlainObject(v.columns) || !isPlainObject(v.tasksById)) return null;
const columnsRaw = v.columns as Record<string, unknown>;
const tasksByIdRaw = v.tasksById as Record<string, unknown>;
const tasksById: Record<string, TaskEntity> = {};
for (const [id, raw] of Object.entries(tasksByIdRaw)) {
const coerced = coerceTaskEntity(id, raw);
if (coerced) tasksById[coerced.id] = coerced;
}
const columns: Record<TaskColumnId, string[]> = {
todo: coerceStringArray(columnsRaw.todo),
doing: coerceStringArray(columnsRaw.doing),
done: coerceStringArray(columnsRaw.done),
blocked: coerceStringArray(columnsRaw.blocked),
};
// Drop ids that don't exist.
for (const key of Object.keys(columns) as TaskColumnId[]) {
columns[key] = columns[key].filter((id) => Boolean(tasksById[id]));
}
const updatedAtMs = typeof v.updatedAtMs === "number" ? v.updatedAtMs : 0;
return { version: 3, updatedAtMs, tasksById, columns };
}
function migrateV1ToV3(v: unknown): TaskBoardV3 | null {
if (!isPlainObject(v)) return null;
if (v.version !== 1) return null;
if (!isPlainObject(v.columns)) return null;
const cols = v.columns as Record<string, unknown>;
const updatedAtMs = typeof v.updatedAtMs === "number" ? v.updatedAtMs : 0;
const tasksById: Record<string, TaskEntity> = {};
const columns: Record<TaskColumnId, string[]> = {
todo: [],
doing: [],
done: [],
blocked: [],
};
for (const key of ["todo", "doing", "done", "later"] as const) {
const items = Array.isArray(cols[key]) ? cols[key] : [];
for (const item of items) {
if (!isPlainObject(item)) continue;
const id = typeof item.id === "string" ? item.id.trim() : "";
const title = typeof item.title === "string" ? item.title.trim() : "";
if (!id || !title) continue;
const createdAtMs = typeof item.createdAtMs === "number" ? item.createdAtMs : Date.now();
const updatedAt = typeof item.updatedAtMs === "number" ? item.updatedAtMs : createdAtMs;
tasksById[id] = {
id,
name: title,
description: "",
createdAtMs,
updatedAtMs: updatedAt,
priority: 3,
dueAtMs: null,
tags: [],
links: [],
checklist: [],
blockReason: "",
extras: {},
};
const targetColumn: TaskColumnId = key === "later" ? "blocked" : (key satisfies any);
columns[targetColumn].push(id);
}
}
return { version: 3, updatedAtMs, tasksById, columns };
}
function migrateV2ToV3(v: unknown): TaskBoardV3 | null {
if (!isPlainObject(v)) return null;
if (v.version !== 2) return null;
if (!isPlainObject(v.columns) || !isPlainObject(v.tasksById)) return null;
const updatedAtMs = typeof v.updatedAtMs === "number" ? v.updatedAtMs : 0;
// Reuse entity coercion and just map columns.
const tasksByIdRaw = v.tasksById as Record<string, unknown>;
const tasksById: Record<string, TaskEntity> = {};
for (const [id, raw] of Object.entries(tasksByIdRaw)) {
const coerced = coerceTaskEntity(id, raw);
if (coerced) {
tasksById[coerced.id] = {
...coerced,
blockReason: typeof coerced.blockReason === "string" ? coerced.blockReason : "",
};
}
}
const cols = v.columns as Record<string, unknown>;
const todo = coerceStringArray(cols.todo);
const doing = coerceStringArray(cols.doing);
const done = coerceStringArray(cols.done);
const blocked = coerceStringArray(cols.later);
const columns: Record<TaskColumnId, string[]> = {
todo: todo.filter((id) => Boolean(tasksById[id])),
doing: doing.filter((id) => Boolean(tasksById[id])),
done: done.filter((id) => Boolean(tasksById[id])),
blocked: blocked.filter((id) => Boolean(tasksById[id])),
};
return { version: 3, updatedAtMs, tasksById, columns };
}
function migrateToV3(v: unknown): TaskBoardV3 | null {
return migrateV2ToV3(v) ?? migrateV1ToV3(v);
}
function validateIncomingBoard(
v: unknown,
): { ok: true; board: TaskBoardV3 } | { ok: false; message: string } {
const coerced = coerceBoardV3(v);
if (!coerced) return { ok: false, message: "board must be a v3 task board" };
const ids = Object.keys(coerced.tasksById);
if (ids.length > 5000) return { ok: false, message: "too many tasks" };
return { ok: true, board: coerced };
}
export const tasksHandlers: GatewayRequestHandlers = {
"tasks.get": async ({ respond }) => {
const filePath = resolveBoardPath();
const board = await readBoardFile(filePath);
respond(true, { board, path: filePath });
},
"tasks.save": async ({ params, respond }) => {
const incoming = (params as Record<string, unknown>).board;
const validated = validateIncomingBoard(incoming);
if (!validated.ok) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, validated.message));
return;
}
const filePath = resolveBoardPath();
const now = Date.now();
const next: TaskBoardV3 = {
...validated.board,
updatedAtMs: now,
};
await writeBoardFile(filePath, next);
respond(true, { ok: true, board: next, path: filePath });
},
};

View File

@ -25,93 +25,3 @@
overflow: hidden;
animation: slide-in 200ms ease-out;
}
@keyframes slide-in {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
/* Sidebar Panel */
.sidebar-panel {
display: flex;
flex-direction: column;
height: 100%;
background: var(--panel);
}
.sidebar-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
position: sticky;
top: 0;
z-index: 10;
background: var(--panel);
}
/* Smaller close button for sidebar */
.sidebar-header .btn {
padding: 4px 8px;
font-size: 14px;
min-width: auto;
line-height: 1;
}
.sidebar-title {
font-weight: 600;
font-size: 14px;
}
.sidebar-content {
flex: 1;
overflow: auto;
padding: 16px;
}
.sidebar-markdown {
font-size: 14px;
line-height: 1.5;
}
.sidebar-markdown pre {
background: rgba(0, 0, 0, 0.12);
border-radius: 4px;
padding: 12px;
overflow-x: auto;
}
.sidebar-markdown code {
font-family: var(--mono);
font-size: 13px;
}
/* Mobile: Full-screen modal */
@media (max-width: 768px) {
.chat-split-container--open {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1000;
}
.chat-split-container--open .chat-main {
display: none; /* Hide chat on mobile when sidebar open */
}
.chat-split-container--open .chat-sidebar {
width: 100%;
min-width: 0;
border-left: none;
}
}

View File

@ -1,4 +1,5 @@
@import './chat.css';
@import './taskboard.css';
/* ===========================================
Cards - Refined with depth

198
ui/src/styles/taskboard.css Normal file
View File

@ -0,0 +1,198 @@
/* TaskBoard page */
.taskboard {
display: flex;
flex-direction: column;
gap: 12px;
}
.taskboard__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
}
.taskboard__title {
font-weight: 700;
font-size: 18px;
}
.taskboard__sub {
color: var(--muted);
font-size: 13px;
margin-top: 4px;
}
.taskboard__path {
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.taskboard__grid {
display: grid;
grid-template-columns: repeat(4, minmax(240px, 1fr));
gap: 12px;
align-items: start;
}
@media (max-width: 1100px) {
.taskboard__grid {
grid-template-columns: repeat(2, minmax(240px, 1fr));
}
}
@media (max-width: 680px) {
.taskboard__grid {
grid-template-columns: 1fr;
}
}
.taskboard-col {
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--panel);
padding: 12px;
}
.taskboard-col__header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 10px;
}
.taskboard-col__title {
font-weight: 600;
font-size: 13px;
}
.taskboard-col__count {
font-size: 12px;
color: var(--muted);
}
.taskboard-col__add {
display: grid;
grid-template-columns: 1fr;
gap: 8px;
margin-bottom: 10px;
}
.input {
width: 100%;
padding: 8px 10px;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--bg);
color: var(--text);
font-size: 13px;
}
.textarea {
resize: vertical;
}
.taskboard-col__list {
display: flex;
flex-direction: column;
gap: 8px;
}
.task {
border: 1px solid var(--border);
border-radius: 12px;
background: var(--bg);
overflow: hidden;
}
.task__summary {
list-style: none;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
padding: 10px;
cursor: pointer;
}
.task__summary::-webkit-details-marker {
display: none;
}
.task__summary-main {
min-width: 0;
}
.task__name {
font-weight: 600;
font-size: 13px;
margin-bottom: 4px;
}
.task__desc {
font-size: 12px;
color: var(--muted);
white-space: pre-wrap;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.task__blocked {
margin-top: 6px;
font-size: 12px;
color: #f59e0b;
white-space: pre-wrap;
}
.task__details {
border-top: 1px solid var(--border);
padding: 10px;
display: flex;
flex-direction: column;
gap: 10px;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.field__label {
font-size: 12px;
color: var(--muted);
}
.task__row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
@media (max-width: 680px) {
.task__row {
grid-template-columns: 1fr;
}
}
.checklist {
display: flex;
flex-direction: column;
gap: 8px;
}
.checklist__item {
display: grid;
grid-template-columns: 18px 1fr auto;
gap: 8px;
align-items: center;
}
.checklist__text {
font-size: 13px;
}

View File

@ -139,6 +139,7 @@ export function connectGateway(host: GatewayHost) {
void loadAgents(host as unknown as MoltbotApp);
void loadNodes(host as unknown as MoltbotApp, { quiet: true });
void loadDevices(host as unknown as MoltbotApp, { quiet: true });
// TaskBoard loads on-demand when tab is active.
void refreshActiveTab(host as unknown as Parameters<typeof refreshActiveTab>[0]);
},
onClose: ({ code, reason }) => {

View File

@ -32,6 +32,7 @@ import type {
import type { ChatQueueItem, CronFormState } from "./ui-types";
import { refreshChatAvatar } from "./app-chat";
import { renderChat } from "./views/chat";
import { renderTaskBoardView } from "./views/taskboard";
import { renderConfig } from "./views/config";
import { renderChannels } from "./views/channels";
import { renderCron } from "./views/cron";
@ -426,6 +427,23 @@ export function renderApp(state: AppViewState) {
})
: nothing}
${state.tab === "taskboard"
? renderTaskBoardView({
loading: state.taskBoardLoading,
saving: state.taskBoardSaving,
error: state.taskBoardError,
path: state.taskBoardPath,
board: state.taskBoard,
onRefresh: () => void state.refreshTaskBoard(),
onAdd: (column, payload) => state.addTask(column, payload),
onDelete: (taskId) => state.deleteTask(taskId),
onMove: (taskId, from, to) => state.moveTask(taskId, from, to),
onUpdate: (taskId, patch) => state.updateTask(taskId, patch),
onUpdateChecklist: (taskId, checklist) =>
state.updateTask(taskId, { checklist }),
})
: nothing}
${state.tab === "chat"
? renderChat({
sessionKey: state.sessionKey,

View File

@ -14,6 +14,7 @@ import { saveSettings, type UiSettings } from "./storage";
import { resolveTheme, type ResolvedTheme, type ThemeMode } from "./theme";
import { startThemeTransition, type ThemeTransitionContext } from "./theme-transition";
import { scheduleChatScroll, scheduleLogsScroll } from "./app-scroll";
import { loadTaskBoard } from "./controllers/tasks";
import { startLogsPolling, stopLogsPolling, startDebugPolling, stopDebugPolling } from "./app-polling";
import { refreshChat } from "./app-chat";
import type { MoltbotApp } from "./app";
@ -148,6 +149,7 @@ export async function refreshActiveTab(host: SettingsHost) {
if (host.tab === "instances") await loadPresence(host as unknown as MoltbotApp);
if (host.tab === "sessions") await loadSessions(host as unknown as MoltbotApp);
if (host.tab === "cron") await loadCron(host);
if (host.tab === "taskboard") await loadTaskBoard(host as unknown as MoltbotApp);
if (host.tab === "skills") await loadSkills(host as unknown as MoltbotApp);
if (host.tab === "nodes") {
await loadNodes(host as unknown as MoltbotApp);

View File

@ -20,6 +20,7 @@ import type {
StatusSummary,
} from "./types";
import type { ChatAttachment, ChatQueueItem, CronFormState } from "./ui-types";
import type { TaskBoard, TaskColumnId } from "./types/task-board";
import type { EventLogEntry } from "./app-events";
import type { SkillMessage } from "./controllers/skills";
import type {
@ -57,6 +58,15 @@ export type AppViewState = {
chatAvatarUrl: string | null;
chatThinkingLevel: string | null;
chatQueue: ChatQueueItem[];
// Task board
taskBoardOpen: boolean;
taskBoardLoading: boolean;
taskBoardSaving: boolean;
taskBoardError: string | null;
taskBoardPath: string | null;
taskBoard: TaskBoard;
nodesLoading: boolean;
nodes: Array<Record<string, unknown>>;
devicesLoading: boolean;
@ -202,6 +212,14 @@ export type AppViewState = {
handleChatSelectQueueItem: (id: string) => void;
handleChatDropQueueItem: (id: string) => void;
handleChatClearQueue: () => void;
// Task board
toggleTaskBoard: () => void;
refreshTaskBoard: () => Promise<void>;
addTask: (column: TaskColumnId, payload: { name: string; description: string }) => void;
updateTask: (taskId: string, patch: unknown) => void;
deleteTask: (taskId: string) => void;
moveTask: (taskId: string, from: TaskColumnId, to: TaskColumnId) => void;
handleLogsFilterChange: (next: string) => void;
handleLogsLevelFilterToggle: (level: LogLevel) => void;
handleLogsAutoFollowToggle: (next: boolean) => void;

View File

@ -78,6 +78,14 @@ import {
} from "./app-channels";
import type { NostrProfileFormState } from "./views/channels.nostr-profile-form";
import { loadAssistantIdentity as loadAssistantIdentityInternal } from "./controllers/assistant-identity";
import { loadTaskBoard, saveTaskBoard } from "./controllers/tasks";
import {
DEFAULT_TASK_BOARD,
type TaskBoard,
type TaskColumnId,
type TaskEntity,
} from "./types/task-board";
import { generateUUID } from "./uuid";
declare global {
interface Window {
@ -136,6 +144,16 @@ export class MoltbotApp extends LitElement {
@state() sidebarError: string | null = null;
@state() splitRatio = this.settings.splitRatio;
// Task board
@state() taskBoardOpen = true;
@state() taskBoardLoading = false;
@state() taskBoardSaving = false;
@state() taskBoardError: string | null = null;
@state() taskBoardPath: string | null = null;
@state() taskBoard: TaskBoard = { ...DEFAULT_TASK_BOARD };
private taskBoardSaveTimer: number | null = null;
@state() nodesLoading = false;
@state() nodes: Array<Record<string, unknown>> = [];
@state() devicesLoading = false;
@ -496,6 +514,118 @@ export class MoltbotApp extends LitElement {
this.applySettings({ ...this.settings, splitRatio: newRatio });
}
// --- Task board (Chat left sidebar) ---
toggleTaskBoard() {
this.taskBoardOpen = !this.taskBoardOpen;
}
private scheduleTaskBoardSave(next: TaskBoard) {
if (this.taskBoardSaveTimer != null) {
window.clearTimeout(this.taskBoardSaveTimer);
}
this.taskBoardSaveTimer = window.setTimeout(() => {
this.taskBoardSaveTimer = null;
void saveTaskBoard(this, next);
}, 400);
}
addTask(column: TaskColumnId, payload: { name: string; description: string }) {
const name = payload.name.trim();
const description = (payload.description ?? "").trim();
if (!name) return;
const now = Date.now();
const id = generateUUID();
const entity: TaskEntity = {
id,
name,
description,
createdAtMs: now,
updatedAtMs: now,
priority: 3,
dueAtMs: null,
tags: [],
links: [],
checklist: [],
blockReason: "",
extras: {},
};
const next: TaskBoard = {
...this.taskBoard,
tasksById: { ...this.taskBoard.tasksById, [id]: entity },
columns: {
...this.taskBoard.columns,
[column]: [id, ...(this.taskBoard.columns[column] ?? [])],
},
};
this.taskBoard = next;
this.scheduleTaskBoardSave(next);
}
updateTask(taskId: string, patch: Partial<TaskEntity>) {
const existing = this.taskBoard.tasksById[taskId];
if (!existing) return;
const now = Date.now();
const nextTask: TaskEntity = {
...existing,
...patch,
updatedAtMs: now,
};
const next: TaskBoard = {
...this.taskBoard,
tasksById: { ...this.taskBoard.tasksById, [taskId]: nextTask },
};
this.taskBoard = next;
this.scheduleTaskBoardSave(next);
}
deleteTask(taskId: string) {
if (!this.taskBoard.tasksById[taskId]) return;
const nextTasksById = { ...this.taskBoard.tasksById };
delete nextTasksById[taskId];
const nextColumns = {
todo: (this.taskBoard.columns.todo ?? []).filter((id) => id !== taskId),
doing: (this.taskBoard.columns.doing ?? []).filter((id) => id !== taskId),
done: (this.taskBoard.columns.done ?? []).filter((id) => id !== taskId),
blocked: (this.taskBoard.columns.blocked ?? []).filter((id) => id !== taskId),
};
const next: TaskBoard = {
...this.taskBoard,
tasksById: nextTasksById,
columns: nextColumns,
};
this.taskBoard = next;
this.scheduleTaskBoardSave(next);
}
moveTask(taskId: string, from: TaskColumnId, to: TaskColumnId) {
if (from === to) return;
if (!this.taskBoard.tasksById[taskId]) return;
const fromList = this.taskBoard.columns[from] ?? [];
const toList = this.taskBoard.columns[to] ?? [];
if (!fromList.includes(taskId)) return;
const now = Date.now();
const nextTasksById = {
...this.taskBoard.tasksById,
[taskId]: { ...this.taskBoard.tasksById[taskId], updatedAtMs: now },
};
const next: TaskBoard = {
...this.taskBoard,
tasksById: nextTasksById,
columns: {
...this.taskBoard.columns,
[from]: fromList.filter((id) => id !== taskId),
[to]: [taskId, ...toList],
},
};
this.taskBoard = next;
this.scheduleTaskBoardSave(next);
}
async refreshTaskBoard() {
await loadTaskBoard(this);
}
render() {
return renderApp(this);
}

View File

@ -0,0 +1,52 @@
import type { MoltbotApp } from "../app";
import { DEFAULT_TASK_BOARD, type TaskBoard } from "../types/task-board";
export async function loadTaskBoard(host: MoltbotApp) {
if (!host.client || !host.connected) return;
host.taskBoardLoading = true;
host.taskBoardError = null;
try {
const res = (await host.client.request("tasks.get")) as {
board?: unknown;
path?: unknown;
};
const board = isTaskBoard(res.board) ? res.board : DEFAULT_TASK_BOARD;
host.taskBoard = board;
host.taskBoardPath = typeof res.path === "string" ? res.path : null;
} catch (err: any) {
host.taskBoardError = String(err?.message ?? err);
} finally {
host.taskBoardLoading = false;
}
}
export async function saveTaskBoard(host: MoltbotApp, board: TaskBoard) {
if (!host.client || !host.connected) return;
host.taskBoardSaving = true;
host.taskBoardError = null;
try {
const res = (await host.client.request("tasks.save", { board })) as {
board?: unknown;
path?: unknown;
};
const next = isTaskBoard(res.board) ? res.board : board;
host.taskBoard = next;
host.taskBoardPath = typeof res.path === "string" ? res.path : host.taskBoardPath;
} catch (err: any) {
host.taskBoardError = String(err?.message ?? err);
} finally {
host.taskBoardSaving = false;
}
}
function isPlainObject(v: unknown): v is Record<string, unknown> {
return Boolean(v) && typeof v === "object" && !Array.isArray(v);
}
function isTaskBoard(v: unknown): v is TaskBoard {
if (!isPlainObject(v)) return false;
if (v.version !== 3) return false;
if (!isPlainObject(v.columns)) return false;
if (!isPlainObject(v.tasksById)) return false;
return true;
}

View File

@ -4,7 +4,7 @@ export const TAB_GROUPS = [
{ label: "Chat", tabs: ["chat"] },
{
label: "Control",
tabs: ["overview", "channels", "instances", "sessions", "cron"],
tabs: ["overview", "channels", "instances", "sessions", "cron", "taskboard"],
},
{ label: "Agent", tabs: ["skills", "nodes"] },
{ label: "Settings", tabs: ["config", "debug", "logs"] },
@ -16,6 +16,7 @@ export type Tab =
| "instances"
| "sessions"
| "cron"
| "taskboard"
| "skills"
| "nodes"
| "chat"
@ -29,6 +30,7 @@ const TAB_PATHS: Record<Tab, string> = {
instances: "/instances",
sessions: "/sessions",
cron: "/cron",
taskboard: "/taskboard",
skills: "/skills",
nodes: "/nodes",
chat: "/chat",
@ -114,6 +116,8 @@ export function iconForTab(tab: Tab): IconName {
return "fileText";
case "cron":
return "loader";
case "taskboard":
return "folder";
case "skills":
return "zap";
case "nodes":
@ -141,6 +145,8 @@ export function titleForTab(tab: Tab) {
return "Sessions";
case "cron":
return "Cron Jobs";
case "taskboard":
return "TaskBoard";
case "skills":
return "Skills";
case "nodes":
@ -170,6 +176,8 @@ export function subtitleForTab(tab: Tab) {
return "Inspect active sessions and adjust per-session defaults.";
case "cron":
return "Schedule wakeups and recurring agent runs.";
case "taskboard":
return "Local persistent task board (extensible).";
case "skills":
return "Manage skill availability and API key injection.";
case "nodes":

View File

@ -0,0 +1,44 @@
export type TaskColumnId = "todo" | "doing" | "done" | "blocked";
export type TaskChecklistItem = {
id: string;
text: string;
done: boolean;
};
export type TaskEntity = {
id: string;
name: string;
description: string;
createdAtMs: number;
updatedAtMs: number;
// Optional/expandable fields
priority?: number; // 1-5
dueAtMs?: number | null;
tags?: string[];
links?: string[];
checklist?: TaskChecklistItem[];
// When a task is blocked, record why.
blockReason?: string;
extras?: Record<string, unknown>;
};
export type TaskBoard = {
version: 3;
updatedAtMs: number;
tasksById: Record<string, TaskEntity>;
columns: Record<TaskColumnId, string[]>; // arrays of task ids
};
export const DEFAULT_TASK_BOARD: TaskBoard = {
version: 3,
updatedAtMs: 0,
tasksById: {},
columns: {
todo: [],
doing: [],
done: [],
blocked: [],
},
};

View File

@ -0,0 +1,343 @@
import { html, nothing } from "lit";
import type { TaskBoard, TaskColumnId, TaskEntity, TaskChecklistItem } from "../types/task-board";
import { icons } from "../icons";
export type TaskBoardViewProps = {
loading: boolean;
saving: boolean;
error: string | null;
path: string | null;
board: TaskBoard;
onRefresh: () => void;
onAdd: (column: TaskColumnId, payload: { name: string; description: string }) => void;
onDelete: (taskId: string) => void;
onMove: (taskId: string, from: TaskColumnId, to: TaskColumnId) => void;
onUpdate: (taskId: string, patch: Partial<TaskEntity>) => void;
onUpdateChecklist: (taskId: string, checklist: TaskChecklistItem[]) => void;
};
export function renderTaskBoardView(props: TaskBoardViewProps) {
const columns: Array<{ id: TaskColumnId; title: string }> = [
{ id: "todo", title: "未完成" },
{ id: "doing", title: "进行中" },
{ id: "done", title: "已完成" },
{ id: "blocked", title: "Blocked" },
];
const onDropColumn = (e: DragEvent, to: TaskColumnId) => {
e.preventDefault();
const raw = e.dataTransfer?.getData("application/json") ?? "";
try {
const parsed = JSON.parse(raw) as { taskId?: unknown; from?: unknown };
const taskId = typeof parsed.taskId === "string" ? parsed.taskId : "";
const from = typeof parsed.from === "string" ? (parsed.from as TaskColumnId) : null;
if (!taskId || !from) return;
props.onMove(taskId, from, to);
} catch {
return;
}
};
return html`
<div class="taskboard">
<div class="taskboard__header">
<div>
<div class="taskboard__title">TaskBoard</div>
<div class="taskboard__sub"> · </div>
</div>
<div class="taskboard__actions">
<button class="btn" type="button" @click=${props.onRefresh} title="Refresh">
</button>
</div>
</div>
${props.error
? html`<div class="callout danger">${props.error}</div>`
: nothing}
${props.loading ? html`<div class="muted">Loading…</div>` : nothing}
${props.saving ? html`<div class="muted">Saving…</div>` : nothing}
${props.path ? html`<div class="muted taskboard__path">${props.path}</div>` : nothing}
<div class="taskboard__grid">
${columns.map((col) => {
const ids = props.board.columns[col.id] ?? [];
const tasks = ids
.map((id) => props.board.tasksById[id])
.filter(Boolean) as TaskEntity[];
return html`
<div
class="taskboard-col"
@dragover=${(e: DragEvent) => e.preventDefault()}
@drop=${(e: DragEvent) => onDropColumn(e, col.id)}
>
<div class="taskboard-col__header">
<div class="taskboard-col__title">${col.title}</div>
<div class="taskboard-col__count">${tasks.length}</div>
</div>
<form
class="taskboard-col__add"
@submit=${(e: Event) => {
e.preventDefault();
const form = e.currentTarget as HTMLFormElement;
const nameInput = form.querySelector(
"input[name=task-name]",
) as HTMLInputElement | null;
const descInput = form.querySelector(
"textarea[name=task-desc]",
) as HTMLTextAreaElement | null;
const name = nameInput?.value ?? "";
const description = descInput?.value ?? "";
if (!name.trim()) return;
props.onAdd(col.id, { name, description });
if (nameInput) nameInput.value = "";
if (descInput) descInput.value = "";
}}
>
<input
class="input"
name="task-name"
type="text"
placeholder="任务名称"
/>
<textarea
class="input textarea"
name="task-desc"
placeholder="描述(可选)"
rows="2"
></textarea>
<button class="btn primary" type="submit"></button>
</form>
<div class="taskboard-col__list">
${tasks.map((t) => renderTaskItem(props, t, col.id))}
</div>
</div>
`;
})}
</div>
</div>
`;
}
function renderTaskItem(
props: TaskBoardViewProps,
task: TaskEntity,
column: TaskColumnId,
) {
const tags = Array.isArray(task.tags) ? task.tags.join(", ") : "";
const links = Array.isArray(task.links) ? task.links.join("\n") : "";
const checklist = Array.isArray(task.checklist) ? task.checklist : [];
const due = typeof task.dueAtMs === "number" ? new Date(task.dueAtMs).toISOString().slice(0, 10) : "";
const blockReason = (task.blockReason ?? "").trim();
return html`
<details class="task" @toggle=${(e: Event) => {
// No-op: allows click-to-expand without extra state.
void e;
}}>
<summary
class="task__summary"
draggable="true"
@dragstart=${(e: DragEvent) => {
e.dataTransfer?.setData(
"application/json",
JSON.stringify({ taskId: task.id, from: column }),
);
e.dataTransfer!.effectAllowed = "move";
}}
>
<div class="task__summary-main">
<div class="task__name">${task.name}</div>
${task.description
? html`<div class="task__desc">${task.description}</div>`
: nothing}
${column === "blocked" && blockReason
? html`<div class="task__blocked">Blocked: ${blockReason}</div>`
: nothing}
</div>
<div class="task__summary-actions">
<button
class="btn"
type="button"
title="Delete"
@click=${(e: Event) => {
e.preventDefault();
e.stopPropagation();
props.onDelete(task.id);
}}
>
${icons.x}
</button>
</div>
</summary>
<div class="task__details">
<label class="field">
<div class="field__label"></div>
<input
class="input"
type="text"
.value=${task.name}
@change=${(e: Event) =>
props.onUpdate(task.id, { name: (e.target as HTMLInputElement).value })}
/>
</label>
<label class="field">
<div class="field__label"></div>
<textarea
class="input textarea"
rows="4"
.value=${task.description ?? ""}
@change=${(e: Event) =>
props.onUpdate(task.id, {
description: (e.target as HTMLTextAreaElement).value,
})}
></textarea>
</label>
<div class="task__row">
<label class="field">
<div class="field__label"> (1-5)</div>
<input
class="input"
type="number"
min="1"
max="5"
.value=${String(task.priority ?? "")}
@change=${(e: Event) => {
const v = Number((e.target as HTMLInputElement).value);
props.onUpdate(task.id, { priority: Number.isFinite(v) ? v : undefined });
}}
/>
</label>
<label class="field">
<div class="field__label"></div>
<input
class="input"
type="date"
.value=${due}
@change=${(e: Event) => {
const v = (e.target as HTMLInputElement).value;
const ms = v ? new Date(v + "T00:00:00Z").getTime() : null;
props.onUpdate(task.id, { dueAtMs: ms });
}}
/>
</label>
</div>
<label class="field">
<div class="field__label"> ()</div>
<input
class="input"
type="text"
.value=${tags}
@change=${(e: Event) => {
const raw = (e.target as HTMLInputElement).value;
const arr = raw
.split(",")
.map((s) => s.trim())
.filter(Boolean);
props.onUpdate(task.id, { tags: arr });
}}
/>
</label>
<label class="field">
<div class="field__label">Links ()</div>
<textarea
class="input textarea"
rows="3"
.value=${links}
@change=${(e: Event) => {
const raw = (e.target as HTMLTextAreaElement).value;
const arr = raw
.split("\n")
.map((s) => s.trim())
.filter(Boolean);
props.onUpdate(task.id, { links: arr });
}}
></textarea>
</label>
<label class="field">
<div class="field__label">Blocked </div>
<textarea
class="input textarea"
rows="3"
.value=${blockReason}
@change=${(e: Event) =>
props.onUpdate(task.id, {
blockReason: (e.target as HTMLTextAreaElement).value,
})}
></textarea>
</label>
<div class="field">
<div class="field__label">Checklist</div>
<div class="checklist">
${checklist.map(
(c) => html`
<label class="checklist__item">
<input
type="checkbox"
.checked=${c.done}
@change=${(e: Event) => {
const done = (e.target as HTMLInputElement).checked;
const next = checklist.map((x) =>
x.id === c.id ? { ...x, done } : x,
);
props.onUpdateChecklist(task.id, next);
}}
/>
<input
class="input checklist__text"
type="text"
.value=${c.text}
@change=${(e: Event) => {
const text = (e.target as HTMLInputElement).value;
const next = checklist.map((x) =>
x.id === c.id ? { ...x, text } : x,
);
props.onUpdateChecklist(task.id, next);
}}
/>
<button
class="btn"
type="button"
title="Remove"
@click=${() => {
const next = checklist.filter((x) => x.id !== c.id);
props.onUpdateChecklist(task.id, next);
}}
>
${icons.x}
</button>
</label>
`,
)}
<button
class="btn"
type="button"
@click=${() => {
const id = crypto.randomUUID();
const next: TaskChecklistItem[] = [
...checklist,
{ id, text: "", done: false },
];
props.onUpdateChecklist(task.id, next);
}}
>
+ Add item
</button>
</div>
</div>
</div>
</details>
`;
}