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:
Luke The Dev
2026-03-30 22:58:18 -05:00
committed by GitHub
parent 464a49bb6d
commit a997f13601
46 changed files with 5950 additions and 143 deletions
+356 -6
View File
@@ -31,6 +31,7 @@ export const FURNITURE_GLB: Record<string, string> = {
fridge: "/office-assets/models/furniture/kitchenFridgeSmall.glb",
water_cooler: "/office-assets/models/furniture/plantSmall1.glb",
whiteboard: "/office-assets/models/furniture/bookcaseClosed.glb",
kanban_board: "/office-assets/models/furniture/deskCorner.glb",
cabinet: "/office-assets/models/furniture/kitchenCabinet.glb",
computer: "/office-assets/models/furniture/computerScreen.glb",
lamp: "/office-assets/models/furniture/lampRoundFloor.glb",
@@ -53,6 +54,7 @@ export const FURNITURE_SCALE: Record<string, [number, number, number]> = {
fridge: [1, 1.4, 1],
water_cooler: [1, 2, 1],
whiteboard: [0.6, 1.4, 0.3],
kanban_board: [1.8, 1.8, 1.8],
cabinet: [2.6, 1.2, 1],
computer: [1.1, 1.1, 1.1],
lamp: [1.2, 1.2, 1.2],
@@ -63,6 +65,9 @@ export const FURNITURE_Y_OFFSET: Record<string, number> = {
computer: 0.61,
};
/** Global offset for all kanban desk clutter (papers, monitor, mug, etc.). */
export const KANBAN_CLUTTER_OFFSET = { x: -1, y: 1, z: -2 };
export const FURNITURE_TINT: Record<string, string | null> = {
desk_cubicle: "#8b5e32",
executive_desk: "#6b3c1a",
@@ -79,6 +84,7 @@ export const FURNITURE_TINT: Record<string, string | null> = {
fridge: "#505a60",
water_cooler: "#3a5070",
whiteboard: "#f4f2ee",
kanban_board: "#8b5e32",
cabinet: "#3c4248",
plant: null,
lamp: "#c8a060",
@@ -144,7 +150,9 @@ const resolveFurnitureTemplate = (params: {
};
return nextMaterial;
});
mesh.material = Array.isArray(mesh.material) ? templateMats : templateMats[0];
mesh.material = Array.isArray(mesh.material)
? templateMats
: templateMats[0];
});
furnitureTemplateCache.set(cacheKey, template);
@@ -163,8 +171,16 @@ const buildFurnitureItemMatrix = (item: FurnitureItem, itemType: string) => {
const containerMatrix = new THREE.Matrix4().makeTranslation(wx, yOffset, wz);
const pivotMatrix = new THREE.Matrix4().makeTranslation(pivotX, 0, pivotZ);
const rotationMatrix = new THREE.Matrix4().makeRotationY(rotY);
const unpivotMatrix = new THREE.Matrix4().makeTranslation(-pivotX, 0, -pivotZ);
const scaleMatrix = new THREE.Matrix4().makeScale(scale[0], scale[1], scale[2]);
const unpivotMatrix = new THREE.Matrix4().makeTranslation(
-pivotX,
0,
-pivotZ,
);
const scaleMatrix = new THREE.Matrix4().makeScale(
scale[0],
scale[1],
scale[2],
);
return containerMatrix
.multiply(pivotMatrix)
@@ -274,6 +290,7 @@ export function FurnitureModel({
isSelected,
isHovered,
editMode,
kanbanTaskCount = 0,
onPointerDown,
onPointerOver,
onPointerOut,
@@ -300,18 +317,159 @@ export function FurnitureModel({
const { width, height } = getItemBaseSize(item);
const pivotX = width * SCALE * 0.5;
const pivotZ = height * SCALE * 0.5;
const kanbanDeskLoadout = useMemo(() => {
const visibleTaskCount = Math.max(0, Math.min(kanbanTaskCount, 12));
if (visibleTaskCount === 0) {
return {
papers: [] as Array<{
x: number;
y: number;
z: number;
w: number;
h: number;
r: number;
color: string;
}>,
folders: [] as Array<{
x: number;
y: number;
z: number;
w: number;
h: number;
d: number;
color: string;
r: number;
}>,
stickyNotes: [] as Array<{
x: number;
y: number;
z: number;
color: string;
r: number;
}>,
binders: [] as Array<{
x: number;
y: number;
z: number;
w: number;
h: number;
d: number;
color: string;
r: number;
}>,
};
}
const cx = KANBAN_CLUTTER_OFFSET.x;
const cy = KANBAN_CLUTTER_OFFSET.y;
const cz = KANBAN_CLUTTER_OFFSET.z;
const papers = Array.from(
{ length: Math.min(visibleTaskCount + 2, 14) },
(_, index) => {
const row = index % 4;
const stack = Math.floor(index / 4);
return {
x: cx + -0.22 + row * 0.16 + (stack % 2) * 0.03,
z: cz + 0.06 - stack * 0.12 + (row % 2) * 0.02,
y: cy + stack * 0.007 + index * 0.0015,
w: 0.17 + (index % 3) * 0.02,
h: 0.12 + ((index + 1) % 2) * 0.02,
r: -0.2 + row * 0.08 + stack * 0.03,
color: ["#fff7df", "#f6edd2", "#efe4c7", "#fffaf0"][index % 4]!,
};
},
);
const folders = [
{
x: cx + 0.28,
y: cy + 0.013,
z: cz + 0.0,
w: 0.24,
h: 0.17,
d: 0.035,
color: "#d6a447",
r: 0.16,
},
...(visibleTaskCount >= 5
? [
{
x: cx + 0.06,
y: cy + 0.018,
z: cz + 0.14,
w: 0.22,
h: 0.16,
d: 0.04,
color: "#9d5f3f",
r: -0.08,
},
]
: []),
];
const stickyNotes = Array.from(
{ length: Math.min(2 + Math.floor(visibleTaskCount / 3), 5) },
(_, index) => ({
x: cx + -0.1 + index * 0.08,
y: cy + 0.012 + index * 0.002,
z: cz + -0.14 - (index % 2) * 0.04,
color: ["#f7db5e", "#ffb35c", "#97d7f6", "#c0e56e", "#ff8fa3"][
index % 5
]!,
r: -0.15 + index * 0.08,
}),
);
const binders =
visibleTaskCount >= 7
? [
{
x: cx + -0.24,
y: cy + 0.04,
z: cz + -0.06,
w: 0.12,
h: 0.12,
d: 0.18,
color: "#5d7bb0",
r: -0.08,
},
{
x: cx + -0.14,
y: cy + 0.047,
z: cz + -0.1,
w: 0.12,
h: 0.13,
d: 0.19,
color: "#6f8b3d",
r: 0.03,
},
]
: [];
return {
papers,
folders,
stickyNotes,
binders,
};
}, [kanbanTaskCount]);
useEffect(() => {
const highlightActive = isSelected || (isHovered && editMode);
cloned.traverse((child) => {
if (!(child as THREE.Mesh).isMesh) return;
const mesh = child as THREE.Mesh;
const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
const mats = Array.isArray(mesh.material)
? mesh.material
: [mesh.material];
const nextMats = mats.map((material) => {
if (!(material instanceof THREE.MeshStandardMaterial)) {
return material;
}
const hasOwnMaterial = Boolean(material.userData?.furnitureInstanceMaterial);
const hasOwnMaterial = Boolean(
material.userData?.furnitureInstanceMaterial,
);
let nextMaterial = material;
if (highlightActive && !hasOwnMaterial) {
nextMaterial = material.clone();
@@ -363,6 +521,196 @@ export function FurnitureModel({
<group position={[-pivotX, 0, -pivotZ]} scale={scale}>
<primitive object={cloned} />
</group>
{itemType === "kanban_board" ? (
<>
{kanbanTaskCount > 0 ? (
<>
{/* Monitor. */}
<mesh
position={[
KANBAN_CLUTTER_OFFSET.x + 0.02,
KANBAN_CLUTTER_OFFSET.y + 0.1,
KANBAN_CLUTTER_OFFSET.z + -0.16,
]}
rotation={[0, -0.28, 0]}
castShadow
receiveShadow
>
<boxGeometry args={[0.22, 0.16, 0.03]} />
<meshStandardMaterial
color="#30374a"
roughness={0.48}
metalness={0.18}
/>
</mesh>
{/* Keyboard. */}
<mesh
position={[
KANBAN_CLUTTER_OFFSET.x + 0.02,
KANBAN_CLUTTER_OFFSET.y + 0.01,
KANBAN_CLUTTER_OFFSET.z + -0.03,
]}
rotation={[-Math.PI / 2, -0.1, 0]}
castShadow
>
<boxGeometry args={[0.22, 0.018, 0.09]} />
<meshStandardMaterial
color="#d8dce4"
roughness={0.82}
metalness={0.08}
/>
</mesh>
{/* Mug. */}
<mesh
position={[
KANBAN_CLUTTER_OFFSET.x + 0.24,
KANBAN_CLUTTER_OFFSET.y + 0.03,
KANBAN_CLUTTER_OFFSET.z + -0.17,
]}
rotation={[-Math.PI / 2, 0.14, 0]}
castShadow
>
<cylinderGeometry args={[0.04, 0.04, 0.09, 18]} />
<meshStandardMaterial
color="#2d4f73"
roughness={0.68}
metalness={0.12}
/>
</mesh>
{/* Book stack. */}
<mesh
position={[
KANBAN_CLUTTER_OFFSET.x + 0.34,
KANBAN_CLUTTER_OFFSET.y + 0.04,
KANBAN_CLUTTER_OFFSET.z + -0.06,
]}
rotation={[0, 0.2, 0]}
castShadow
receiveShadow
>
<boxGeometry args={[0.17, 0.05, 0.24]} />
<meshStandardMaterial
color="#bcc5d0"
roughness={0.78}
metalness={0.12}
/>
</mesh>
<mesh
position={[
KANBAN_CLUTTER_OFFSET.x + 0.34,
KANBAN_CLUTTER_OFFSET.y + 0.07,
KANBAN_CLUTTER_OFFSET.z + -0.06,
]}
rotation={[0, 0.2, 0]}
castShadow
>
<boxGeometry args={[0.17, 0.012, 0.24]} />
<meshStandardMaterial
color="#eef2f4"
roughness={0.92}
metalness={0.03}
/>
</mesh>
<mesh
position={[
KANBAN_CLUTTER_OFFSET.x + 0.34,
KANBAN_CLUTTER_OFFSET.y + 0.095,
KANBAN_CLUTTER_OFFSET.z + -0.06,
]}
rotation={[0, 0.2, 0]}
castShadow
receiveShadow
>
<boxGeometry args={[0.17, 0.05, 0.24]} />
<meshStandardMaterial
color="#cbd3db"
roughness={0.8}
metalness={0.1}
/>
</mesh>
<mesh
position={[
KANBAN_CLUTTER_OFFSET.x + 0.34,
KANBAN_CLUTTER_OFFSET.y + 0.125,
KANBAN_CLUTTER_OFFSET.z + -0.06,
]}
rotation={[0, 0.2, 0]}
castShadow
>
<boxGeometry args={[0.17, 0.012, 0.24]} />
<meshStandardMaterial
color="#fffdf7"
roughness={0.94}
metalness={0.02}
/>
</mesh>
</>
) : null}
{kanbanDeskLoadout.papers.map((paper, index) => (
<mesh
key={`kanban-paper-${index}`}
position={[paper.x, paper.y, paper.z]}
rotation={[-Math.PI / 2, paper.r, 0]}
castShadow
receiveShadow
>
<boxGeometry args={[paper.w, 0.018, paper.h]} />
<meshStandardMaterial
color={paper.color}
roughness={0.94}
metalness={0.02}
/>
</mesh>
))}
{kanbanDeskLoadout.folders.map((folder, index) => (
<mesh
key={`kanban-folder-${index}`}
position={[folder.x, folder.y, folder.z]}
rotation={[-Math.PI / 2, folder.r, 0]}
castShadow
receiveShadow
>
<boxGeometry args={[folder.w, folder.d, folder.h]} />
<meshStandardMaterial
color={folder.color}
roughness={0.84}
metalness={0.06}
/>
</mesh>
))}
{kanbanDeskLoadout.stickyNotes.map((note, index) => (
<mesh
key={`kanban-sticky-${index}`}
position={[note.x, note.y, note.z]}
rotation={[-Math.PI / 2, note.r, 0]}
castShadow
>
<boxGeometry args={[0.075, 0.014, 0.075]} />
<meshStandardMaterial
color={note.color}
roughness={0.95}
metalness={0.01}
/>
</mesh>
))}
{kanbanDeskLoadout.binders.map((binder, index) => (
<mesh
key={`kanban-binder-${index}`}
position={[binder.x, binder.y, binder.z]}
rotation={[0, binder.r, 0]}
castShadow
receiveShadow
>
<boxGeometry args={[binder.w, binder.h, binder.d]} />
<meshStandardMaterial
color={binder.color}
roughness={0.74}
metalness={0.08}
/>
</mesh>
))}
</>
) : null}
</group>
</group>
);
@@ -402,4 +750,6 @@ export function PlacementGhost({
);
}
[...new Set(Object.values(FURNITURE_GLB))].forEach((path) => useGLTF.preload(path));
[...new Set(Object.values(FURNITURE_GLB))].forEach((path) =>
useGLTF.preload(path),
);