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>
This commit is contained in:
@@ -6,6 +6,13 @@ export type StudioSettingsFixture = {
|
||||
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 = {
|
||||
@@ -13,6 +20,7 @@ const DEFAULT_SETTINGS: StudioSettingsFixture = {
|
||||
gateway: null,
|
||||
focused: {},
|
||||
avatars: {},
|
||||
taskBoard: {},
|
||||
};
|
||||
|
||||
const createStudioRoute = (initial: StudioSettingsFixture = DEFAULT_SETTINGS) => {
|
||||
@@ -21,6 +29,7 @@ const createStudioRoute = (initial: StudioSettingsFixture = DEFAULT_SETTINGS) =>
|
||||
gateway: initial.gateway ?? null,
|
||||
focused: { ...(initial.focused ?? {}) },
|
||||
avatars: { ...(initial.avatars ?? {}) },
|
||||
taskBoard: { ...(initial.taskBoard ?? {}) },
|
||||
};
|
||||
|
||||
return async (route: Route, request: Request) => {
|
||||
@@ -97,6 +106,32 @@ const createStudioRoute = (initial: StudioSettingsFixture = DEFAULT_SETTINGS) =>
|
||||
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,
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { stubStudioRoute } from "./helpers/studioRoute";
|
||||
|
||||
test.skip(
|
||||
process.env.CLAW3D_E2E_GATEWAY !== "1",
|
||||
"Requires a reachable gateway-backed office shell."
|
||||
);
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await stubStudioRoute(page);
|
||||
});
|
||||
|
||||
test("creates and edits a kanban card from HQ", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
await page.getByRole("button", { name: "Open headquarters sidebar" }).click();
|
||||
await page.getByRole("tab", { name: "Kanban" }).click();
|
||||
await page.getByRole("button", { name: "New Task" }).click();
|
||||
|
||||
const titleInput = page.getByLabel("Title");
|
||||
await expect(titleInput).toHaveValue("New task");
|
||||
await titleInput.fill("Create marketing website");
|
||||
await page.getByLabel("Description").fill("Landing page for the spring campaign.");
|
||||
await page.getByLabel("Status").selectOption("in_progress");
|
||||
|
||||
await expect(page.getByText("Create marketing website")).toBeVisible();
|
||||
await expect(titleInput).toHaveValue("Create marketing website");
|
||||
});
|
||||
|
||||
test("persists kanban cards to studio settings", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
await page.getByRole("button", { name: "Open headquarters sidebar" }).click();
|
||||
await page.getByRole("tab", { name: "Kanban" }).click();
|
||||
await page.getByRole("button", { name: "New Task" }).click();
|
||||
await page.getByLabel("Title").fill("Persistent task card");
|
||||
|
||||
const request = await page.waitForRequest((req) => {
|
||||
if (!req.url().includes("/api/studio") || req.method() !== "PUT") {
|
||||
return false;
|
||||
}
|
||||
const payload = JSON.parse(req.postData() ?? "{}") as {
|
||||
taskBoard?: Record<string, { cards?: Array<{ title?: string }> }>;
|
||||
};
|
||||
const entries = Object.values(payload.taskBoard ?? {});
|
||||
return entries.some((entry) =>
|
||||
(entry.cards ?? []).some((card) => card.title === "Persistent task card")
|
||||
);
|
||||
});
|
||||
|
||||
const payload = JSON.parse(request.postData() ?? "{}") as {
|
||||
taskBoard?: Record<string, { cards?: Array<{ title?: string }> }>;
|
||||
};
|
||||
expect(
|
||||
Object.values(payload.taskBoard ?? {}).some((entry) =>
|
||||
(entry.cards ?? []).some((card) => card.title === "Persistent task card")
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
Reference in New Issue
Block a user