a997f13601
* feat(kanban): add Kanban board with task-manager skill, modal UI, and desk clutter Implement a full Kanban board system for tracking agent tasks: - Add task-manager skill with shared JSON task store for persistence - Render board as a floating modal over the live 3D office (not immersive) - Auto-create tasks from actionable user messages with heuristic filtering - Sync task status through OpenClaw agent lifecycle events - Collapse task details panel by default, expand on card click - Add dynamic desk clutter (papers, folders, etc.) reflecting active task count - Exclude done tasks from desk clutter count - Extract KANBAN_CLUTTER_OFFSET for easy positioning adjustment - Add install flow with progress bar for the task-manager skill - Include unit and e2e test coverage Made-with: Cursor * feat(kanban): production-harden task board with AI-free classification, resilient persistence, and modal UX - Harden shared task store with atomic writes, payload size limits, and server-side enum validation - Add client resilience: request timeouts (AbortController), exponential backoff retries, poll deduplication - Implement optimistic UI with rollback on all card mutations (update, move, archive) - Add modal accessibility: focus trap, Escape to close, aria-modal, keyboard card navigation - Trust OpenClaw agent lifecycle phase=start as task classification signal instead of regex heuristics - Keep regex heuristic only as lightweight filter for direct chat events (conversational noise) - Expand verb recognition with typo tolerance and broader action vocabulary - Create tasks from agent runs even when no chat event is received (external channel support) - Merge dual header bars into single bar; reposition close button outside modal corner - Exclude done tasks from desk clutter count; make clutter position configurable via KANBAN_CLUTTER_OFFSET - Update default furniture layout to match user configuration - Ensure kanban_board furniture persists in local storage across sessions - Add comprehensive test coverage for store, API route, and controller logic Made-with: Cursor --------- Co-authored-by: iamlukethedev <lucas.guilherme@smartwayslfl.com>
150 lines
4.7 KiB
TypeScript
150 lines
4.7 KiB
TypeScript
import type { Page, Route, Request } from "@playwright/test";
|
|
import type { AgentAvatarProfile } from "@/lib/avatars/profile";
|
|
|
|
export type StudioSettingsFixture = {
|
|
version: 1;
|
|
gateway: { url: string; token: string } | null;
|
|
focused: Record<string, { mode: "focused"; filter: string; selectedAgentId: string | null }>;
|
|
avatars: Record<string, Record<string, AgentAvatarProfile>>;
|
|
taskBoard?: Record<
|
|
string,
|
|
{
|
|
cards: Array<Record<string, unknown>>;
|
|
selectedCardId: string | null;
|
|
}
|
|
>;
|
|
};
|
|
|
|
const DEFAULT_SETTINGS: StudioSettingsFixture = {
|
|
version: 1,
|
|
gateway: null,
|
|
focused: {},
|
|
avatars: {},
|
|
taskBoard: {},
|
|
};
|
|
|
|
const createStudioRoute = (initial: StudioSettingsFixture = DEFAULT_SETTINGS) => {
|
|
let settings: StudioSettingsFixture = {
|
|
version: 1,
|
|
gateway: initial.gateway ?? null,
|
|
focused: { ...(initial.focused ?? {}) },
|
|
avatars: { ...(initial.avatars ?? {}) },
|
|
taskBoard: { ...(initial.taskBoard ?? {}) },
|
|
};
|
|
|
|
return async (route: Route, request: Request) => {
|
|
if (request.method() === "GET") {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({ settings }),
|
|
});
|
|
return;
|
|
}
|
|
if (request.method() !== "PUT") {
|
|
await route.fallback();
|
|
return;
|
|
}
|
|
|
|
const patch = JSON.parse(request.postData() ?? "{}") as Record<string, unknown>;
|
|
const next = { ...settings };
|
|
|
|
if ("gateway" in patch) {
|
|
next.gateway = (patch.gateway as StudioSettingsFixture["gateway"]) ?? null;
|
|
}
|
|
|
|
if (patch.focused && typeof patch.focused === "object") {
|
|
const focusedPatch = patch.focused as Record<string, Record<string, unknown>>;
|
|
const focusedNext = { ...next.focused };
|
|
for (const [key, value] of Object.entries(focusedPatch)) {
|
|
const existing = focusedNext[key] ?? {
|
|
mode: "focused" as const,
|
|
filter: "all",
|
|
selectedAgentId: null,
|
|
};
|
|
focusedNext[key] = {
|
|
mode: (value.mode as "focused") ?? existing.mode,
|
|
filter: (value.filter as string) ?? existing.filter,
|
|
selectedAgentId:
|
|
"selectedAgentId" in value
|
|
? ((value.selectedAgentId as string | null) ?? null)
|
|
: existing.selectedAgentId,
|
|
};
|
|
}
|
|
next.focused = focusedNext;
|
|
}
|
|
|
|
if (patch.avatars && typeof patch.avatars === "object") {
|
|
const avatarsPatch = patch.avatars as
|
|
| Record<string, Record<string, AgentAvatarProfile | null> | null>
|
|
| null;
|
|
const avatarsNext: StudioSettingsFixture["avatars"] = { ...next.avatars };
|
|
for (const [gatewayKey, gatewayPatch] of Object.entries(avatarsPatch ?? {})) {
|
|
if (gatewayPatch === null) {
|
|
delete avatarsNext[gatewayKey];
|
|
continue;
|
|
}
|
|
const existing = avatarsNext[gatewayKey] ? { ...avatarsNext[gatewayKey] } : {};
|
|
for (const [agentId, avatarPatch] of Object.entries(gatewayPatch)) {
|
|
if (avatarPatch === null) {
|
|
delete existing[agentId];
|
|
continue;
|
|
}
|
|
if (
|
|
typeof avatarPatch !== "object" ||
|
|
avatarPatch === null ||
|
|
typeof avatarPatch.seed !== "string" ||
|
|
avatarPatch.seed.trim().length === 0
|
|
) {
|
|
delete existing[agentId];
|
|
continue;
|
|
}
|
|
existing[agentId] = avatarPatch;
|
|
}
|
|
avatarsNext[gatewayKey] = existing;
|
|
}
|
|
next.avatars = avatarsNext;
|
|
}
|
|
|
|
if (patch.taskBoard && typeof patch.taskBoard === "object") {
|
|
const taskBoardPatch = patch.taskBoard as Record<
|
|
string,
|
|
{ cards?: Array<Record<string, unknown>>; selectedCardId?: string | null } | null
|
|
>;
|
|
const taskBoardNext = { ...(next.taskBoard ?? {}) };
|
|
for (const [gatewayKey, gatewayValue] of Object.entries(taskBoardPatch)) {
|
|
if (gatewayValue === null) {
|
|
delete taskBoardNext[gatewayKey];
|
|
continue;
|
|
}
|
|
const existing = taskBoardNext[gatewayKey] ?? {
|
|
cards: [],
|
|
selectedCardId: null,
|
|
};
|
|
taskBoardNext[gatewayKey] = {
|
|
cards: Array.isArray(gatewayValue.cards) ? gatewayValue.cards : existing.cards,
|
|
selectedCardId:
|
|
"selectedCardId" in gatewayValue
|
|
? (gatewayValue.selectedCardId ?? null)
|
|
: existing.selectedCardId,
|
|
};
|
|
}
|
|
next.taskBoard = taskBoardNext;
|
|
}
|
|
|
|
settings = next;
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({ settings }),
|
|
});
|
|
};
|
|
};
|
|
|
|
export const stubStudioRoute = async (
|
|
page: Page,
|
|
initial: StudioSettingsFixture = DEFAULT_SETTINGS
|
|
) => {
|
|
await page.route("**/api/studio", createStudioRoute(initial));
|
|
};
|