diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index 66492b976..ef271285b 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -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, diff --git a/src/gateway/server-methods/tasks.ts b/src/gateway/server-methods/tasks.ts new file mode 100644 index 000000000..f8ba19dfe --- /dev/null +++ b/src/gateway/server-methods/tasks.ts @@ -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; +}; + +export type TaskBoardV3 = { + version: 3; + updatedAtMs: number; + tasksById: Record; + columns: Record; +}; + +type TaskBoardV2 = { + version: 2; + updatedAtMs: number; + tasksById: Record; + 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 { + 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 { + 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 { + 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) : 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; + const tasksByIdRaw = v.tasksById as Record; + + const tasksById: Record = {}; + for (const [id, raw] of Object.entries(tasksByIdRaw)) { + const coerced = coerceTaskEntity(id, raw); + if (coerced) tasksById[coerced.id] = coerced; + } + + const columns: Record = { + 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; + const updatedAtMs = typeof v.updatedAtMs === "number" ? v.updatedAtMs : 0; + + const tasksById: Record = {}; + const columns: Record = { + 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; + const tasksById: Record = {}; + 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; + const todo = coerceStringArray(cols.todo); + const doing = coerceStringArray(cols.doing); + const done = coerceStringArray(cols.done); + const blocked = coerceStringArray(cols.later); + + const columns: Record = { + 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).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 }); + }, +}; diff --git a/ui/src/styles/chat/sidebar.css b/ui/src/styles/chat/sidebar.css index 934e285d9..83d915045 100644 --- a/ui/src/styles/chat/sidebar.css +++ b/ui/src/styles/chat/sidebar.css @@ -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; - } -} diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index 27dfe62d1..7963d4d39 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -1,4 +1,5 @@ @import './chat.css'; +@import './taskboard.css'; /* =========================================== Cards - Refined with depth diff --git a/ui/src/styles/taskboard.css b/ui/src/styles/taskboard.css new file mode 100644 index 000000000..9c48f1a22 --- /dev/null +++ b/ui/src/styles/taskboard.css @@ -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; +} diff --git a/ui/src/ui/app-gateway.ts b/ui/src/ui/app-gateway.ts index ba1df61e1..65dd7013b 100644 --- a/ui/src/ui/app-gateway.ts +++ b/ui/src/ui/app-gateway.ts @@ -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[0]); }, onClose: ({ code, reason }) => { diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 422af6863..66997a0fc 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -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, diff --git a/ui/src/ui/app-settings.ts b/ui/src/ui/app-settings.ts index 7e3ab29cf..bbbc65896 100644 --- a/ui/src/ui/app-settings.ts +++ b/ui/src/ui/app-settings.ts @@ -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); diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index f58656bfb..f64cda97a 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -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>; 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; + 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; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 50ffcdf76..1aaaeeb8a 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -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> = []; @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) { + 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); } diff --git a/ui/src/ui/controllers/tasks.ts b/ui/src/ui/controllers/tasks.ts new file mode 100644 index 000000000..fec13c2e8 --- /dev/null +++ b/ui/src/ui/controllers/tasks.ts @@ -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 { + 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; +} diff --git a/ui/src/ui/navigation.ts b/ui/src/ui/navigation.ts index 966abec96..02bfab522 100644 --- a/ui/src/ui/navigation.ts +++ b/ui/src/ui/navigation.ts @@ -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 = { 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": diff --git a/ui/src/ui/types/task-board.ts b/ui/src/ui/types/task-board.ts new file mode 100644 index 000000000..72d497489 --- /dev/null +++ b/ui/src/ui/types/task-board.ts @@ -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; +}; + +export type TaskBoard = { + version: 3; + updatedAtMs: number; + tasksById: Record; + columns: Record; // arrays of task ids +}; + +export const DEFAULT_TASK_BOARD: TaskBoard = { + version: 3, + updatedAtMs: 0, + tasksById: {}, + columns: { + todo: [], + doing: [], + done: [], + blocked: [], + }, +}; diff --git a/ui/src/ui/views/taskboard.ts b/ui/src/ui/views/taskboard.ts new file mode 100644 index 000000000..e7332e440 --- /dev/null +++ b/ui/src/ui/views/taskboard.ts @@ -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) => 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` +
+
+
+
TaskBoard
+
本地存储 · 可扩展任务看板
+
+
+ +
+
+ + ${props.error + ? html`
${props.error}
` + : nothing} + ${props.loading ? html`
Loading…
` : nothing} + ${props.saving ? html`
Saving…
` : nothing} + ${props.path ? html`
${props.path}
` : nothing} + +
+ ${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` +
e.preventDefault()} + @drop=${(e: DragEvent) => onDropColumn(e, col.id)} + > +
+
${col.title}
+
${tasks.length}
+
+ +
{ + 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 = ""; + }} + > + + + +
+ +
+ ${tasks.map((t) => renderTaskItem(props, t, col.id))} +
+
+ `; + })} +
+
+ `; +} + +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` +
{ + // No-op: allows click-to-expand without extra state. + void e; + }}> + { + e.dataTransfer?.setData( + "application/json", + JSON.stringify({ taskId: task.id, from: column }), + ); + e.dataTransfer!.effectAllowed = "move"; + }} + > +
+
${task.name}
+ ${task.description + ? html`
${task.description}
` + : nothing} + ${column === "blocked" && blockReason + ? html`
Blocked: ${blockReason}
` + : nothing} +
+
+ +
+
+ +
+ + + + +
+ + +
+ + + + + + + +
+
Checklist
+
+ ${checklist.map( + (c) => html` + + `, + )} + + +
+
+
+
+ `; +}