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>
242 lines
10 KiB
TypeScript
242 lines
10 KiB
TypeScript
import {
|
|
CANVAS_H,
|
|
CANVAS_W,
|
|
DOOR_LENGTH,
|
|
DOOR_THICKNESS,
|
|
MIN_WALL_LENGTH,
|
|
SCALE,
|
|
SNAP_GRID,
|
|
WALL_THICKNESS,
|
|
} from "@/features/retro-office/core/constants";
|
|
import type {
|
|
CanvasPoint,
|
|
FurnitureItem,
|
|
} from "@/features/retro-office/core/types";
|
|
|
|
export const toWorld = (cx: number, cy: number): [number, number, number] => [
|
|
cx * SCALE - CANVAS_W * SCALE * 0.5,
|
|
0,
|
|
cy * SCALE - CANVAS_H * SCALE * 0.5,
|
|
];
|
|
|
|
export const snap = (value: number) =>
|
|
Math.round(value / SNAP_GRID) * SNAP_GRID;
|
|
|
|
let uidCounter = 0;
|
|
|
|
export const nextUid = () => `fi_${Date.now()}_${uidCounter++}`;
|
|
|
|
export const normalizeDegrees = (value: number) => {
|
|
const normalized = value % 360;
|
|
return normalized < 0 ? normalized + 360 : normalized;
|
|
};
|
|
|
|
export const resolveItemTypeKey = (item: FurnitureItem) =>
|
|
item.type === "couch" && item.vertical ? "couch_v" : item.type;
|
|
|
|
export const ITEM_FOOTPRINT: Record<string, [number, number]> = {
|
|
wall: [80, WALL_THICKNESS],
|
|
door: [DOOR_LENGTH, DOOR_THICKNESS],
|
|
desk_cubicle: [100, 55],
|
|
chair: [24, 24],
|
|
round_table: [120, 120],
|
|
executive_desk: [130, 65],
|
|
couch: [100, 40],
|
|
couch_v: [40, 80],
|
|
bookshelf: [80, 120],
|
|
plant: [24, 24],
|
|
beanbag: [40, 40],
|
|
pingpong: [100, 60],
|
|
table_rect: [80, 40],
|
|
coffee_machine: [32, 34],
|
|
fridge: [40, 80],
|
|
water_cooler: [20, 54],
|
|
atm: [42, 38],
|
|
sms_booth: [58, 54],
|
|
phone_booth: [78, 72],
|
|
whiteboard: [10, 60],
|
|
cabinet: [200, 40],
|
|
computer: [30, 20],
|
|
lamp: [30, 30],
|
|
printer: [40, 35],
|
|
stove: [40, 40],
|
|
microwave: [30, 20],
|
|
wall_cabinet: [80, 20],
|
|
sink: [40, 40],
|
|
vending: [40, 60],
|
|
server_rack: [45, 90],
|
|
server_terminal: [42, 34],
|
|
qa_terminal: [54, 38],
|
|
kanban_board: [130, 65],
|
|
device_rack: [70, 36],
|
|
test_bench: [90, 42],
|
|
treadmill: [70, 35],
|
|
weight_bench: [90, 45],
|
|
dumbbell_rack: [80, 28],
|
|
exercise_bike: [45, 65],
|
|
punching_bag: [28, 28],
|
|
jukebox: [60, 40],
|
|
rowing_machine: [90, 34],
|
|
kettlebell_rack: [70, 26],
|
|
yoga_mat: [70, 30],
|
|
keyboard: [30, 14],
|
|
mouse: [16, 10],
|
|
trash: [20, 20],
|
|
mug: [14, 14],
|
|
clock: [20, 20],
|
|
};
|
|
|
|
export const getItemBaseSize = (item: FurnitureItem) => {
|
|
if (item.r !== undefined) {
|
|
return { width: item.r * 2, height: item.r * 2 };
|
|
}
|
|
const [defaultWidth, defaultHeight] = ITEM_FOOTPRINT[
|
|
resolveItemTypeKey(item)
|
|
] ?? [item.w ?? 40, item.h ?? 40];
|
|
return {
|
|
width: item.w ?? defaultWidth,
|
|
height: item.h ?? defaultHeight,
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Per-type metadata for furniture items.
|
|
*
|
|
* blocksNavigation: true → solid floor-standing prop; marks grid cells as impassable.
|
|
* blocksNavigation: false → desk decoration, wall-mounted, elevated, or passable item.
|
|
*
|
|
* This is the single source of truth for nav-blocking behaviour. `buildNavGrid` in
|
|
* navigation.ts reads this instead of maintaining its own hardcoded type set.
|
|
*/
|
|
export const ITEM_METADATA: Record<string, { blocksNavigation: boolean; navPadding?: number }> = {
|
|
// ── structural ────────────────────────────────────────────────────────────
|
|
wall: { blocksNavigation: true },
|
|
door: { blocksNavigation: false }, // passable
|
|
// ── seating / lounge ──────────────────────────────────────────────────────
|
|
chair: { blocksNavigation: false }, // passable / agents sit on them
|
|
couch: { blocksNavigation: true },
|
|
couch_v: { blocksNavigation: true },
|
|
beanbag: { blocksNavigation: true }, // large floor seat (issue #4)
|
|
// ── desks / workstations ──────────────────────────────────────────────────
|
|
desk_cubicle: { blocksNavigation: true, navPadding: 0 }, // blocks nav with zero padding (tight to desk body)
|
|
executive_desk: { blocksNavigation: true },
|
|
// ── tables ────────────────────────────────────────────────────────────────
|
|
round_table: { blocksNavigation: true },
|
|
table_rect: { blocksNavigation: true },
|
|
pingpong: { blocksNavigation: true },
|
|
// ── storage / shelving ────────────────────────────────────────────────────
|
|
bookshelf: { blocksNavigation: true },
|
|
cabinet: { blocksNavigation: true },
|
|
wall_cabinet: { blocksNavigation: false }, // wall-mounted; agents walk under
|
|
// ── kitchen appliances ────────────────────────────────────────────────────
|
|
fridge: { blocksNavigation: true },
|
|
stove: { blocksNavigation: true },
|
|
microwave: { blocksNavigation: false }, // counter-top / elevated
|
|
dishwasher: { blocksNavigation: true }, // floor appliance (issue #4)
|
|
sink: { blocksNavigation: true },
|
|
coffee_machine: { blocksNavigation: false }, // elevated on counter
|
|
// ── office equipment ──────────────────────────────────────────────────────
|
|
printer: { blocksNavigation: true },
|
|
vending: { blocksNavigation: true },
|
|
atm: { blocksNavigation: true },
|
|
whiteboard: { blocksNavigation: true },
|
|
computer: { blocksNavigation: false }, // desk item
|
|
keyboard: { blocksNavigation: false }, // desk decoration
|
|
mouse: { blocksNavigation: false }, // desk decoration
|
|
// ── server room ───────────────────────────────────────────────────────────
|
|
server_rack: { blocksNavigation: true },
|
|
server_terminal: { blocksNavigation: true }, // floor-standing terminal (issue #4)
|
|
sms_booth: { blocksNavigation: true },
|
|
phone_booth: { blocksNavigation: true },
|
|
// ── QA lab ────────────────────────────────────────────────────────────────
|
|
qa_terminal: { blocksNavigation: true },
|
|
kanban_board: { blocksNavigation: true },
|
|
device_rack: { blocksNavigation: true },
|
|
test_bench: { blocksNavigation: true },
|
|
// ── gym ───────────────────────────────────────────────────────────────────
|
|
treadmill: { blocksNavigation: true },
|
|
weight_bench: { blocksNavigation: true },
|
|
dumbbell_rack: { blocksNavigation: true },
|
|
exercise_bike: { blocksNavigation: true },
|
|
punching_bag: { blocksNavigation: true },
|
|
jukebox: { blocksNavigation: true },
|
|
rowing_machine: { blocksNavigation: true },
|
|
kettlebell_rack: { blocksNavigation: true },
|
|
yoga_mat: { blocksNavigation: true },
|
|
// ── art room ──────────────────────────────────────────────────────────────
|
|
easel: { blocksNavigation: true }, // floor-standing prop (issue #4)
|
|
// ── water cooler ──────────────────────────────────────────────────────────
|
|
water_cooler: { blocksNavigation: true }, // freestanding floor appliance (issue #4)
|
|
// ── decorative / small ────────────────────────────────────────────────────
|
|
plant: { blocksNavigation: true },
|
|
lamp: { blocksNavigation: false }, // floor lamp but thin; passable in practice
|
|
trash: { blocksNavigation: false }, // small bin
|
|
clock: { blocksNavigation: false }, // wall-mounted
|
|
mug: { blocksNavigation: false }, // desk item
|
|
};
|
|
|
|
export const FURNITURE_ROTATION: Record<string, number> = {
|
|
couch: Math.PI,
|
|
couch_v: Math.PI / 2,
|
|
executive_desk: -Math.PI / 2,
|
|
whiteboard: Math.PI / 2,
|
|
};
|
|
|
|
export const getItemRotationRadians = (item: FurnitureItem) =>
|
|
((item.facing ?? 0) * Math.PI) / 180 +
|
|
(FURNITURE_ROTATION[resolveItemTypeKey(item)] ?? 0);
|
|
|
|
export const getItemBounds = (item: FurnitureItem) => {
|
|
const { width, height } = getItemBaseSize(item);
|
|
const rotation = getItemRotationRadians(item);
|
|
const absCos = Math.abs(Math.cos(rotation));
|
|
const absSin = Math.abs(Math.sin(rotation));
|
|
const boundsWidth = width * absCos + height * absSin;
|
|
const boundsHeight = width * absSin + height * absCos;
|
|
const centerX = item.x + width / 2;
|
|
const centerY = item.y + height / 2;
|
|
return {
|
|
x: centerX - boundsWidth / 2,
|
|
y: centerY - boundsHeight / 2,
|
|
w: boundsWidth,
|
|
h: boundsHeight,
|
|
width,
|
|
height,
|
|
};
|
|
};
|
|
|
|
export const createWallItem = (
|
|
start: CanvasPoint,
|
|
end: CanvasPoint,
|
|
uid: string,
|
|
): FurnitureItem => {
|
|
const dx = end.x - start.x;
|
|
const dy = end.y - start.y;
|
|
const horizontal = Math.abs(dx) >= Math.abs(dy);
|
|
if (horizontal) {
|
|
const minX = Math.min(start.x, end.x);
|
|
const maxX = Math.max(start.x, end.x);
|
|
return {
|
|
_uid: uid,
|
|
type: "wall",
|
|
x: snap(minX),
|
|
y: snap(start.y) - WALL_THICKNESS / 2,
|
|
w: Math.max(MIN_WALL_LENGTH, snap(maxX - minX) + WALL_THICKNESS),
|
|
h: WALL_THICKNESS,
|
|
facing: 0,
|
|
};
|
|
}
|
|
|
|
const minY = Math.min(start.y, end.y);
|
|
const maxY = Math.max(start.y, end.y);
|
|
return {
|
|
_uid: uid,
|
|
type: "wall",
|
|
x: snap(start.x) - WALL_THICKNESS / 2,
|
|
y: snap(minY),
|
|
w: WALL_THICKNESS,
|
|
h: Math.max(MIN_WALL_LENGTH, snap(maxY - minY) + WALL_THICKNESS),
|
|
facing: 0,
|
|
};
|
|
};
|