add
This commit is contained in:
parent
5f4715acfc
commit
83e2f2299c
@ -18,6 +18,7 @@ import { sessionsHandlers } from "./server-methods/sessions.js";
|
|||||||
import { skillsHandlers } from "./server-methods/skills.js";
|
import { skillsHandlers } from "./server-methods/skills.js";
|
||||||
import { systemHandlers } from "./server-methods/system.js";
|
import { systemHandlers } from "./server-methods/system.js";
|
||||||
import { talkHandlers } from "./server-methods/talk.js";
|
import { talkHandlers } from "./server-methods/talk.js";
|
||||||
|
import { tasksHandlers } from "./server-methods/tasks.js";
|
||||||
import { ttsHandlers } from "./server-methods/tts.js";
|
import { ttsHandlers } from "./server-methods/tts.js";
|
||||||
import type { GatewayRequestHandlers, GatewayRequestOptions } from "./server-methods/types.js";
|
import type { GatewayRequestHandlers, GatewayRequestOptions } from "./server-methods/types.js";
|
||||||
import { updateHandlers } from "./server-methods/update.js";
|
import { updateHandlers } from "./server-methods/update.js";
|
||||||
@ -72,6 +73,7 @@ const READ_METHODS = new Set([
|
|||||||
"node.list",
|
"node.list",
|
||||||
"node.describe",
|
"node.describe",
|
||||||
"chat.history",
|
"chat.history",
|
||||||
|
"tasks.get",
|
||||||
]);
|
]);
|
||||||
const WRITE_METHODS = new Set([
|
const WRITE_METHODS = new Set([
|
||||||
"send",
|
"send",
|
||||||
@ -88,6 +90,7 @@ const WRITE_METHODS = new Set([
|
|||||||
"chat.send",
|
"chat.send",
|
||||||
"chat.abort",
|
"chat.abort",
|
||||||
"browser.request",
|
"browser.request",
|
||||||
|
"tasks.save",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function authorizeGatewayMethod(method: string, client: GatewayRequestOptions["client"]) {
|
function authorizeGatewayMethod(method: string, client: GatewayRequestOptions["client"]) {
|
||||||
@ -160,6 +163,7 @@ export const coreGatewayHandlers: GatewayRequestHandlers = {
|
|||||||
...configHandlers,
|
...configHandlers,
|
||||||
...wizardHandlers,
|
...wizardHandlers,
|
||||||
...talkHandlers,
|
...talkHandlers,
|
||||||
|
...tasksHandlers,
|
||||||
...ttsHandlers,
|
...ttsHandlers,
|
||||||
...skillsHandlers,
|
...skillsHandlers,
|
||||||
...sessionsHandlers,
|
...sessionsHandlers,
|
||||||
|
|||||||
320
src/gateway/server-methods/tasks.ts
Normal file
320
src/gateway/server-methods/tasks.ts
Normal 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 });
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -25,93 +25,3 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
animation: slide-in 200ms ease-out;
|
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
@import './chat.css';
|
@import './chat.css';
|
||||||
|
@import './taskboard.css';
|
||||||
|
|
||||||
/* ===========================================
|
/* ===========================================
|
||||||
Cards - Refined with depth
|
Cards - Refined with depth
|
||||||
|
|||||||
198
ui/src/styles/taskboard.css
Normal file
198
ui/src/styles/taskboard.css
Normal 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;
|
||||||
|
}
|
||||||
@ -139,6 +139,7 @@ export function connectGateway(host: GatewayHost) {
|
|||||||
void loadAgents(host as unknown as MoltbotApp);
|
void loadAgents(host as unknown as MoltbotApp);
|
||||||
void loadNodes(host as unknown as MoltbotApp, { quiet: true });
|
void loadNodes(host as unknown as MoltbotApp, { quiet: true });
|
||||||
void loadDevices(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]);
|
void refreshActiveTab(host as unknown as Parameters<typeof refreshActiveTab>[0]);
|
||||||
},
|
},
|
||||||
onClose: ({ code, reason }) => {
|
onClose: ({ code, reason }) => {
|
||||||
|
|||||||
@ -32,6 +32,7 @@ import type {
|
|||||||
import type { ChatQueueItem, CronFormState } from "./ui-types";
|
import type { ChatQueueItem, CronFormState } from "./ui-types";
|
||||||
import { refreshChatAvatar } from "./app-chat";
|
import { refreshChatAvatar } from "./app-chat";
|
||||||
import { renderChat } from "./views/chat";
|
import { renderChat } from "./views/chat";
|
||||||
|
import { renderTaskBoardView } from "./views/taskboard";
|
||||||
import { renderConfig } from "./views/config";
|
import { renderConfig } from "./views/config";
|
||||||
import { renderChannels } from "./views/channels";
|
import { renderChannels } from "./views/channels";
|
||||||
import { renderCron } from "./views/cron";
|
import { renderCron } from "./views/cron";
|
||||||
@ -426,6 +427,23 @@ export function renderApp(state: AppViewState) {
|
|||||||
})
|
})
|
||||||
: nothing}
|
: 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"
|
${state.tab === "chat"
|
||||||
? renderChat({
|
? renderChat({
|
||||||
sessionKey: state.sessionKey,
|
sessionKey: state.sessionKey,
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import { saveSettings, type UiSettings } from "./storage";
|
|||||||
import { resolveTheme, type ResolvedTheme, type ThemeMode } from "./theme";
|
import { resolveTheme, type ResolvedTheme, type ThemeMode } from "./theme";
|
||||||
import { startThemeTransition, type ThemeTransitionContext } from "./theme-transition";
|
import { startThemeTransition, type ThemeTransitionContext } from "./theme-transition";
|
||||||
import { scheduleChatScroll, scheduleLogsScroll } from "./app-scroll";
|
import { scheduleChatScroll, scheduleLogsScroll } from "./app-scroll";
|
||||||
|
import { loadTaskBoard } from "./controllers/tasks";
|
||||||
import { startLogsPolling, stopLogsPolling, startDebugPolling, stopDebugPolling } from "./app-polling";
|
import { startLogsPolling, stopLogsPolling, startDebugPolling, stopDebugPolling } from "./app-polling";
|
||||||
import { refreshChat } from "./app-chat";
|
import { refreshChat } from "./app-chat";
|
||||||
import type { MoltbotApp } from "./app";
|
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 === "instances") await loadPresence(host as unknown as MoltbotApp);
|
||||||
if (host.tab === "sessions") await loadSessions(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 === "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 === "skills") await loadSkills(host as unknown as MoltbotApp);
|
||||||
if (host.tab === "nodes") {
|
if (host.tab === "nodes") {
|
||||||
await loadNodes(host as unknown as MoltbotApp);
|
await loadNodes(host as unknown as MoltbotApp);
|
||||||
|
|||||||
@ -20,6 +20,7 @@ import type {
|
|||||||
StatusSummary,
|
StatusSummary,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import type { ChatAttachment, ChatQueueItem, CronFormState } from "./ui-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 { EventLogEntry } from "./app-events";
|
||||||
import type { SkillMessage } from "./controllers/skills";
|
import type { SkillMessage } from "./controllers/skills";
|
||||||
import type {
|
import type {
|
||||||
@ -57,6 +58,15 @@ export type AppViewState = {
|
|||||||
chatAvatarUrl: string | null;
|
chatAvatarUrl: string | null;
|
||||||
chatThinkingLevel: string | null;
|
chatThinkingLevel: string | null;
|
||||||
chatQueue: ChatQueueItem[];
|
chatQueue: ChatQueueItem[];
|
||||||
|
|
||||||
|
// Task board
|
||||||
|
taskBoardOpen: boolean;
|
||||||
|
taskBoardLoading: boolean;
|
||||||
|
taskBoardSaving: boolean;
|
||||||
|
taskBoardError: string | null;
|
||||||
|
taskBoardPath: string | null;
|
||||||
|
taskBoard: TaskBoard;
|
||||||
|
|
||||||
nodesLoading: boolean;
|
nodesLoading: boolean;
|
||||||
nodes: Array<Record<string, unknown>>;
|
nodes: Array<Record<string, unknown>>;
|
||||||
devicesLoading: boolean;
|
devicesLoading: boolean;
|
||||||
@ -202,6 +212,14 @@ export type AppViewState = {
|
|||||||
handleChatSelectQueueItem: (id: string) => void;
|
handleChatSelectQueueItem: (id: string) => void;
|
||||||
handleChatDropQueueItem: (id: string) => void;
|
handleChatDropQueueItem: (id: string) => void;
|
||||||
handleChatClearQueue: () => 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;
|
handleLogsFilterChange: (next: string) => void;
|
||||||
handleLogsLevelFilterToggle: (level: LogLevel) => void;
|
handleLogsLevelFilterToggle: (level: LogLevel) => void;
|
||||||
handleLogsAutoFollowToggle: (next: boolean) => void;
|
handleLogsAutoFollowToggle: (next: boolean) => void;
|
||||||
|
|||||||
130
ui/src/ui/app.ts
130
ui/src/ui/app.ts
@ -78,6 +78,14 @@ import {
|
|||||||
} from "./app-channels";
|
} from "./app-channels";
|
||||||
import type { NostrProfileFormState } from "./views/channels.nostr-profile-form";
|
import type { NostrProfileFormState } from "./views/channels.nostr-profile-form";
|
||||||
import { loadAssistantIdentity as loadAssistantIdentityInternal } from "./controllers/assistant-identity";
|
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 {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
@ -136,6 +144,16 @@ export class MoltbotApp extends LitElement {
|
|||||||
@state() sidebarError: string | null = null;
|
@state() sidebarError: string | null = null;
|
||||||
@state() splitRatio = this.settings.splitRatio;
|
@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() nodesLoading = false;
|
||||||
@state() nodes: Array<Record<string, unknown>> = [];
|
@state() nodes: Array<Record<string, unknown>> = [];
|
||||||
@state() devicesLoading = false;
|
@state() devicesLoading = false;
|
||||||
@ -496,6 +514,118 @@ export class MoltbotApp extends LitElement {
|
|||||||
this.applySettings({ ...this.settings, splitRatio: newRatio });
|
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() {
|
render() {
|
||||||
return renderApp(this);
|
return renderApp(this);
|
||||||
}
|
}
|
||||||
|
|||||||
52
ui/src/ui/controllers/tasks.ts
Normal file
52
ui/src/ui/controllers/tasks.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -4,7 +4,7 @@ export const TAB_GROUPS = [
|
|||||||
{ label: "Chat", tabs: ["chat"] },
|
{ label: "Chat", tabs: ["chat"] },
|
||||||
{
|
{
|
||||||
label: "Control",
|
label: "Control",
|
||||||
tabs: ["overview", "channels", "instances", "sessions", "cron"],
|
tabs: ["overview", "channels", "instances", "sessions", "cron", "taskboard"],
|
||||||
},
|
},
|
||||||
{ label: "Agent", tabs: ["skills", "nodes"] },
|
{ label: "Agent", tabs: ["skills", "nodes"] },
|
||||||
{ label: "Settings", tabs: ["config", "debug", "logs"] },
|
{ label: "Settings", tabs: ["config", "debug", "logs"] },
|
||||||
@ -16,6 +16,7 @@ export type Tab =
|
|||||||
| "instances"
|
| "instances"
|
||||||
| "sessions"
|
| "sessions"
|
||||||
| "cron"
|
| "cron"
|
||||||
|
| "taskboard"
|
||||||
| "skills"
|
| "skills"
|
||||||
| "nodes"
|
| "nodes"
|
||||||
| "chat"
|
| "chat"
|
||||||
@ -29,6 +30,7 @@ const TAB_PATHS: Record<Tab, string> = {
|
|||||||
instances: "/instances",
|
instances: "/instances",
|
||||||
sessions: "/sessions",
|
sessions: "/sessions",
|
||||||
cron: "/cron",
|
cron: "/cron",
|
||||||
|
taskboard: "/taskboard",
|
||||||
skills: "/skills",
|
skills: "/skills",
|
||||||
nodes: "/nodes",
|
nodes: "/nodes",
|
||||||
chat: "/chat",
|
chat: "/chat",
|
||||||
@ -114,6 +116,8 @@ export function iconForTab(tab: Tab): IconName {
|
|||||||
return "fileText";
|
return "fileText";
|
||||||
case "cron":
|
case "cron":
|
||||||
return "loader";
|
return "loader";
|
||||||
|
case "taskboard":
|
||||||
|
return "folder";
|
||||||
case "skills":
|
case "skills":
|
||||||
return "zap";
|
return "zap";
|
||||||
case "nodes":
|
case "nodes":
|
||||||
@ -141,6 +145,8 @@ export function titleForTab(tab: Tab) {
|
|||||||
return "Sessions";
|
return "Sessions";
|
||||||
case "cron":
|
case "cron":
|
||||||
return "Cron Jobs";
|
return "Cron Jobs";
|
||||||
|
case "taskboard":
|
||||||
|
return "TaskBoard";
|
||||||
case "skills":
|
case "skills":
|
||||||
return "Skills";
|
return "Skills";
|
||||||
case "nodes":
|
case "nodes":
|
||||||
@ -170,6 +176,8 @@ export function subtitleForTab(tab: Tab) {
|
|||||||
return "Inspect active sessions and adjust per-session defaults.";
|
return "Inspect active sessions and adjust per-session defaults.";
|
||||||
case "cron":
|
case "cron":
|
||||||
return "Schedule wakeups and recurring agent runs.";
|
return "Schedule wakeups and recurring agent runs.";
|
||||||
|
case "taskboard":
|
||||||
|
return "Local persistent task board (extensible).";
|
||||||
case "skills":
|
case "skills":
|
||||||
return "Manage skill availability and API key injection.";
|
return "Manage skill availability and API key injection.";
|
||||||
case "nodes":
|
case "nodes":
|
||||||
|
|||||||
44
ui/src/ui/types/task-board.ts
Normal file
44
ui/src/ui/types/task-board.ts
Normal 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: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
343
ui/src/ui/views/taskboard.ts
Normal file
343
ui/src/ui/views/taskboard.ts
Normal 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>
|
||||||
|
`;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user