Files
claw3d/tests/e2e/helpers/studioRoute.ts
T
Luke The Dev a997f13601 feat(kanban): Interactive Kanban board with real-time task tracking (#83)
* 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>
2026-03-30 22:58:18 -05:00

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));
};