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 { 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,
|
||||
|
||||
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;
|
||||
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 './taskboard.css';
|
||||
|
||||
/* ===========================================
|
||||
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 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 }) => {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
130
ui/src/ui/app.ts
130
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<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);
|
||||
}
|
||||
|
||||
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: "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":
|
||||
|
||||
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