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:
@@ -0,0 +1,142 @@
|
||||
---
|
||||
name: task-manager
|
||||
description: Capture actionable user requests as persistent tasks, update task status as work progresses, and keep a shared task store in sync. Use when a user asks an agent to do work, check progress, block a task, complete a task, or manage the Kanban board.
|
||||
metadata: {"openclaw":{"skillKey":"task-manager"}}
|
||||
---
|
||||
|
||||
# Task Manager
|
||||
|
||||
Use this skill for task capture and task lifecycle updates.
|
||||
|
||||
## Trigger
|
||||
|
||||
```json
|
||||
{
|
||||
"activation": {
|
||||
"anyPhrases": [
|
||||
"add a task",
|
||||
"create a task",
|
||||
"track this task",
|
||||
"task status",
|
||||
"mark this done",
|
||||
"block this task",
|
||||
"what tasks do we have"
|
||||
]
|
||||
},
|
||||
"movement": {
|
||||
"target": "desk",
|
||||
"skipIfAlreadyThere": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Also use this skill even when those exact phrases are absent if the latest user message is an actionable work request. If the user asks the agent to do something, that request must become a task before the agent proceeds.
|
||||
|
||||
## Storage location
|
||||
|
||||
The authoritative task file is:
|
||||
|
||||
- `${OPENCLAW_STATE_DIR}/claw3d/task-manager/tasks.json` when `OPENCLAW_STATE_DIR` is set.
|
||||
- `~/.openclaw/claw3d/task-manager/tasks.json` otherwise.
|
||||
|
||||
Always treat that file as the shared source of truth for the Kanban board.
|
||||
|
||||
## Required workflow
|
||||
|
||||
1. Read the task file before handling an actionable request.
|
||||
2. If the file does not exist, create it with the schema in this document.
|
||||
3. If the latest user message is actionable and no matching active task exists, create one immediately.
|
||||
4. Before starting execution, ensure the task is `todo` or move it to `in_progress`.
|
||||
5. If work cannot continue, set the task to `blocked` and record a short reason in `notes`.
|
||||
6. When work is finished, set the task to `done`.
|
||||
7. When work needs user review or confirmation, set the task to `review`.
|
||||
8. After every mutation, write the full updated JSON back to disk.
|
||||
|
||||
## Matching rules
|
||||
|
||||
- Match first by `externalThreadId` when the request comes from a stable thread or conversation.
|
||||
- Otherwise match by a concise normalized title that preserves user intent.
|
||||
- Avoid creating duplicate active tasks for the same request.
|
||||
|
||||
## Task fields
|
||||
|
||||
Each task must include:
|
||||
|
||||
- `id`
|
||||
- `title`
|
||||
- `description`
|
||||
- `status`
|
||||
- `source`
|
||||
- `sourceEventId`
|
||||
- `assignedAgentId`
|
||||
- `createdAt`
|
||||
- `updatedAt`
|
||||
- `playbookJobId`
|
||||
- `runId`
|
||||
- `channel`
|
||||
- `externalThreadId`
|
||||
- `lastActivityAt`
|
||||
- `notes`
|
||||
- `isArchived`
|
||||
- `isInferred`
|
||||
- `history`
|
||||
|
||||
## Status rules
|
||||
|
||||
- New actionable requests start as `todo` unless work has already begun.
|
||||
- Move to `in_progress` when the agent is actively working.
|
||||
- Move to `blocked` when progress depends on missing input, credentials, approvals, or failures.
|
||||
- Move to `review` when the work is ready for inspection or handoff.
|
||||
- Move to `done` only when the requested work is complete.
|
||||
|
||||
## File format
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"updatedAt": "2026-03-30T00:00:00.000Z",
|
||||
"tasks": [
|
||||
{
|
||||
"id": "research-mtulsa-com",
|
||||
"title": "Research mtulsa.com",
|
||||
"description": "Review mtulsa.com and summarize the site, positioning, and improvement opportunities.",
|
||||
"status": "in_progress",
|
||||
"source": "claw3d_manual",
|
||||
"sourceEventId": null,
|
||||
"assignedAgentId": "main",
|
||||
"createdAt": "2026-03-30T00:00:00.000Z",
|
||||
"updatedAt": "2026-03-30T00:10:00.000Z",
|
||||
"playbookJobId": null,
|
||||
"runId": null,
|
||||
"channel": "telegram",
|
||||
"externalThreadId": "telegram:direct:6866695577",
|
||||
"lastActivityAt": "2026-03-30T00:10:00.000Z",
|
||||
"notes": [],
|
||||
"isArchived": false,
|
||||
"isInferred": false,
|
||||
"history": [
|
||||
{
|
||||
"at": "2026-03-30T00:00:00.000Z",
|
||||
"type": "created",
|
||||
"note": "Task created.",
|
||||
"fromStatus": null,
|
||||
"toStatus": "todo"
|
||||
},
|
||||
{
|
||||
"at": "2026-03-30T00:10:00.000Z",
|
||||
"type": "status_changed",
|
||||
"note": null,
|
||||
"fromStatus": "todo",
|
||||
"toStatus": "in_progress"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Response rules
|
||||
|
||||
- Briefly confirm which task was created or updated.
|
||||
- If the request is ambiguous, ask a clarifying question instead of guessing.
|
||||
- Do not claim work is complete without updating the task status.
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"updatedAt": "2026-03-30T00:10:00.000Z",
|
||||
"tasks": [
|
||||
{
|
||||
"id": "research-mtulsa-com",
|
||||
"title": "Research mtulsa.com",
|
||||
"description": "Review mtulsa.com and summarize the site, positioning, and improvement opportunities.",
|
||||
"status": "in_progress",
|
||||
"source": "claw3d_manual",
|
||||
"sourceEventId": null,
|
||||
"assignedAgentId": "main",
|
||||
"createdAt": "2026-03-30T00:00:00.000Z",
|
||||
"updatedAt": "2026-03-30T00:10:00.000Z",
|
||||
"playbookJobId": null,
|
||||
"runId": null,
|
||||
"channel": "telegram",
|
||||
"externalThreadId": "telegram:direct:6866695577",
|
||||
"lastActivityAt": "2026-03-30T00:10:00.000Z",
|
||||
"notes": [],
|
||||
"isArchived": false,
|
||||
"isInferred": false,
|
||||
"history": [
|
||||
{
|
||||
"at": "2026-03-30T00:00:00.000Z",
|
||||
"type": "created",
|
||||
"note": "Task created.",
|
||||
"fromStatus": null,
|
||||
"toStatus": "todo"
|
||||
},
|
||||
{
|
||||
"at": "2026-03-30T00:10:00.000Z",
|
||||
"type": "status_changed",
|
||||
"note": null,
|
||||
"fromStatus": "todo",
|
||||
"toStatus": "in_progress"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { defineConfig } from "@playwright/test";
|
||||
import path from "node:path";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests/e2e",
|
||||
use: {
|
||||
baseURL: "http://127.0.0.1:3100",
|
||||
},
|
||||
webServer: {
|
||||
command: "PORT=3100 npm run dev",
|
||||
port: 3100,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCLAW_STATE_DIR: path.resolve("./tests/fixtures/openclaw-empty-state"),
|
||||
NEXT_PUBLIC_GATEWAY_URL: "",
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { isTaskBoardSource, isTaskBoardStatus } from "@/features/office/tasks/types";
|
||||
import { archiveSharedTask, listSharedTasks, upsertSharedTask } from "@/lib/tasks/shared-store";
|
||||
|
||||
const json = (body: unknown, status = 200) =>
|
||||
Response.json(body, {
|
||||
status,
|
||||
headers: { "cache-control": "no-store" },
|
||||
});
|
||||
|
||||
const errorJson = (message: string, status: number) =>
|
||||
json({ error: message }, status);
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
return json({ tasks: listSharedTasks() });
|
||||
} catch (error) {
|
||||
console.error("[task-store] GET failed:", error);
|
||||
return errorJson("Internal error reading task store.", 500);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return errorJson("Invalid JSON payload.", 400);
|
||||
}
|
||||
if (!isRecord(body) || !isRecord(body.task)) {
|
||||
return errorJson("Task payload is required.", 400);
|
||||
}
|
||||
const task = body.task;
|
||||
const id = typeof task.id === "string" ? task.id.trim() : "";
|
||||
const title = typeof task.title === "string" ? task.title.trim() : "";
|
||||
if (!id || !title) {
|
||||
return errorJson("Task id and title are required.", 400);
|
||||
}
|
||||
if (task.status !== undefined && !isTaskBoardStatus(task.status)) {
|
||||
return errorJson(`Invalid status: "${String(task.status)}".`, 400);
|
||||
}
|
||||
if (task.source !== undefined && !isTaskBoardSource(task.source)) {
|
||||
return errorJson(`Invalid source: "${String(task.source)}".`, 400);
|
||||
}
|
||||
try {
|
||||
return json({
|
||||
task: upsertSharedTask({ ...task, id, title }),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[task-store] PUT failed:", error);
|
||||
return errorJson("Internal error writing task store.", 500);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return errorJson("Invalid JSON payload.", 400);
|
||||
}
|
||||
if (!isRecord(body)) {
|
||||
return errorJson("Task id is required.", 400);
|
||||
}
|
||||
const taskId = typeof body.id === "string" ? body.id.trim() : "";
|
||||
if (!taskId) {
|
||||
return errorJson("Task id is required.", 400);
|
||||
}
|
||||
try {
|
||||
const task = archiveSharedTask(taskId);
|
||||
if (!task) {
|
||||
return errorJson("Task not found.", 404);
|
||||
}
|
||||
return json({ task });
|
||||
} catch (error) {
|
||||
console.error("[task-store] DELETE failed:", error);
|
||||
return errorJson("Internal error archiving task.", 500);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
import Image from "next/image";
|
||||
import { useMemo } from "react";
|
||||
import type { AgentAvatarProfile } from "@/lib/avatars/profile";
|
||||
|
||||
import { buildAvatarDataUrl } from "@/lib/avatars/multiavatar";
|
||||
import { buildAgentAvatarPortraitDataUrl } from "@/lib/avatars/profilePortrait";
|
||||
|
||||
type AgentAvatarProps = {
|
||||
seed: string;
|
||||
name: string;
|
||||
avatarProfile?: AgentAvatarProfile | null;
|
||||
avatarUrl?: string | null;
|
||||
size?: number;
|
||||
isSelected?: boolean;
|
||||
@@ -14,15 +17,17 @@ type AgentAvatarProps = {
|
||||
export const AgentAvatar = ({
|
||||
seed,
|
||||
name,
|
||||
avatarProfile,
|
||||
avatarUrl,
|
||||
size = 112,
|
||||
isSelected = false,
|
||||
}: AgentAvatarProps) => {
|
||||
const src = useMemo(() => {
|
||||
if (avatarProfile) return buildAgentAvatarPortraitDataUrl(avatarProfile);
|
||||
const trimmed = avatarUrl?.trim();
|
||||
if (trimmed) return trimmed;
|
||||
return buildAvatarDataUrl(seed);
|
||||
}, [avatarUrl, seed]);
|
||||
}, [avatarProfile, avatarUrl, seed]);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -13,8 +13,9 @@ import {
|
||||
import type { AgentState as AgentRecord } from "@/features/agents/state/store";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { Check, ChevronRight, Clock, Cog, Mic, Pencil, Square, Trash2, X } from "lucide-react";
|
||||
import { Check, ChevronRight, Clock, Mic, Pencil, Square, Trash2, X } from "lucide-react";
|
||||
import type { GatewayModelChoice } from "@/lib/gateway/models";
|
||||
import type { AgentAvatarProfile } from "@/lib/avatars/profile";
|
||||
import { rewriteMediaLinesToMarkdown } from "@/lib/text/media-markdown";
|
||||
import { normalizeAssistantDisplayText } from "@/lib/text/assistantText";
|
||||
import { isNearBottom } from "@/lib/dom";
|
||||
@@ -52,6 +53,10 @@ const ASSISTANT_GUTTER_CLASS = "pl-[44px]";
|
||||
const ASSISTANT_MAX_WIDTH_DEFAULT_CLASS = "max-w-[68ch]";
|
||||
const ASSISTANT_MAX_WIDTH_EXPANDED_CLASS = "max-w-[1120px]";
|
||||
const CHAT_TOP_THRESHOLD_PX = 8;
|
||||
const CHAT_SELECT_STYLE = {
|
||||
backgroundColor: "#17120a",
|
||||
color: "#ffffff",
|
||||
} as const;
|
||||
const EMPTY_CHAT_INTRO_MESSAGES = [
|
||||
"How can I help you today?",
|
||||
"What should we accomplish today?",
|
||||
@@ -120,7 +125,6 @@ type AgentChatPanelProps = {
|
||||
stopBusy: boolean;
|
||||
stopDisabledReason?: string | null;
|
||||
onLoadMoreHistory: () => void;
|
||||
onOpenSettings: () => void;
|
||||
onRename?: (name: string) => Promise<boolean>;
|
||||
onNewSession?: () => Promise<void> | void;
|
||||
onModelChange: (value: string | null) => void;
|
||||
@@ -361,6 +365,7 @@ const UserMessageCard = memo(function UserMessageCard({
|
||||
|
||||
const AssistantMessageCard = memo(function AssistantMessageCard({
|
||||
avatarSeed,
|
||||
avatarProfile,
|
||||
avatarUrl,
|
||||
name,
|
||||
timestampMs,
|
||||
@@ -372,6 +377,7 @@ const AssistantMessageCard = memo(function AssistantMessageCard({
|
||||
streaming,
|
||||
}: {
|
||||
avatarSeed: string;
|
||||
avatarProfile?: AgentAvatarProfile | null;
|
||||
avatarUrl: string | null;
|
||||
name: string;
|
||||
timestampMs?: number;
|
||||
@@ -398,7 +404,13 @@ const AssistantMessageCard = memo(function AssistantMessageCard({
|
||||
<div className="w-full self-start">
|
||||
<div className={`relative w-full ${widthClass} ${ASSISTANT_GUTTER_CLASS}`}>
|
||||
<div className="absolute left-[4px] top-[2px]">
|
||||
<AgentAvatar seed={avatarSeed} name={name} avatarUrl={avatarUrl} size={22} />
|
||||
<AgentAvatar
|
||||
seed={avatarSeed}
|
||||
name={name}
|
||||
avatarProfile={avatarProfile}
|
||||
avatarUrl={avatarUrl}
|
||||
size={22}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3 py-0.5">
|
||||
<div className="type-meta min-w-0 truncate font-mono text-foreground/90">
|
||||
@@ -500,11 +512,13 @@ const AssistantMessageCard = memo(function AssistantMessageCard({
|
||||
|
||||
const AssistantIntroCard = memo(function AssistantIntroCard({
|
||||
avatarSeed,
|
||||
avatarProfile,
|
||||
avatarUrl,
|
||||
name,
|
||||
title,
|
||||
}: {
|
||||
avatarSeed: string;
|
||||
avatarProfile?: AgentAvatarProfile | null;
|
||||
avatarUrl: string | null;
|
||||
name: string;
|
||||
title: string;
|
||||
@@ -513,7 +527,13 @@ const AssistantIntroCard = memo(function AssistantIntroCard({
|
||||
<div className="w-full self-start">
|
||||
<div className={`relative w-full ${ASSISTANT_MAX_WIDTH_DEFAULT_CLASS} ${ASSISTANT_GUTTER_CLASS}`}>
|
||||
<div className="absolute left-[4px] top-[2px]">
|
||||
<AgentAvatar seed={avatarSeed} name={name} avatarUrl={avatarUrl} size={22} />
|
||||
<AgentAvatar
|
||||
seed={avatarSeed}
|
||||
name={name}
|
||||
avatarProfile={avatarProfile}
|
||||
avatarUrl={avatarUrl}
|
||||
size={22}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3 py-0.5">
|
||||
<div className="type-meta min-w-0 truncate font-mono text-foreground/90">
|
||||
@@ -535,6 +555,7 @@ const AgentChatFinalItems = memo(function AgentChatFinalItems({
|
||||
agentId,
|
||||
name,
|
||||
avatarSeed,
|
||||
avatarProfile,
|
||||
avatarUrl,
|
||||
chatItems,
|
||||
running,
|
||||
@@ -543,6 +564,7 @@ const AgentChatFinalItems = memo(function AgentChatFinalItems({
|
||||
agentId: string;
|
||||
name: string;
|
||||
avatarSeed: string;
|
||||
avatarProfile?: AgentAvatarProfile | null;
|
||||
avatarUrl: string | null;
|
||||
chatItems: AgentChatItem[];
|
||||
running: boolean;
|
||||
@@ -567,6 +589,7 @@ const AgentChatFinalItems = memo(function AgentChatFinalItems({
|
||||
<AssistantMessageCard
|
||||
key={`chat-${agentId}-assistant-${index}`}
|
||||
avatarSeed={avatarSeed}
|
||||
avatarProfile={avatarProfile}
|
||||
avatarUrl={avatarUrl}
|
||||
name={name}
|
||||
timestampMs={block.timestampMs ?? (streaming ? runStartedAt ?? undefined : undefined)}
|
||||
@@ -585,6 +608,7 @@ const AgentChatTranscript = memo(function AgentChatTranscript({
|
||||
agentId,
|
||||
name,
|
||||
avatarSeed,
|
||||
avatarProfile,
|
||||
avatarUrl,
|
||||
status,
|
||||
historyMaybeTruncated,
|
||||
@@ -607,6 +631,7 @@ const AgentChatTranscript = memo(function AgentChatTranscript({
|
||||
agentId: string;
|
||||
name: string;
|
||||
avatarSeed: string;
|
||||
avatarProfile?: AgentAvatarProfile | null;
|
||||
avatarUrl: string | null;
|
||||
status: AgentRecord["status"];
|
||||
historyMaybeTruncated: boolean;
|
||||
@@ -766,6 +791,7 @@ const AgentChatTranscript = memo(function AgentChatTranscript({
|
||||
{!hasTranscriptContent ? (
|
||||
<AssistantIntroCard
|
||||
avatarSeed={avatarSeed}
|
||||
avatarProfile={avatarProfile}
|
||||
avatarUrl={avatarUrl}
|
||||
name={name}
|
||||
title={emptyStateTitle}
|
||||
@@ -776,6 +802,7 @@ const AgentChatTranscript = memo(function AgentChatTranscript({
|
||||
agentId={agentId}
|
||||
name={name}
|
||||
avatarSeed={avatarSeed}
|
||||
avatarProfile={avatarProfile}
|
||||
avatarUrl={avatarUrl}
|
||||
chatItems={chatItems}
|
||||
running={status === "running"}
|
||||
@@ -784,6 +811,7 @@ const AgentChatTranscript = memo(function AgentChatTranscript({
|
||||
{showLiveAssistantCard ? (
|
||||
<AssistantMessageCard
|
||||
avatarSeed={avatarSeed}
|
||||
avatarProfile={avatarProfile}
|
||||
avatarUrl={avatarUrl}
|
||||
name={name}
|
||||
timestampMs={runStartedAt ?? undefined}
|
||||
@@ -961,10 +989,10 @@ const AgentChatComposer = memo(function AgentChatComposer({
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<InlineHoverTooltip text="Choose model">
|
||||
<select
|
||||
className="ui-input ui-control-important h-6 min-w-0 rounded-md px-1.5 text-[10px] font-semibold text-foreground"
|
||||
className="ui-input ui-control-important h-6 min-w-0 rounded-md border-white/10 px-1.5 text-[10px] font-semibold text-white"
|
||||
aria-label="Model"
|
||||
value={modelValue}
|
||||
style={{ width: `${modelSelectWidthCh}ch` }}
|
||||
style={{ ...CHAT_SELECT_STYLE, width: `${modelSelectWidthCh}ch` }}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value.trim();
|
||||
onModelChange(nextValue ? nextValue : null);
|
||||
@@ -984,10 +1012,10 @@ const AgentChatComposer = memo(function AgentChatComposer({
|
||||
{allowThinking ? (
|
||||
<InlineHoverTooltip text="Select reasoning effort">
|
||||
<select
|
||||
className="ui-input ui-control-important h-6 rounded-md px-1.5 text-[10px] font-semibold text-foreground"
|
||||
className="ui-input ui-control-important h-6 rounded-md border-white/10 px-1.5 text-[10px] font-semibold text-white"
|
||||
aria-label="Thinking"
|
||||
value={thinkingValue}
|
||||
style={{ width: `${thinkingSelectWidthCh}ch` }}
|
||||
style={{ ...CHAT_SELECT_STYLE, width: `${thinkingSelectWidthCh}ch` }}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value.trim();
|
||||
onThinkingChange(nextValue ? nextValue : null);
|
||||
@@ -1173,7 +1201,6 @@ export const AgentChatPanel = ({
|
||||
stopBusy,
|
||||
stopDisabledReason = null,
|
||||
onLoadMoreHistory,
|
||||
onOpenSettings,
|
||||
onRename,
|
||||
onNewSession,
|
||||
onModelChange,
|
||||
@@ -1341,6 +1368,7 @@ export const AgentChatPanel = ({
|
||||
const allowThinking = selectedModel?.reasoning !== false;
|
||||
|
||||
const avatarSeed = agent.avatarSeed ?? agent.agentId;
|
||||
const avatarProfile = agent.avatarProfile ?? null;
|
||||
const emptyStateTitle = useMemo(
|
||||
() => resolveEmptyChatIntroMessage(agent.agentId, agent.sessionEpoch),
|
||||
[agent.agentId, agent.sessionEpoch]
|
||||
@@ -1473,6 +1501,7 @@ export const AgentChatPanel = ({
|
||||
<AgentAvatar
|
||||
seed={avatarSeed}
|
||||
name={agent.name}
|
||||
avatarProfile={avatarProfile}
|
||||
avatarUrl={agent.avatarUrl ?? null}
|
||||
size={52}
|
||||
isSelected={isSelected}
|
||||
@@ -1562,7 +1591,7 @@ export const AgentChatPanel = ({
|
||||
|
||||
<div className="mt-0.5 flex items-center gap-2">
|
||||
<button
|
||||
className="nodrag inline-flex items-center whitespace-nowrap rounded border border-[color:var(--status-approval-border)] bg-[color:var(--status-approval-bg)] px-2 py-0.5 font-mono text-[9px] font-medium tracking-[0.02em] text-[color:var(--status-approval-fg)] transition hover:bg-[color:var(--status-approval-bg)] hover:text-[color:var(--status-approval-fg)] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
className="nodrag inline-flex items-center whitespace-nowrap rounded border border-[color:var(--status-approval-border)] bg-[color:var(--status-approval-bg)] px-2 py-0.5 font-mono text-[9px] font-medium tracking-[0.02em] text-white transition hover:bg-[color:var(--status-approval-bg)] hover:text-white disabled:cursor-not-allowed disabled:opacity-40"
|
||||
type="button"
|
||||
data-testid="agent-new-session-toggle"
|
||||
aria-label="Start new session"
|
||||
@@ -1574,17 +1603,6 @@ export const AgentChatPanel = ({
|
||||
>
|
||||
{newSessionBusy ? "Starting..." : "New session"}
|
||||
</button>
|
||||
<button
|
||||
className="nodrag ui-btn-icon"
|
||||
style={{ "--ui-btn-icon-size": "1.25rem" } as React.CSSProperties}
|
||||
type="button"
|
||||
data-testid="agent-settings-toggle"
|
||||
aria-label="Open behavior"
|
||||
title="Behavior"
|
||||
onClick={onOpenSettings}
|
||||
>
|
||||
<Cog className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1594,6 +1612,7 @@ export const AgentChatPanel = ({
|
||||
agentId={agent.agentId}
|
||||
name={agent.name}
|
||||
avatarSeed={avatarSeed}
|
||||
avatarProfile={avatarProfile}
|
||||
avatarUrl={agent.avatarUrl ?? null}
|
||||
status={agent.status}
|
||||
historyMaybeTruncated={agent.historyMaybeTruncated}
|
||||
|
||||
@@ -1739,7 +1739,6 @@ const AgentsPageScreen = () => {
|
||||
stopBusy={stopBusyAgentId === focusedAgent.agentId}
|
||||
stopDisabledReason={focusedAgentStopDisabledReason}
|
||||
onLoadMoreHistory={() => loadMoreAgentHistory(focusedAgent.agentId)}
|
||||
onOpenSettings={() => handleOpenAgentSettingsRoute(focusedAgent.agentId)}
|
||||
onRename={(name) =>
|
||||
settingsMutationController.handleRenameAgent(focusedAgent.agentId, name)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { ReactNode } from "react";
|
||||
export type HQSidebarTab =
|
||||
| "inbox"
|
||||
| "history"
|
||||
| "kanban"
|
||||
| "playbooks"
|
||||
| "analytics";
|
||||
|
||||
@@ -19,6 +20,7 @@ type HQSidebarProps = {
|
||||
onOpenCompanyBuilder?: () => void;
|
||||
inboxPanel: ReactNode;
|
||||
historyPanel: ReactNode;
|
||||
kanbanPanel: ReactNode;
|
||||
playbooksPanel: ReactNode;
|
||||
analyticsPanel: ReactNode;
|
||||
};
|
||||
@@ -26,11 +28,12 @@ type HQSidebarProps = {
|
||||
const TAB_LABELS: Record<HQSidebarTab, string> = {
|
||||
inbox: "Inbox",
|
||||
history: "History",
|
||||
kanban: "Kanban",
|
||||
playbooks: "Playbooks",
|
||||
analytics: "Analytics",
|
||||
};
|
||||
|
||||
const PRIMARY_TABS: HQSidebarTab[] = ["inbox", "history", "playbooks"];
|
||||
const PRIMARY_TABS: HQSidebarTab[] = ["inbox", "history", "kanban", "playbooks"];
|
||||
|
||||
export function HQSidebar({
|
||||
open,
|
||||
@@ -43,6 +46,7 @@ export function HQSidebar({
|
||||
onOpenCompanyBuilder,
|
||||
inboxPanel,
|
||||
historyPanel,
|
||||
kanbanPanel,
|
||||
playbooksPanel,
|
||||
analyticsPanel,
|
||||
}: HQSidebarProps) {
|
||||
@@ -53,9 +57,12 @@ export function HQSidebar({
|
||||
? inboxPanel
|
||||
: activeTab === "history"
|
||||
? historyPanel
|
||||
: activeTab === "kanban"
|
||||
? kanbanPanel
|
||||
: activeTab === "playbooks"
|
||||
? playbooksPanel
|
||||
: analyticsPanel;
|
||||
const boardLikeWidth = activeTab === "kanban";
|
||||
|
||||
return (
|
||||
<aside className="pointer-events-none fixed inset-y-0 right-0 z-20 flex justify-end">
|
||||
@@ -108,7 +115,11 @@ export function HQSidebar({
|
||||
</div>
|
||||
|
||||
{open ? (
|
||||
<div className="pointer-events-auto flex h-full w-56 flex-col border-l border-cyan-500/20 bg-black/85 shadow-2xl backdrop-blur">
|
||||
<div
|
||||
className={`pointer-events-auto flex h-full flex-col border-l border-cyan-500/20 bg-black/85 shadow-2xl backdrop-blur ${
|
||||
boardLikeWidth ? "w-[min(94vw,1180px)]" : "w-56"
|
||||
}`}
|
||||
>
|
||||
<div className="border-b border-cyan-500/15 px-4 py-3">
|
||||
<div className="font-mono text-[10px] font-semibold tracking-[0.32em] text-cyan-300/80">
|
||||
{analyticsOnly ? "ANALYTICS" : "HEADQUARTERS"}
|
||||
@@ -151,7 +162,7 @@ export function HQSidebar({
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="Headquarters panels"
|
||||
className="grid grid-cols-3 border-b border-cyan-500/15"
|
||||
className="grid grid-cols-4 border-b border-cyan-500/15"
|
||||
>
|
||||
{PRIMARY_TABS.map((tab) => {
|
||||
const isActive = tab === activeTab;
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
type KanbanDisabledPanelProps = {
|
||||
onClose: () => void;
|
||||
onInstall: () => void;
|
||||
installing?: boolean;
|
||||
progressPercent?: number;
|
||||
progressMessage?: string | null;
|
||||
errorMessage?: string | null;
|
||||
};
|
||||
|
||||
export function KanbanDisabledPanel({
|
||||
onClose,
|
||||
onInstall,
|
||||
installing = false,
|
||||
progressPercent = 0,
|
||||
progressMessage = null,
|
||||
errorMessage = null,
|
||||
}: KanbanDisabledPanelProps) {
|
||||
return (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-black/60 p-6">
|
||||
<div className="w-full max-w-sm rounded-3xl border border-slate-700/40 bg-slate-950/95 p-8 text-center shadow-2xl">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl border border-slate-700/40 bg-slate-800/60 px-2 text-center text-sm font-semibold uppercase tracking-[0.12em] text-slate-200">
|
||||
Kanban
|
||||
</div>
|
||||
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.24em] text-slate-500">
|
||||
Task Manager
|
||||
</div>
|
||||
<h2 className="mt-1 text-xl font-semibold text-white">Kanban Skill Not Installed</h2>
|
||||
<p className="mt-3 text-sm leading-relaxed text-slate-400">
|
||||
Install the <span className="text-cyan-400">TASK-MANAGER</span> skill to let your
|
||||
agents capture work as tasks and open the Kanban desk.
|
||||
</p>
|
||||
|
||||
{installing ? (
|
||||
<div className="mt-5 rounded-2xl border border-cyan-500/20 bg-cyan-500/5 p-4 text-left">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-cyan-300/80">
|
||||
Installing
|
||||
</span>
|
||||
<span className="font-mono text-[10px] text-cyan-100/70">
|
||||
{Math.max(0, Math.min(100, Math.round(progressPercent)))}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 h-2 overflow-hidden rounded-full bg-slate-800/90">
|
||||
<div
|
||||
className="h-full rounded-full bg-cyan-400 transition-[width] duration-500 ease-out"
|
||||
style={{ width: `${Math.max(6, Math.min(100, progressPercent))}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-3 text-sm leading-relaxed text-slate-300">
|
||||
{progressMessage?.trim() || "Installing the task-manager skill."}
|
||||
</p>
|
||||
<p className="mt-2 text-xs leading-relaxed text-slate-500">
|
||||
Once it's installed, Claw3D will refresh the task-manager state.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{errorMessage ? (
|
||||
<div className="mt-4 rounded-2xl border border-rose-500/20 bg-rose-500/8 px-4 py-3 text-sm text-rose-200">
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-6 flex flex-col gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-xl bg-cyan-500 px-5 py-2.5 text-sm font-medium text-slate-950 transition hover:bg-cyan-400 active:scale-95 disabled:cursor-not-allowed disabled:bg-cyan-700/60 disabled:text-slate-200"
|
||||
onClick={onInstall}
|
||||
disabled={installing}
|
||||
>
|
||||
{installing ? "Installing TASK-MANAGER skill..." : "Install TASK-MANAGER skill"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-xl border border-slate-700/40 px-5 py-2.5 text-sm text-slate-400 transition hover:bg-slate-800/50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onClick={onClose}
|
||||
disabled={installing}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import type { ComponentProps } from "react";
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
import { TaskBoardView } from "@/features/office/tasks/TaskBoardView";
|
||||
import type { TaskBoardCard, TaskBoardStatus } from "@/features/office/tasks/types";
|
||||
import type { CronJobSummary } from "@/lib/cron/types";
|
||||
|
||||
export function TaskBoardPanel({
|
||||
agents,
|
||||
cardsByStatus,
|
||||
selectedCard,
|
||||
activeRuns,
|
||||
cronJobs,
|
||||
cronLoading,
|
||||
cronError,
|
||||
taskCaptureDebug,
|
||||
onCreateCard,
|
||||
onMoveCard,
|
||||
onSelectCard,
|
||||
onUpdateCard,
|
||||
onDeleteCard,
|
||||
onRefreshCronJobs,
|
||||
}: {
|
||||
agents: AgentState[];
|
||||
cardsByStatus: Record<TaskBoardStatus, TaskBoardCard[]>;
|
||||
selectedCard: TaskBoardCard | null;
|
||||
activeRuns: Array<{ runId: string; agentId: string; label: string }>;
|
||||
cronJobs: CronJobSummary[];
|
||||
cronLoading: boolean;
|
||||
cronError: string | null;
|
||||
taskCaptureDebug?: ComponentProps<typeof TaskBoardView>["taskCaptureDebug"];
|
||||
onCreateCard: () => void;
|
||||
onMoveCard: (cardId: string, status: TaskBoardStatus) => void;
|
||||
onSelectCard: (cardId: string | null) => void;
|
||||
onUpdateCard: (cardId: string, patch: Partial<TaskBoardCard>) => void;
|
||||
onDeleteCard: (cardId: string) => void;
|
||||
onRefreshCronJobs: () => void;
|
||||
}) {
|
||||
return (
|
||||
<TaskBoardView
|
||||
title="Kanban"
|
||||
subtitle="Manual tasks, inferred requests, and scheduled playbooks."
|
||||
agents={agents}
|
||||
cardsByStatus={cardsByStatus}
|
||||
selectedCard={selectedCard}
|
||||
activeRuns={activeRuns}
|
||||
cronJobs={cronJobs}
|
||||
cronLoading={cronLoading}
|
||||
cronError={cronError}
|
||||
taskCaptureDebug={taskCaptureDebug}
|
||||
onCreateCard={onCreateCard}
|
||||
onMoveCard={onMoveCard}
|
||||
onSelectCard={onSelectCard}
|
||||
onUpdateCard={onUpdateCard}
|
||||
onDeleteCard={onDeleteCard}
|
||||
onRefreshCronJobs={onRefreshCronJobs}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -284,6 +284,117 @@ export const useOfficeSkillsMarketplace = ({
|
||||
[client, runSkillMutation]
|
||||
);
|
||||
|
||||
const handleInstallPackagedSkillAndEnable = useCallback(
|
||||
async (params: {
|
||||
skillKey: string;
|
||||
agentId?: string | null;
|
||||
onProgress?: (progress: { percent: number; message: string }) => void;
|
||||
}) => {
|
||||
const packagedSkill = getPackagedSkillBySkillKey(params.skillKey);
|
||||
if (!packagedSkill) {
|
||||
setMessage({
|
||||
kind: "error",
|
||||
text: `No packaged marketplace skill was found for ${params.skillKey.trim() || "that entry"}.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const targetAgentId = params.agentId?.trim() || selectedAgentId?.trim() || "";
|
||||
if (!targetAgentId) {
|
||||
setMessage({
|
||||
kind: "error",
|
||||
text: "Select an agent before installing marketplace skills.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedAgentId(targetAgentId);
|
||||
setBusySkillKey(packagedSkill.skillKey);
|
||||
setError(null);
|
||||
setMessage(null);
|
||||
onSkillActivityStart?.(targetAgentId);
|
||||
try {
|
||||
params.onProgress?.({
|
||||
percent: 12,
|
||||
message: "Preparing the workspace skill install.",
|
||||
});
|
||||
const initialReport = await loadAgentSkillStatus(client, targetAgentId);
|
||||
params.onProgress?.({
|
||||
percent: 38,
|
||||
message: "Installing task-manager into the workspace.",
|
||||
});
|
||||
await installPackagedSkillViaGatewayAgent({
|
||||
client,
|
||||
request: {
|
||||
packageId: packagedSkill.packageId,
|
||||
source: packagedSkill.installSource,
|
||||
workspaceDir: initialReport.workspaceDir,
|
||||
managedSkillsDir: initialReport.managedSkillsDir,
|
||||
},
|
||||
});
|
||||
params.onProgress?.({
|
||||
percent: 62,
|
||||
message: "Enabling task-manager for this gateway.",
|
||||
});
|
||||
await updateSkill(client, { skillKey: packagedSkill.skillKey, enabled: true });
|
||||
params.onProgress?.({
|
||||
percent: 78,
|
||||
message: "Enabling task-manager for the main agent.",
|
||||
});
|
||||
const refreshedReport = await loadAgentSkillStatus(client, targetAgentId);
|
||||
await setAgentSkillEnabled({
|
||||
client,
|
||||
agentId: targetAgentId,
|
||||
skillName: packagedSkill.name,
|
||||
enabled: true,
|
||||
visibleSkills: refreshedReport.skills,
|
||||
});
|
||||
params.onProgress?.({
|
||||
percent: 92,
|
||||
message: "Refreshing skill state in Claw3D.",
|
||||
});
|
||||
await loadMarketplace(targetAgentId);
|
||||
params.onProgress?.({
|
||||
percent: 100,
|
||||
message: "Task-manager installed and enabled.",
|
||||
});
|
||||
const agentName =
|
||||
agents.find((agent) => agent.agentId === targetAgentId)?.name ?? "the main agent";
|
||||
setMessage({
|
||||
kind: "success",
|
||||
text: `Installed and enabled ${packagedSkill.name.trim()} for ${agentName}.`,
|
||||
});
|
||||
} catch (err) {
|
||||
const nextMessage =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Failed to install and enable the skill.";
|
||||
setError(nextMessage);
|
||||
setMessage({
|
||||
kind: "error",
|
||||
text: nextMessage,
|
||||
});
|
||||
if (!isGatewayDisconnectLikeError(err)) {
|
||||
console.error(nextMessage);
|
||||
}
|
||||
throw err instanceof Error ? err : new Error(nextMessage);
|
||||
} finally {
|
||||
onSkillActivityEnd?.(targetAgentId);
|
||||
setBusySkillKey((current) =>
|
||||
current === packagedSkill.skillKey ? null : current,
|
||||
);
|
||||
}
|
||||
},
|
||||
[
|
||||
agents,
|
||||
client,
|
||||
loadMarketplace,
|
||||
onSkillActivityEnd,
|
||||
onSkillActivityStart,
|
||||
selectedAgentId,
|
||||
],
|
||||
);
|
||||
|
||||
const handleSetSkillGlobalEnabled = useCallback(
|
||||
async (skillKey: string, enabled: boolean) => {
|
||||
await runSkillMutation({
|
||||
@@ -338,6 +449,7 @@ export const useOfficeSkillsMarketplace = ({
|
||||
handleSetSkillEnabled,
|
||||
handleInstallSkill,
|
||||
handleInstallPackagedSkill,
|
||||
handleInstallPackagedSkillAndEnable,
|
||||
handleSetSkillGlobalEnabled,
|
||||
handleRemoveSkill,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import { type ComponentProps, useCallback, useEffect, useRef } from "react";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
import { TaskBoardView } from "@/features/office/tasks/TaskBoardView";
|
||||
import type { TaskBoardCard, TaskBoardStatus } from "@/features/office/tasks/types";
|
||||
import type { CronJobSummary } from "@/lib/cron/types";
|
||||
|
||||
export function KanbanImmersiveScreen({
|
||||
agents,
|
||||
cardsByStatus,
|
||||
selectedCard,
|
||||
activeRuns,
|
||||
cronJobs,
|
||||
cronLoading,
|
||||
cronError,
|
||||
taskCaptureDebug,
|
||||
onCreateCard,
|
||||
onMoveCard,
|
||||
onSelectCard,
|
||||
onUpdateCard,
|
||||
onDeleteCard,
|
||||
onRefreshCronJobs,
|
||||
onClose,
|
||||
}: {
|
||||
agents: AgentState[];
|
||||
cardsByStatus: Record<TaskBoardStatus, TaskBoardCard[]>;
|
||||
selectedCard: TaskBoardCard | null;
|
||||
activeRuns: Array<{ runId: string; agentId: string; label: string }>;
|
||||
cronJobs: CronJobSummary[];
|
||||
cronLoading: boolean;
|
||||
cronError: string | null;
|
||||
taskCaptureDebug?: ComponentProps<typeof TaskBoardView>["taskCaptureDebug"];
|
||||
onCreateCard: () => void;
|
||||
onMoveCard: (cardId: string, status: TaskBoardStatus) => void;
|
||||
onSelectCard: (cardId: string | null) => void;
|
||||
onUpdateCard: (cardId: string, patch: Partial<TaskBoardCard>) => void;
|
||||
onDeleteCard: (cardId: string) => void;
|
||||
onRefreshCronJobs: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
event.stopPropagation();
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[onClose]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [handleKeyDown]);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
|
||||
const previouslyFocused = document.activeElement as HTMLElement | null;
|
||||
dialog.focus();
|
||||
|
||||
const trapFocus = (event: FocusEvent) => {
|
||||
if (!dialog.contains(event.target as Node)) {
|
||||
event.stopPropagation();
|
||||
dialog.focus();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("focusin", trapFocus);
|
||||
return () => {
|
||||
document.removeEventListener("focusin", trapFocus);
|
||||
previouslyFocused?.focus?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Kanban Board"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close Kanban Board"
|
||||
className="absolute -right-5 -top-5 z-10 flex h-10 w-10 items-center justify-center rounded-full border border-amber-400/20 bg-[#0e0b07]/90 text-amber-200/70 backdrop-blur-sm transition-colors hover:border-amber-400/40 hover:text-white"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<div
|
||||
ref={dialogRef}
|
||||
tabIndex={-1}
|
||||
className="flex h-[min(75vh,800px)] w-[min(80vw,1280px)] flex-col overflow-hidden rounded-2xl border border-amber-500/20 bg-[#0e0b07]/85 shadow-2xl outline-none backdrop-blur-md"
|
||||
>
|
||||
<div className="min-h-0 flex-1">
|
||||
<TaskBoardView
|
||||
title="Kanban Board"
|
||||
subtitle="Headquarters task routing, scheduling, and review."
|
||||
agents={agents}
|
||||
cardsByStatus={cardsByStatus}
|
||||
selectedCard={selectedCard}
|
||||
activeRuns={activeRuns}
|
||||
cronJobs={cronJobs}
|
||||
cronLoading={cronLoading}
|
||||
cronError={cronError}
|
||||
taskCaptureDebug={taskCaptureDebug}
|
||||
onCreateCard={onCreateCard}
|
||||
onMoveCard={onMoveCard}
|
||||
onSelectCard={onSelectCard}
|
||||
onUpdateCard={onUpdateCard}
|
||||
onDeleteCard={onDeleteCard}
|
||||
onRefreshCronJobs={onRefreshCronJobs}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -137,8 +137,10 @@ import type {
|
||||
import { AnalyticsPanel } from "@/features/office/components/panels/AnalyticsPanel";
|
||||
import { HistoryPanel } from "@/features/office/components/panels/HistoryPanel";
|
||||
import { InboxPanel } from "@/features/office/components/panels/InboxPanel";
|
||||
import { KanbanDisabledPanel } from "@/features/office/components/panels/KanbanDisabledPanel";
|
||||
import { PlaybooksPanel } from "@/features/office/components/panels/PlaybooksPanel";
|
||||
import { SkillsMarketplaceModal } from "@/features/office/components/panels/SkillsMarketplaceModal";
|
||||
import { TaskBoardPanel } from "@/features/office/components/panels/TaskBoardPanel";
|
||||
import { JukeboxPanel } from "@/features/spotify-jukebox/components/JukeboxPanel";
|
||||
import { JukeboxDisabledPanel } from "@/features/spotify-jukebox/components/JukeboxDisabledPanel";
|
||||
import { executeBrowserJukeboxCommand } from "@/features/spotify-jukebox/agentBridge";
|
||||
@@ -152,6 +154,7 @@ import { useRemoteOfficeLayout } from "@/features/office/hooks/useRemoteOfficeLa
|
||||
import { useOfficeSkillsMarketplace } from "@/features/office/hooks/useOfficeSkillsMarketplace";
|
||||
import { useOfficeStandupController } from "@/features/office/hooks/useOfficeStandupController";
|
||||
import { useRunLog } from "@/features/office/hooks/useRunLog";
|
||||
import { useTaskBoardController } from "@/features/office/tasks/useTaskBoardController";
|
||||
import {
|
||||
OnboardingWizard,
|
||||
useOnboardingState,
|
||||
@@ -880,6 +883,8 @@ export function OfficeScreen({
|
||||
const [openClawConsoleCopyStatus, setOpenClawConsoleCopyStatus] = useState<
|
||||
"idle" | "copied" | "error"
|
||||
>("idle");
|
||||
const taskBoardEventHandlerRef = useRef<(event: EventFrame) => void>(() => {});
|
||||
const taskBoardRefreshRef = useRef<() => Promise<void>>(async () => {});
|
||||
const [officeTriggerState, setOfficeTriggerState] = useState(() =>
|
||||
createOfficeAnimationTriggerState(),
|
||||
);
|
||||
@@ -963,6 +968,18 @@ export function OfficeScreen({
|
||||
const [gatewayModels, setGatewayModels] = useState<GatewayModelChoice[]>([]);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [marketplaceOpen, setMarketplaceOpen] = useState(false);
|
||||
const [kanbanInstallPromptOpen, setKanbanInstallPromptOpen] = useState(false);
|
||||
const [kanbanInstallProgress, setKanbanInstallProgress] = useState<{
|
||||
active: boolean;
|
||||
percent: number;
|
||||
message: string;
|
||||
error: string | null;
|
||||
}>({
|
||||
active: false,
|
||||
percent: 0,
|
||||
message: "",
|
||||
error: null,
|
||||
});
|
||||
const [danceUntilByAgentId, setDanceUntilByAgentId] = useState<Record<string, number>>({});
|
||||
const initJukeboxStore = useJukeboxStore((state) => state.init);
|
||||
const jukeboxToken = useJukeboxStore((state) => state.token);
|
||||
@@ -2468,6 +2485,7 @@ export function OfficeScreen({
|
||||
) {
|
||||
return;
|
||||
}
|
||||
taskBoardEventHandlerRef.current(event);
|
||||
runtimeHandler.handleEvent(event);
|
||||
});
|
||||
const unsubscribeGap = client.onGap(() => {
|
||||
@@ -2476,6 +2494,7 @@ export function OfficeScreen({
|
||||
settingsMaxAgeMs: 30_000,
|
||||
silent: true,
|
||||
});
|
||||
void taskBoardRefreshRef.current();
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -2672,6 +2691,21 @@ export function OfficeScreen({
|
||||
gatewayUrl,
|
||||
agents: standupAgentSnapshots,
|
||||
});
|
||||
const taskBoard = useTaskBoardController({
|
||||
gatewayUrl,
|
||||
settingsCoordinator,
|
||||
client,
|
||||
status,
|
||||
agents: state.agents,
|
||||
runLog,
|
||||
standup: standupController,
|
||||
});
|
||||
const ingestTaskBoardEvent = taskBoard.ingestGatewayEvent;
|
||||
taskBoardEventHandlerRef.current = ingestTaskBoardEvent;
|
||||
taskBoardRefreshRef.current = async () => {
|
||||
await taskBoard.refreshSharedTasks();
|
||||
await taskBoard.refreshRemoteTasks();
|
||||
};
|
||||
const handleMarketplaceGymStart = useCallback((agentId: string) => {
|
||||
setMarketplaceGymHoldByAgentId((previous) => ({
|
||||
...previous,
|
||||
@@ -3945,6 +3979,19 @@ export function OfficeScreen({
|
||||
}) ?? null,
|
||||
[marketplace.skillsReport],
|
||||
);
|
||||
const taskManagerSkill = useMemo<SkillStatusEntry | null>(
|
||||
() =>
|
||||
marketplace.skillsReport?.skills.find((skill) => {
|
||||
const normalizedKey = skill.skillKey.trim().toLowerCase();
|
||||
const normalizedName = skill.name.trim().toLowerCase();
|
||||
return normalizedKey === "task-manager" || normalizedName === "task-manager";
|
||||
}) ?? null,
|
||||
[marketplace.skillsReport],
|
||||
);
|
||||
const taskManagerReady = useMemo(
|
||||
() => (taskManagerSkill ? deriveSkillReadinessState(taskManagerSkill) === "ready" : false),
|
||||
[taskManagerSkill],
|
||||
);
|
||||
const soundclawReady = useMemo(
|
||||
() => (soundclawSkill ? deriveSkillReadinessState(soundclawSkill) === "ready" : false),
|
||||
[soundclawSkill]
|
||||
@@ -4114,6 +4161,7 @@ export function OfficeScreen({
|
||||
monitorAgentId={monitorAgentId}
|
||||
monitorByAgentId={monitorByAgentId}
|
||||
githubSkill={githubSkill}
|
||||
taskManagerEnabled={taskManagerReady}
|
||||
soundclawEnabled={soundclawReady}
|
||||
officeTitle={officeTitle}
|
||||
officeTitleLoaded={officeTitleLoaded}
|
||||
@@ -4207,6 +4255,34 @@ export function OfficeScreen({
|
||||
onJukeboxInteract={() => {
|
||||
setJukeboxOpen(true);
|
||||
}}
|
||||
onKanbanInteract={() => {
|
||||
setKanbanInstallPromptOpen(true);
|
||||
}}
|
||||
taskBoardAgents={state.agents}
|
||||
taskBoardCardsByStatus={taskBoard.cardsByStatus}
|
||||
taskBoardSelectedCard={taskBoard.selectedCard}
|
||||
taskBoardActiveRuns={taskBoard.activeRuns}
|
||||
taskBoardCronJobs={taskBoard.cronJobs}
|
||||
taskBoardCronLoading={taskBoard.cronLoading}
|
||||
taskBoardCronError={
|
||||
taskBoard.sharedTasksError ?? taskBoard.gatewayTasksError ?? taskBoard.cronError
|
||||
}
|
||||
taskBoardCaptureDebug={showOpenClawConsole ? taskBoard.taskCaptureDebug : undefined}
|
||||
preferredKanbanAgentId={selectedChatAgentId ?? state.selectedAgentId}
|
||||
onTaskBoardCreateCard={() => {
|
||||
taskBoard.createManualCard();
|
||||
}}
|
||||
onTaskBoardMoveCard={taskBoard.moveCard}
|
||||
onTaskBoardSelectCard={(cardId) => {
|
||||
taskBoard.selectCard(cardId);
|
||||
}}
|
||||
onTaskBoardUpdateCard={taskBoard.updateCard}
|
||||
onTaskBoardDeleteCard={taskBoard.removeCard}
|
||||
onTaskBoardRefreshCronJobs={() => {
|
||||
void taskBoard.refreshSharedTasks();
|
||||
void taskBoard.refreshRemoteTasks();
|
||||
void taskBoard.refreshCronJobs();
|
||||
}}
|
||||
/>
|
||||
{jukeboxOpen ? (
|
||||
soundclawReady ? (
|
||||
@@ -4225,6 +4301,75 @@ export function OfficeScreen({
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
{kanbanInstallPromptOpen ? (
|
||||
<KanbanDisabledPanel
|
||||
onClose={() => {
|
||||
if (kanbanInstallProgress.active) {
|
||||
return;
|
||||
}
|
||||
setKanbanInstallPromptOpen(false);
|
||||
setKanbanInstallProgress({
|
||||
active: false,
|
||||
percent: 0,
|
||||
message: "",
|
||||
error: null,
|
||||
});
|
||||
}}
|
||||
onInstall={() => {
|
||||
const targetAgentId =
|
||||
(selectedChatAgentId ?? state.selectedAgentId ?? state.agents[0]?.agentId ?? "")
|
||||
.trim() || null;
|
||||
setKanbanInstallProgress({
|
||||
active: true,
|
||||
percent: 8,
|
||||
message: "Starting task-manager installation.",
|
||||
error: null,
|
||||
});
|
||||
void (async () => {
|
||||
try {
|
||||
await marketplace.handleInstallPackagedSkillAndEnable({
|
||||
skillKey: "task-manager",
|
||||
agentId: targetAgentId,
|
||||
onProgress: ({ percent, message }) => {
|
||||
setKanbanInstallProgress({
|
||||
active: true,
|
||||
percent,
|
||||
message,
|
||||
error: null,
|
||||
});
|
||||
},
|
||||
});
|
||||
setKanbanInstallProgress({
|
||||
active: true,
|
||||
percent: 100,
|
||||
message: "Refreshing task-manager state in Claw3D.",
|
||||
error: null,
|
||||
});
|
||||
setKanbanInstallPromptOpen(false);
|
||||
setKanbanInstallProgress({
|
||||
active: false,
|
||||
percent: 0,
|
||||
message: "",
|
||||
error: null,
|
||||
});
|
||||
} catch (error) {
|
||||
setKanbanInstallProgress((current) => ({
|
||||
...current,
|
||||
active: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to install task-manager.",
|
||||
}));
|
||||
}
|
||||
})();
|
||||
}}
|
||||
installing={kanbanInstallProgress.active}
|
||||
progressPercent={kanbanInstallProgress.percent}
|
||||
progressMessage={kanbanInstallProgress.message}
|
||||
errorMessage={kanbanInstallProgress.error}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{showEmptyFleetBanner ? (
|
||||
@@ -4311,6 +4456,33 @@ export function OfficeScreen({
|
||||
}}
|
||||
/>
|
||||
}
|
||||
kanbanPanel={
|
||||
<TaskBoardPanel
|
||||
agents={state.agents}
|
||||
cardsByStatus={taskBoard.cardsByStatus}
|
||||
selectedCard={taskBoard.selectedCard}
|
||||
activeRuns={taskBoard.activeRuns}
|
||||
cronJobs={taskBoard.cronJobs}
|
||||
cronLoading={taskBoard.cronLoading}
|
||||
cronError={
|
||||
taskBoard.sharedTasksError ?? taskBoard.gatewayTasksError ?? taskBoard.cronError
|
||||
}
|
||||
taskCaptureDebug={showOpenClawConsole ? taskBoard.taskCaptureDebug : undefined}
|
||||
onCreateCard={() => {
|
||||
taskBoard.createManualCard();
|
||||
setActiveSidebarTab("kanban");
|
||||
}}
|
||||
onMoveCard={taskBoard.moveCard}
|
||||
onSelectCard={taskBoard.selectCard}
|
||||
onUpdateCard={taskBoard.updateCard}
|
||||
onDeleteCard={taskBoard.removeCard}
|
||||
onRefreshCronJobs={() => {
|
||||
void taskBoard.refreshSharedTasks();
|
||||
void taskBoard.refreshRemoteTasks();
|
||||
void taskBoard.refreshCronJobs();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
playbooksPanel={
|
||||
<PlaybooksPanel
|
||||
client={client}
|
||||
@@ -4661,9 +4833,6 @@ export function OfficeScreen({
|
||||
chatController.stopBusyAgentId === focusedChatAgent.agentId
|
||||
}
|
||||
onLoadMoreHistory={() => {}}
|
||||
onOpenSettings={() => {
|
||||
router.push("/office");
|
||||
}}
|
||||
onNewSession={() =>
|
||||
chatController.handleNewSession(focusedChatAgent.agentId)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,440 @@
|
||||
"use client";
|
||||
|
||||
import type { DragEvent, KeyboardEvent as ReactKeyboardEvent } from "react";
|
||||
import { Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
import type { CronJobSummary } from "@/lib/cron/types";
|
||||
import type { TaskBoardCard, TaskBoardStatus } from "@/features/office/tasks/types";
|
||||
|
||||
const STATUS_LABELS: Record<TaskBoardStatus, string> = {
|
||||
todo: "Todo",
|
||||
in_progress: "In Progress",
|
||||
blocked: "Blocked",
|
||||
review: "Review",
|
||||
done: "Done",
|
||||
};
|
||||
|
||||
const STATUS_ORDER: TaskBoardStatus[] = [
|
||||
"todo",
|
||||
"in_progress",
|
||||
"blocked",
|
||||
"review",
|
||||
"done",
|
||||
];
|
||||
|
||||
const formatRelativeTime = (value: string | null) => {
|
||||
if (!value) return "No activity";
|
||||
const at = Date.parse(value);
|
||||
if (!Number.isFinite(at)) return "No activity";
|
||||
const delta = Math.max(0, Date.now() - at);
|
||||
if (delta < 60_000) return "Just now";
|
||||
if (delta < 3_600_000) return `${Math.max(1, Math.floor(delta / 60_000))}m ago`;
|
||||
if (delta < 86_400_000) return `${Math.max(1, Math.floor(delta / 3_600_000))}h ago`;
|
||||
return `${Math.max(1, Math.floor(delta / 86_400_000))}d ago`;
|
||||
};
|
||||
|
||||
const stopAndGetCardId = (event: DragEvent<HTMLElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return event.dataTransfer.getData("text/task-card-id").trim();
|
||||
};
|
||||
|
||||
export function TaskBoardView({
|
||||
title,
|
||||
subtitle,
|
||||
agents,
|
||||
cardsByStatus,
|
||||
selectedCard,
|
||||
activeRuns,
|
||||
cronJobs,
|
||||
cronLoading,
|
||||
cronError,
|
||||
taskCaptureDebug,
|
||||
onCreateCard,
|
||||
onMoveCard,
|
||||
onSelectCard,
|
||||
onUpdateCard,
|
||||
onDeleteCard,
|
||||
onRefreshCronJobs,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
agents: AgentState[];
|
||||
cardsByStatus: Record<TaskBoardStatus, TaskBoardCard[]>;
|
||||
selectedCard: TaskBoardCard | null;
|
||||
activeRuns: Array<{ runId: string; agentId: string; label: string }>;
|
||||
cronJobs: CronJobSummary[];
|
||||
cronLoading: boolean;
|
||||
cronError: string | null;
|
||||
taskCaptureDebug?: {
|
||||
lastStatus: "idle" | "detected" | "persisted" | "failed" | "unsupported";
|
||||
lastUpdatedAt: string | null;
|
||||
lastTitle: string | null;
|
||||
lastTaskId: string | null;
|
||||
lastSessionKey: string | null;
|
||||
lastMessage: string | null;
|
||||
detectedCount: number;
|
||||
visibleCardCount: number;
|
||||
totalCardCount: number;
|
||||
sharedTasksSupported: boolean;
|
||||
sharedTasksLoading: boolean;
|
||||
sharedTasksError: string | null;
|
||||
};
|
||||
onCreateCard: () => void;
|
||||
onMoveCard: (cardId: string, status: TaskBoardStatus) => void;
|
||||
onSelectCard: (cardId: string | null) => void;
|
||||
onUpdateCard: (cardId: string, patch: Partial<TaskBoardCard>) => void;
|
||||
onDeleteCard: (cardId: string) => void;
|
||||
onRefreshCronJobs: () => void;
|
||||
}) {
|
||||
return (
|
||||
<section className="flex h-full min-h-0 flex-col bg-transparent text-white">
|
||||
<div className="border-b border-cyan-500/10 bg-[#070b11]/22 px-4 py-3 backdrop-blur-[1px]">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="font-mono text-[11px] uppercase tracking-[0.22em] text-cyan-200/80">
|
||||
{title}
|
||||
</div>
|
||||
<div className="mt-1 font-mono text-[11px] text-white/40">{subtitle}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRefreshCronJobs}
|
||||
className="rounded border border-white/10 bg-white/5 px-2 py-1 font-mono text-[10px] uppercase tracking-[0.14em] text-white/70 transition-colors hover:border-white/20 hover:text-white"
|
||||
>
|
||||
{cronLoading ? <RefreshCw className="h-3.5 w-3.5 animate-spin" /> : "Refresh"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCreateCard}
|
||||
className="inline-flex items-center gap-1 rounded border border-cyan-500/25 bg-cyan-500/10 px-2.5 py-1.5 font-mono text-[10px] uppercase tracking-[0.14em] text-cyan-100 transition-colors hover:border-cyan-400/50 hover:text-white"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
New Task
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{cronError ? (
|
||||
<div className="mt-2 rounded border border-rose-500/30 bg-rose-500/10 px-3 py-2 font-mono text-[11px] text-rose-100">
|
||||
{cronError}
|
||||
</div>
|
||||
) : null}
|
||||
{taskCaptureDebug ? (
|
||||
<details className="mt-2 rounded border border-amber-400/20 bg-amber-400/5 px-3 py-2 font-mono text-[11px] text-amber-50">
|
||||
<summary className="cursor-pointer list-none select-none">
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[10px] uppercase tracking-[0.14em] text-amber-100/75">
|
||||
<span>Capture debug</span>
|
||||
<span>Status: {taskCaptureDebug.lastStatus}</span>
|
||||
<span>Visible cards: {taskCaptureDebug.visibleCardCount}</span>
|
||||
<span>Tracked cards: {taskCaptureDebug.totalCardCount}</span>
|
||||
<span>Detected: {taskCaptureDebug.detectedCount}</span>
|
||||
</div>
|
||||
</summary>
|
||||
<div className="mt-2 grid gap-1 text-white/80">
|
||||
<div>
|
||||
Last request: {taskCaptureDebug.lastTitle ?? "None yet."}
|
||||
</div>
|
||||
<div>
|
||||
Last task id: {taskCaptureDebug.lastTaskId ?? "-"}
|
||||
</div>
|
||||
<div>
|
||||
Session/thread: {taskCaptureDebug.lastSessionKey ?? "-"}
|
||||
</div>
|
||||
<div>
|
||||
Last update: {formatRelativeTime(taskCaptureDebug.lastUpdatedAt)}
|
||||
</div>
|
||||
<div>
|
||||
Shared store:{" "}
|
||||
{taskCaptureDebug.sharedTasksSupported
|
||||
? taskCaptureDebug.sharedTasksLoading
|
||||
? "Syncing."
|
||||
: "Available."
|
||||
: "Unavailable."}
|
||||
</div>
|
||||
<div>
|
||||
Note: {taskCaptureDebug.lastMessage ?? "Waiting for inbound request detection."}
|
||||
</div>
|
||||
{taskCaptureDebug.sharedTasksError ? (
|
||||
<div className="text-rose-200">
|
||||
Store error: {taskCaptureDebug.sharedTasksError}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className={`grid min-h-0 flex-1 overflow-hidden ${selectedCard ? "grid-cols-[minmax(0,1fr)_300px]" : "grid-cols-1"}`}>
|
||||
<div className="min-h-0 overflow-auto px-4 py-4">
|
||||
<div className="grid min-w-[700px] grid-cols-5 gap-3">
|
||||
{STATUS_ORDER.map((status) => {
|
||||
const cards = cardsByStatus[status];
|
||||
return (
|
||||
<div
|
||||
key={status}
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault();
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
const cardId = stopAndGetCardId(event);
|
||||
if (!cardId) return;
|
||||
onMoveCard(cardId, status);
|
||||
}}
|
||||
className="flex min-h-[420px] flex-col rounded-xl border border-white/10 bg-black/14 backdrop-blur-[1px]"
|
||||
>
|
||||
<div className="border-b border-white/8 px-3 py-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.16em] text-white/50">
|
||||
{STATUS_LABELS[status]}
|
||||
</div>
|
||||
<div className="rounded bg-white/8 px-1.5 py-0.5 font-mono text-[10px] text-white/60">
|
||||
{cards.length}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 space-y-2 overflow-y-auto p-3">
|
||||
{cards.length === 0 ? (
|
||||
<div className="rounded border border-dashed border-white/10 px-3 py-4 text-center font-mono text-[10px] uppercase tracking-[0.16em] text-white/25">
|
||||
Drop a card here.
|
||||
</div>
|
||||
) : (
|
||||
cards.map((card) => (
|
||||
<button
|
||||
key={card.id}
|
||||
type="button"
|
||||
draggable
|
||||
aria-label={`${card.title} — ${STATUS_LABELS[card.status]}. Arrow keys to move between columns.`}
|
||||
onDragStart={(event) => {
|
||||
event.dataTransfer.setData("text/task-card-id", card.id);
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
}}
|
||||
onClick={() => onSelectCard(selectedCard?.id === card.id ? null : card.id)}
|
||||
onKeyDown={(event: ReactKeyboardEvent) => {
|
||||
const currentIdx = STATUS_ORDER.indexOf(card.status);
|
||||
if (event.key === "ArrowRight" && currentIdx < STATUS_ORDER.length - 1) {
|
||||
event.preventDefault();
|
||||
onMoveCard(card.id, STATUS_ORDER[currentIdx + 1]!);
|
||||
} else if (event.key === "ArrowLeft" && currentIdx > 0) {
|
||||
event.preventDefault();
|
||||
onMoveCard(card.id, STATUS_ORDER[currentIdx - 1]!);
|
||||
}
|
||||
}}
|
||||
className={`flex w-full flex-col rounded-lg border px-3 py-3 text-left transition-colors ${
|
||||
selectedCard?.id === card.id
|
||||
? "border-cyan-400/35 bg-cyan-500/[0.10]"
|
||||
: "border-white/8 bg-black/12 hover:border-cyan-400/20 hover:bg-cyan-500/[0.04]"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="line-clamp-2 text-sm font-medium text-white/90">
|
||||
{card.title}
|
||||
</div>
|
||||
<span className="rounded border border-white/10 px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-[0.14em] text-white/50">
|
||||
{card.source.replaceAll("_", " ")}
|
||||
</span>
|
||||
</div>
|
||||
{card.description ? (
|
||||
<div className="mt-2 line-clamp-3 text-[12px] leading-5 text-white/55">
|
||||
{card.description}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2 font-mono text-[10px] uppercase tracking-[0.12em] text-white/38">
|
||||
<span>{card.assignedAgentId ?? "Unassigned"}</span>
|
||||
{card.runId ? <span>Run linked.</span> : null}
|
||||
{card.playbookJobId ? <span>Playbook linked.</span> : null}
|
||||
</div>
|
||||
<div className="mt-2 font-mono text-[10px] text-white/32">
|
||||
{formatRelativeTime(card.lastActivityAt ?? card.updatedAt)}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedCard ? (
|
||||
<aside className="flex min-h-0 flex-col border-l border-white/8 bg-black/25">
|
||||
<div className="flex items-center justify-between border-b border-white/8 px-4 py-3">
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.16em] text-white/45">
|
||||
Task Details
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectCard(null)}
|
||||
className="font-mono text-[10px] uppercase tracking-[0.14em] text-white/40 hover:text-white/70"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto px-4 py-4">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.16em] text-white/35">
|
||||
Title
|
||||
</span>
|
||||
<input
|
||||
value={selectedCard.title}
|
||||
onChange={(event) =>
|
||||
onUpdateCard(selectedCard.id, { title: event.target.value })
|
||||
}
|
||||
className="rounded border border-white/10 bg-black/40 px-3 py-2 text-sm text-white outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.16em] text-white/35">
|
||||
Description
|
||||
</span>
|
||||
<textarea
|
||||
rows={4}
|
||||
value={selectedCard.description}
|
||||
onChange={(event) =>
|
||||
onUpdateCard(selectedCard.id, { description: event.target.value })
|
||||
}
|
||||
className="rounded border border-white/10 bg-black/40 px-3 py-2 text-sm text-white outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.16em] text-white/35">
|
||||
Status
|
||||
</span>
|
||||
<select
|
||||
value={selectedCard.status}
|
||||
onChange={(event) =>
|
||||
onMoveCard(selectedCard.id, event.target.value as TaskBoardStatus)
|
||||
}
|
||||
className="rounded border border-white/10 bg-black/40 px-3 py-2 text-sm text-white outline-none"
|
||||
>
|
||||
{STATUS_ORDER.map((status) => (
|
||||
<option key={status} value={status}>
|
||||
{STATUS_LABELS[status]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.16em] text-white/35">
|
||||
Assigned agent
|
||||
</span>
|
||||
<select
|
||||
value={selectedCard.assignedAgentId ?? ""}
|
||||
onChange={(event) =>
|
||||
onUpdateCard(selectedCard.id, {
|
||||
assignedAgentId: event.target.value || null,
|
||||
})
|
||||
}
|
||||
className="rounded border border-white/10 bg-black/40 px-3 py-2 text-sm text-white outline-none"
|
||||
>
|
||||
<option value="">Unassigned</option>
|
||||
{agents.map((agent) => (
|
||||
<option key={agent.agentId} value={agent.agentId}>
|
||||
{agent.name || agent.agentId}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.16em] text-white/35">
|
||||
Linked active run
|
||||
</span>
|
||||
<select
|
||||
value={selectedCard.runId ?? ""}
|
||||
onChange={(event) =>
|
||||
onUpdateCard(selectedCard.id, { runId: event.target.value || null })
|
||||
}
|
||||
className="rounded border border-white/10 bg-black/40 px-3 py-2 text-sm text-white outline-none"
|
||||
>
|
||||
<option value="">No linked run</option>
|
||||
{activeRuns.map((run) => (
|
||||
<option key={run.runId} value={run.runId}>
|
||||
{run.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.16em] text-white/35">
|
||||
Linked playbook
|
||||
</span>
|
||||
<select
|
||||
value={selectedCard.playbookJobId ?? ""}
|
||||
onChange={(event) =>
|
||||
onUpdateCard(selectedCard.id, {
|
||||
playbookJobId: event.target.value || null,
|
||||
})
|
||||
}
|
||||
className="rounded border border-white/10 bg-black/40 px-3 py-2 text-sm text-white outline-none"
|
||||
>
|
||||
<option value="">No linked playbook</option>
|
||||
{cronJobs.map((job) => (
|
||||
<option key={job.id} value={job.id}>
|
||||
{job.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.16em] text-white/35">
|
||||
Channel
|
||||
</span>
|
||||
<input
|
||||
value={selectedCard.channel ?? ""}
|
||||
onChange={(event) =>
|
||||
onUpdateCard(selectedCard.id, {
|
||||
channel: event.target.value || null,
|
||||
})
|
||||
}
|
||||
className="rounded border border-white/10 bg-black/40 px-3 py-2 text-sm text-white outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.16em] text-white/35">
|
||||
Notes
|
||||
</span>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={selectedCard.notes.join("\n")}
|
||||
onChange={(event) =>
|
||||
onUpdateCard(selectedCard.id, {
|
||||
notes: event.target.value
|
||||
.split("\n")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean),
|
||||
})
|
||||
}
|
||||
className="rounded border border-white/10 bg-black/40 px-3 py-2 text-sm text-white outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="space-y-2 rounded border border-white/8 bg-white/[0.03] px-3 py-3 font-mono text-[10px] uppercase tracking-[0.14em] text-white/38">
|
||||
<div>Source: {selectedCard.source.replaceAll("_", " ")}.</div>
|
||||
<div>Created: {new Date(selectedCard.createdAt).toLocaleString()}.</div>
|
||||
<div>Updated: {new Date(selectedCard.updatedAt).toLocaleString()}.</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDeleteCard(selectedCard.id)}
|
||||
className="inline-flex items-center gap-2 rounded border border-rose-500/25 bg-rose-500/10 px-3 py-2 font-mono text-[10px] uppercase tracking-[0.16em] text-rose-100 transition-colors hover:border-rose-400/50 hover:text-white"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete Task
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import {
|
||||
defaultTaskBoardPreference,
|
||||
type TaskBoardCard,
|
||||
type TaskBoardPreference,
|
||||
type TaskBoardStatus,
|
||||
} from "@/features/office/tasks/types";
|
||||
|
||||
type TaskBoardAction =
|
||||
| { type: "hydrate"; preference: TaskBoardPreference }
|
||||
| { type: "upsert"; card: TaskBoardCard }
|
||||
| { type: "upsertMany"; cards: TaskBoardCard[] }
|
||||
| { type: "update"; cardId: string; patch: Partial<TaskBoardCard> }
|
||||
| { type: "move"; cardId: string; status: TaskBoardStatus }
|
||||
| { type: "remove"; cardId: string }
|
||||
| { type: "select"; cardId: string | null };
|
||||
|
||||
const compareCards = (left: TaskBoardCard, right: TaskBoardCard) => {
|
||||
const leftArchived = left.isArchived ? 1 : 0;
|
||||
const rightArchived = right.isArchived ? 1 : 0;
|
||||
if (leftArchived !== rightArchived) return leftArchived - rightArchived;
|
||||
const leftAt = Date.parse(left.updatedAt) || 0;
|
||||
const rightAt = Date.parse(right.updatedAt) || 0;
|
||||
if (leftAt !== rightAt) return rightAt - leftAt;
|
||||
return left.title.localeCompare(right.title);
|
||||
};
|
||||
|
||||
export const sortTaskBoardCards = (cards: TaskBoardCard[]): TaskBoardCard[] =>
|
||||
[...cards].sort(compareCards);
|
||||
|
||||
export const upsertTaskBoardCard = (
|
||||
cards: TaskBoardCard[],
|
||||
nextCard: TaskBoardCard,
|
||||
): TaskBoardCard[] => {
|
||||
const cardId = nextCard.id.trim();
|
||||
if (!cardId) return cards;
|
||||
const existingIndex = cards.findIndex((card) => card.id === cardId);
|
||||
if (existingIndex < 0) return sortTaskBoardCards([...cards, nextCard]);
|
||||
const next = [...cards];
|
||||
next[existingIndex] = nextCard;
|
||||
return sortTaskBoardCards(next);
|
||||
};
|
||||
|
||||
export const taskBoardReducer = (
|
||||
state: TaskBoardPreference = defaultTaskBoardPreference(),
|
||||
action: TaskBoardAction,
|
||||
): TaskBoardPreference => {
|
||||
switch (action.type) {
|
||||
case "hydrate":
|
||||
return {
|
||||
cards: sortTaskBoardCards(action.preference.cards),
|
||||
selectedCardId: action.preference.selectedCardId,
|
||||
};
|
||||
case "upsert":
|
||||
return {
|
||||
...state,
|
||||
cards: upsertTaskBoardCard(state.cards, action.card),
|
||||
};
|
||||
case "upsertMany": {
|
||||
let cards = state.cards;
|
||||
for (const card of action.cards) {
|
||||
cards = upsertTaskBoardCard(cards, card);
|
||||
}
|
||||
return { ...state, cards };
|
||||
}
|
||||
case "update": {
|
||||
const existing = state.cards.find((card) => card.id === action.cardId);
|
||||
if (!existing) return state;
|
||||
return {
|
||||
...state,
|
||||
cards: upsertTaskBoardCard(state.cards, {
|
||||
...existing,
|
||||
...action.patch,
|
||||
updatedAt: action.patch.updatedAt ?? new Date().toISOString(),
|
||||
}),
|
||||
};
|
||||
}
|
||||
case "move": {
|
||||
const existing = state.cards.find((card) => card.id === action.cardId);
|
||||
if (!existing) return state;
|
||||
return {
|
||||
...state,
|
||||
cards: upsertTaskBoardCard(state.cards, {
|
||||
...existing,
|
||||
status: action.status,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}),
|
||||
};
|
||||
}
|
||||
case "remove": {
|
||||
const cards = state.cards.filter((card) => card.id !== action.cardId);
|
||||
return {
|
||||
cards,
|
||||
selectedCardId:
|
||||
state.selectedCardId === action.cardId ? cards[0]?.id ?? null : state.selectedCardId,
|
||||
};
|
||||
}
|
||||
case "select":
|
||||
return {
|
||||
...state,
|
||||
selectedCardId: action.cardId,
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { EventFrame } from "@/lib/gateway/GatewayClient";
|
||||
|
||||
export const TASK_BOARD_STATUSES = [
|
||||
"todo",
|
||||
"in_progress",
|
||||
"blocked",
|
||||
"review",
|
||||
"done",
|
||||
] as const;
|
||||
|
||||
export type TaskBoardStatus = (typeof TASK_BOARD_STATUSES)[number];
|
||||
|
||||
export const TASK_BOARD_SOURCES = [
|
||||
"openclaw_event",
|
||||
"claw3d_manual",
|
||||
"playbook",
|
||||
"fallback_inferred",
|
||||
] as const;
|
||||
|
||||
export type TaskBoardSource = (typeof TASK_BOARD_SOURCES)[number];
|
||||
|
||||
export type TaskBoardCard = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
status: TaskBoardStatus;
|
||||
source: TaskBoardSource;
|
||||
sourceEventId: string | null;
|
||||
assignedAgentId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
playbookJobId: string | null;
|
||||
runId: string | null;
|
||||
channel: string | null;
|
||||
externalThreadId: string | null;
|
||||
lastActivityAt: string | null;
|
||||
notes: string[];
|
||||
isArchived: boolean;
|
||||
isInferred: boolean;
|
||||
};
|
||||
|
||||
export type TaskBoardPreference = {
|
||||
cards: TaskBoardCard[];
|
||||
selectedCardId: string | null;
|
||||
};
|
||||
|
||||
export type TaskBoardPreferencePatch = {
|
||||
cards?: TaskBoardCard[];
|
||||
selectedCardId?: string | null;
|
||||
};
|
||||
|
||||
export type TaskBoardExplicitEventKind =
|
||||
| "task_created"
|
||||
| "task_updated"
|
||||
| "task_status_changed"
|
||||
| "task_assigned"
|
||||
| "task_linked_to_run"
|
||||
| "task_deleted"
|
||||
| "task_archived"
|
||||
| "playbook_triggered";
|
||||
|
||||
export type TaskBoardExplicitEvent = {
|
||||
kind: TaskBoardExplicitEventKind;
|
||||
frame: EventFrame;
|
||||
taskId: string;
|
||||
title?: string | null;
|
||||
description?: string | null;
|
||||
status?: TaskBoardStatus | null;
|
||||
assignedAgentId?: string | null;
|
||||
playbookJobId?: string | null;
|
||||
runId?: string | null;
|
||||
channel?: string | null;
|
||||
externalThreadId?: string | null;
|
||||
occurredAt: string;
|
||||
sourceEventId: string;
|
||||
archived?: boolean;
|
||||
};
|
||||
|
||||
export const defaultTaskBoardPreference = (): TaskBoardPreference => ({
|
||||
cards: [],
|
||||
selectedCardId: null,
|
||||
});
|
||||
|
||||
export const isTaskBoardStatus = (value: unknown): value is TaskBoardStatus =>
|
||||
typeof value === "string" &&
|
||||
(TASK_BOARD_STATUSES as readonly string[]).includes(value);
|
||||
|
||||
export const isTaskBoardSource = (value: unknown): value is TaskBoardSource =>
|
||||
typeof value === "string" &&
|
||||
(TASK_BOARD_SOURCES as readonly string[]).includes(value);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,7 @@ import {
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
type ComponentProps,
|
||||
memo,
|
||||
Suspense,
|
||||
useCallback,
|
||||
@@ -29,6 +30,7 @@ import * as THREE from "three";
|
||||
import { SettingsPanel } from "@/features/office/components/panels/SettingsPanel";
|
||||
import { AtmImmersiveScreen } from "@/features/office/screens/AtmImmersiveScreen";
|
||||
import { GithubImmersiveScreen } from "@/features/office/screens/GithubImmersiveScreen";
|
||||
import { KanbanImmersiveScreen } from "@/features/office/screens/KanbanImmersiveScreen";
|
||||
import {
|
||||
PhoneBoothImmersiveScreen,
|
||||
type PhoneCallStep,
|
||||
@@ -39,6 +41,8 @@ import {
|
||||
} from "@/features/office/screens/SmsBoothImmersiveScreen";
|
||||
import { StandupImmersiveScreen } from "@/features/office/screens/StandupImmersiveScreen";
|
||||
import type { OfficeUsageAnalyticsParams } from "@/features/office/hooks/useOfficeUsageAnalyticsViewModel";
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
import type { CronJobSummary } from "@/lib/cron/types";
|
||||
import { buildMockPhoneCallScenario } from "@/lib/office/call/mock";
|
||||
import type { MockPhoneCallScenario } from "@/lib/office/call/types";
|
||||
import { buildMockTextMessageScenario } from "@/lib/office/text/mock";
|
||||
@@ -47,6 +51,10 @@ import type { OfficeDeskMonitor } from "@/lib/office/deskMonitor";
|
||||
import type { OfficeAnimationState } from "@/lib/office/eventTriggers";
|
||||
import type { StandupMeeting } from "@/lib/office/standup/types";
|
||||
import type { SkillStatusEntry } from "@/lib/skills/types";
|
||||
import type {
|
||||
TaskBoardCard,
|
||||
TaskBoardStatus,
|
||||
} from "@/features/office/tasks/types";
|
||||
import { extractSpeechImage } from "@/lib/text/speech-image";
|
||||
import { MonitorImmersiveContent as MonitorImmersiveOverlay } from "@/features/retro-office/overlays/MonitorImmersiveContent";
|
||||
import {
|
||||
@@ -72,6 +80,7 @@ import {
|
||||
import {
|
||||
ensureOfficeAtm,
|
||||
ensureOfficeGymRoom,
|
||||
ensureOfficeKanbanBoard,
|
||||
ensureOfficePhoneBooth,
|
||||
ensureOfficePingPongTable,
|
||||
ensureOfficeQaLab,
|
||||
@@ -209,7 +218,6 @@ import {
|
||||
SpotlightEffect as SceneSpotlightEffect,
|
||||
} from "@/features/retro-office/systems/sceneRuntime";
|
||||
import {
|
||||
DeskNameplates as DeskNameplateOverlay,
|
||||
HeatmapSystem as AgentHeatmapSystem,
|
||||
TrailSystem as AgentTrailSystem,
|
||||
} from "@/features/retro-office/systems/visualSystems";
|
||||
@@ -362,6 +370,12 @@ const PALETTE: PaletteEntry[] = [
|
||||
{ type: "water_cooler", label: "Water", icon: "💧", defaults: {} },
|
||||
{ type: "atm", label: "ATM", icon: "🏧", defaults: { facing: 270 } },
|
||||
{ type: "jukebox", label: "Jukebox", icon: "🎵", defaults: { facing: 0 } },
|
||||
{
|
||||
type: "kanban_board",
|
||||
label: "Kanban Board",
|
||||
icon: "📌",
|
||||
defaults: { w: 130, h: 65, facing: 90 },
|
||||
},
|
||||
{
|
||||
type: "whiteboard",
|
||||
label: "Whiteboard",
|
||||
@@ -2316,6 +2330,7 @@ export function RetroOffice3D({
|
||||
monitorAgentId = null,
|
||||
monitorByAgentId = EMPTY_MONITOR_MAP,
|
||||
githubSkill = null,
|
||||
taskManagerEnabled = false,
|
||||
soundclawEnabled = false,
|
||||
officeTitle = "Luke Headquarters",
|
||||
officeTitleLoaded = false,
|
||||
@@ -2365,6 +2380,28 @@ export function RetroOffice3D({
|
||||
onQaLabDismiss,
|
||||
onOpenGithubSkillSetup,
|
||||
onJukeboxInteract,
|
||||
onKanbanInteract,
|
||||
taskBoardAgents = [],
|
||||
taskBoardCardsByStatus = {
|
||||
todo: [],
|
||||
in_progress: [],
|
||||
blocked: [],
|
||||
review: [],
|
||||
done: [],
|
||||
},
|
||||
taskBoardSelectedCard = null,
|
||||
taskBoardActiveRuns = [],
|
||||
taskBoardCronJobs = [],
|
||||
taskBoardCronLoading = false,
|
||||
taskBoardCronError = null,
|
||||
taskBoardCaptureDebug,
|
||||
preferredKanbanAgentId = null,
|
||||
onTaskBoardCreateCard,
|
||||
onTaskBoardMoveCard,
|
||||
onTaskBoardSelectCard,
|
||||
onTaskBoardUpdateCard,
|
||||
onTaskBoardDeleteCard,
|
||||
onTaskBoardRefreshCronJobs,
|
||||
}: {
|
||||
agents: OfficeAgent[];
|
||||
officeCenterSignal?: number;
|
||||
@@ -2398,6 +2435,7 @@ export function RetroOffice3D({
|
||||
monitorAgentId?: string | null;
|
||||
monitorByAgentId?: OfficeDeskMonitorMap;
|
||||
githubSkill?: SkillStatusEntry | null;
|
||||
taskManagerEnabled?: boolean;
|
||||
soundclawEnabled?: boolean;
|
||||
officeTitle?: string;
|
||||
officeTitleLoaded?: boolean;
|
||||
@@ -2453,10 +2491,44 @@ export function RetroOffice3D({
|
||||
onQaLabDismiss?: () => void;
|
||||
onOpenGithubSkillSetup?: () => void;
|
||||
onJukeboxInteract?: () => void;
|
||||
onKanbanInteract?: () => void;
|
||||
taskBoardAgents?: AgentState[];
|
||||
taskBoardCardsByStatus?: Record<TaskBoardStatus, TaskBoardCard[]>;
|
||||
taskBoardSelectedCard?: TaskBoardCard | null;
|
||||
taskBoardActiveRuns?: Array<{
|
||||
runId: string;
|
||||
agentId: string;
|
||||
label: string;
|
||||
}>;
|
||||
taskBoardCronJobs?: CronJobSummary[];
|
||||
taskBoardCronLoading?: boolean;
|
||||
taskBoardCronError?: string | null;
|
||||
taskBoardCaptureDebug?: ComponentProps<
|
||||
typeof KanbanImmersiveScreen
|
||||
>["taskCaptureDebug"];
|
||||
preferredKanbanAgentId?: string | null;
|
||||
onTaskBoardCreateCard?: () => void;
|
||||
onTaskBoardMoveCard?: (cardId: string, status: TaskBoardStatus) => void;
|
||||
onTaskBoardSelectCard?: (cardId: string | null) => void;
|
||||
onTaskBoardUpdateCard?: (
|
||||
cardId: string,
|
||||
patch: Partial<TaskBoardCard>,
|
||||
) => void;
|
||||
onTaskBoardDeleteCard?: (cardId: string) => void;
|
||||
onTaskBoardRefreshCronJobs?: () => void;
|
||||
}) {
|
||||
const resolvedCleaningCues = animationState?.cleaningCues ?? cleaningCues;
|
||||
const resolvedDanceUntilByAgentId =
|
||||
animationState?.danceUntilByAgentId ?? EMPTY_NUMBER_RECORD;
|
||||
const kanbanDeskTaskCount = useMemo(
|
||||
() =>
|
||||
Object.entries(taskBoardCardsByStatus).reduce(
|
||||
(total, [status, cards]) =>
|
||||
status === "done" ? total : total + cards.length,
|
||||
0,
|
||||
),
|
||||
[taskBoardCardsByStatus],
|
||||
);
|
||||
const resolvedDeskHoldByAgentId =
|
||||
animationState?.deskHoldByAgentId ?? deskHoldByAgentId;
|
||||
const resolvedGymHoldByAgentId =
|
||||
@@ -2474,9 +2546,12 @@ export function RetroOffice3D({
|
||||
: EMPTY_BOOLEAN_RECORD);
|
||||
const resolvedJukeboxHoldByAgentId =
|
||||
animationState?.jukeboxHoldByAgentId ?? EMPTY_BOOLEAN_RECORD;
|
||||
const isJukeboxActive = Object.values(resolvedJukeboxHoldByAgentId).some(Boolean);
|
||||
const isJukeboxActive = Object.values(resolvedJukeboxHoldByAgentId).some(
|
||||
Boolean,
|
||||
);
|
||||
|
||||
const [furniture, setFurniture] = useState<FurnitureItem[]>(() =>
|
||||
ensureOfficeKanbanBoard(
|
||||
ensureOfficeJukebox(
|
||||
ensureOfficeQaLab(
|
||||
ensureOfficeGymRoom(
|
||||
@@ -2496,6 +2571,7 @@ export function RetroOffice3D({
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
const defaultRemoteLayoutFurniture = useMemo(
|
||||
() =>
|
||||
@@ -2536,6 +2612,7 @@ export function RetroOffice3D({
|
||||
const [spaceDown, setSpaceDown] = useState(false);
|
||||
const [spaceDragging, setSpaceDragging] = useState(false);
|
||||
const [standupBoardOpen, setStandupBoardOpen] = useState(false);
|
||||
const [activeKanbanUid, setActiveKanbanUid] = useState<string | null>(null);
|
||||
const [agentRosterOpen, setAgentRosterOpen] = useState(false);
|
||||
const autoOpenedStandupIdRef = useRef<string | null>(null);
|
||||
// Idea 1 (original): hovered agent for tooltip overlay.
|
||||
@@ -2549,6 +2626,8 @@ export function RetroOffice3D({
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
const [deskActionUid, setDeskActionUid] = useState<string | null>(null);
|
||||
const [deskAssignPickerOpen, setDeskAssignPickerOpen] = useState(false);
|
||||
// New Idea 3: speech bubble agent IDs.
|
||||
const [speechAgentIds, setSpeechAgentIds] = useState<Set<string>>(new Set());
|
||||
const statusFeedEvents = useMemo(
|
||||
@@ -2605,6 +2684,7 @@ export function RetroOffice3D({
|
||||
const followAgentIdRef = useRef<string | null>(null);
|
||||
const prevMonitorAgentIdRef = useRef<string | null>(null);
|
||||
const prevAtmUidRef = useRef<string | null>(null);
|
||||
const prevKanbanUidRef = useRef<string | null>(null);
|
||||
const prevSmsBoothViewRef = useRef<string | null>(null);
|
||||
const prevPhoneBoothViewRef = useRef<string | null>(null);
|
||||
const prevGithubViewRef = useRef<string | null>(null);
|
||||
@@ -2904,6 +2984,20 @@ export function RetroOffice3D({
|
||||
: null,
|
||||
[activeAtmUid, furniture],
|
||||
);
|
||||
const activeKanbanBoard = useMemo(
|
||||
() =>
|
||||
activeKanbanUid
|
||||
? (furniture.find(
|
||||
(item) =>
|
||||
item._uid === activeKanbanUid && item.type === "kanban_board",
|
||||
) ?? null)
|
||||
: null,
|
||||
[activeKanbanUid, furniture],
|
||||
);
|
||||
const kanbanBoardItem = useMemo(
|
||||
() => furniture.find((item) => item.type === "kanban_board") ?? null,
|
||||
[furniture],
|
||||
);
|
||||
const atmImmersive = Boolean(activeAtm && atmImmersiveReady);
|
||||
const activeSmsBooth = useMemo(
|
||||
() => furniture.find((item) => item.type === "sms_booth") ?? null,
|
||||
@@ -3008,6 +3102,7 @@ export function RetroOffice3D({
|
||||
(activeQaTerminalUid || (qaTestingAgentId && qaCommandArrived)),
|
||||
) && qaImmersiveReady;
|
||||
const standupImmersive = Boolean(standupBoardOpen && standupMeeting);
|
||||
const kanbanImmersive = Boolean(activeKanbanBoard);
|
||||
const immersiveOverlayActive =
|
||||
monitorImmersive ||
|
||||
atmImmersive ||
|
||||
@@ -3051,6 +3146,24 @@ export function RetroOffice3D({
|
||||
selectedItem?.type === "desk_cubicle"
|
||||
? (deskAssignmentByDeskUid[selectedItem._uid] ?? "")
|
||||
: "";
|
||||
const selectedDeskActionItem = useMemo(
|
||||
() =>
|
||||
deskActionUid
|
||||
? (furniture.find(
|
||||
(item) => item._uid === deskActionUid && item.type === "desk_cubicle",
|
||||
) ?? null)
|
||||
: null,
|
||||
[deskActionUid, furniture],
|
||||
);
|
||||
const selectedDeskActionAssignedAgentId =
|
||||
selectedDeskActionItem ? (deskAssignmentByDeskUid[selectedDeskActionItem._uid] ?? "") : "";
|
||||
const selectedDeskActionAssignedAgent = useMemo(
|
||||
() =>
|
||||
selectedDeskActionAssignedAgentId
|
||||
? (agents.find((agent) => agent.id === selectedDeskActionAssignedAgentId) ?? null)
|
||||
: null,
|
||||
[agents, selectedDeskActionAssignedAgentId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
@@ -3197,7 +3310,7 @@ export function RetroOffice3D({
|
||||
!activeGithubTerminalUid &&
|
||||
!activeQaTerminalUid
|
||||
) {
|
||||
cameraPresetRef.current = CAMERA_PRESET_MAP.overview;
|
||||
cameraPresetRef.current = overviewPresetRef.current;
|
||||
}
|
||||
}, [
|
||||
activeAtmUid,
|
||||
@@ -3226,7 +3339,7 @@ export function RetroOffice3D({
|
||||
!activeGithubTerminalUid &&
|
||||
!activeQaTerminalUid
|
||||
) {
|
||||
cameraPresetRef.current = CAMERA_PRESET_MAP.overview;
|
||||
cameraPresetRef.current = overviewPresetRef.current;
|
||||
}
|
||||
}, [
|
||||
activeAtmUid,
|
||||
@@ -3833,7 +3946,7 @@ export function RetroOffice3D({
|
||||
? `agent:${smsBoothAgentId}`
|
||||
: null;
|
||||
if (!activeViewKey && prevSmsBoothViewRef.current) {
|
||||
cameraPresetRef.current = CAMERA_PRESET_MAP.overview;
|
||||
cameraPresetRef.current = overviewPresetRef.current;
|
||||
}
|
||||
if (!activeViewKey || !activeSmsBooth) {
|
||||
prevSmsBoothViewRef.current = activeViewKey;
|
||||
@@ -3981,7 +4094,7 @@ export function RetroOffice3D({
|
||||
? `agent:${phoneBoothAgentId}`
|
||||
: null;
|
||||
if (!activeViewKey && prevPhoneBoothViewRef.current) {
|
||||
cameraPresetRef.current = CAMERA_PRESET_MAP.overview;
|
||||
cameraPresetRef.current = overviewPresetRef.current;
|
||||
}
|
||||
if (!activeViewKey || !activePhoneBooth) {
|
||||
prevPhoneBoothViewRef.current = activeViewKey;
|
||||
@@ -4128,7 +4241,7 @@ export function RetroOffice3D({
|
||||
|
||||
useEffect(() => {
|
||||
if (!monitorAgentId && prevMonitorAgentIdRef.current) {
|
||||
cameraPresetRef.current = CAMERA_PRESET_MAP.overview;
|
||||
cameraPresetRef.current = overviewPresetRef.current;
|
||||
}
|
||||
if (!monitorAgentId || !activeMonitorComputer) {
|
||||
prevMonitorAgentIdRef.current = monitorAgentId;
|
||||
@@ -4157,6 +4270,17 @@ export function RetroOffice3D({
|
||||
}
|
||||
}, [activeAtm, activeAtmUid]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeKanbanUid && !activeKanbanBoard) {
|
||||
const timer = window.setTimeout(() => {
|
||||
setActiveKanbanUid(null);
|
||||
}, 0);
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}
|
||||
}, [activeKanbanBoard, activeKanbanUid]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeGithubTerminalUid && !activeGithubTerminal) {
|
||||
const timer = window.setTimeout(() => {
|
||||
@@ -4181,7 +4305,7 @@ export function RetroOffice3D({
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeAtmUid && prevAtmUidRef.current) {
|
||||
cameraPresetRef.current = CAMERA_PRESET_MAP.overview;
|
||||
cameraPresetRef.current = overviewPresetRef.current;
|
||||
}
|
||||
if (!activeAtmUid || !activeAtm) {
|
||||
prevAtmUidRef.current = activeAtmUid;
|
||||
@@ -4204,6 +4328,10 @@ export function RetroOffice3D({
|
||||
prevAtmUidRef.current = activeAtmUid;
|
||||
}, [activeAtm, activeAtmUid]);
|
||||
|
||||
useEffect(() => {
|
||||
prevKanbanUidRef.current = activeKanbanUid;
|
||||
}, [activeKanbanUid]);
|
||||
|
||||
useEffect(() => {
|
||||
const activeViewKey = activeGithubTerminalUid
|
||||
? `manual:${activeGithubTerminalUid}`
|
||||
@@ -4211,7 +4339,7 @@ export function RetroOffice3D({
|
||||
? `agent:${githubReviewAgentId}`
|
||||
: null;
|
||||
if (!activeViewKey && prevGithubViewRef.current) {
|
||||
cameraPresetRef.current = CAMERA_PRESET_MAP.overview;
|
||||
cameraPresetRef.current = overviewPresetRef.current;
|
||||
}
|
||||
if (!activeViewKey || !activeGithubTerminal) {
|
||||
prevGithubViewRef.current = activeViewKey;
|
||||
@@ -4246,7 +4374,7 @@ export function RetroOffice3D({
|
||||
? `agent:${qaTestingAgentId}`
|
||||
: null;
|
||||
if (!activeViewKey && prevQaViewRef.current) {
|
||||
cameraPresetRef.current = CAMERA_PRESET_MAP.overview;
|
||||
cameraPresetRef.current = overviewPresetRef.current;
|
||||
}
|
||||
if (!activeViewKey || !activeQaTerminal) {
|
||||
prevQaViewRef.current = activeViewKey;
|
||||
@@ -4303,12 +4431,51 @@ export function RetroOffice3D({
|
||||
[deskByAgentRef, furniture],
|
||||
);
|
||||
|
||||
const openKanbanBoard = useCallback(
|
||||
(item: FurnitureItem | null) => {
|
||||
if (!item || item.type !== "kanban_board") return;
|
||||
if (!taskManagerEnabled) {
|
||||
setActiveKanbanUid(null);
|
||||
onKanbanInteract?.();
|
||||
return;
|
||||
}
|
||||
setFollowAgentId(null);
|
||||
setActiveAtmUid(null);
|
||||
setActiveGithubTerminalUid(null);
|
||||
setActiveQaTerminalUid(null);
|
||||
if (manualSmsBoothOpen) {
|
||||
closeManualSmsBoothView();
|
||||
}
|
||||
if (manualPhoneBoothOpen) {
|
||||
closeManualPhoneBoothView();
|
||||
}
|
||||
onMonitorSelect?.(null);
|
||||
setActiveKanbanUid(item._uid);
|
||||
},
|
||||
[
|
||||
closeManualPhoneBoothView,
|
||||
closeManualSmsBoothView,
|
||||
manualPhoneBoothOpen,
|
||||
manualSmsBoothOpen,
|
||||
onMonitorSelect,
|
||||
onKanbanInteract,
|
||||
taskManagerEnabled,
|
||||
],
|
||||
);
|
||||
|
||||
// E3 Idea 2: click a desk to send its assigned agent to walk and sit there.
|
||||
const handleDeskClick = useCallback(
|
||||
(uid: string) => {
|
||||
if (editMode) return;
|
||||
const item = furniture.find((f) => f._uid === uid);
|
||||
if (!item) return;
|
||||
if (item.type !== "desk_cubicle") {
|
||||
setDeskActionUid(null);
|
||||
setDeskAssignPickerOpen(false);
|
||||
}
|
||||
if (item.type !== "kanban_board" && activeKanbanUid) {
|
||||
setActiveKanbanUid(null);
|
||||
}
|
||||
if (item.type !== "sms_booth" && manualSmsBoothOpen) {
|
||||
closeManualSmsBoothView();
|
||||
}
|
||||
@@ -4400,6 +4567,7 @@ export function RetroOffice3D({
|
||||
}
|
||||
if (item.type === "atm") {
|
||||
setFollowAgentId(null);
|
||||
setActiveKanbanUid(null);
|
||||
setActiveGithubTerminalUid(null);
|
||||
setActiveQaTerminalUid(null);
|
||||
onMonitorSelect?.(null);
|
||||
@@ -4408,6 +4576,7 @@ export function RetroOffice3D({
|
||||
}
|
||||
if (item.type === "sms_booth") {
|
||||
setFollowAgentId(null);
|
||||
setActiveKanbanUid(null);
|
||||
setActiveAtmUid(null);
|
||||
setActiveGithubTerminalUid(null);
|
||||
setActiveQaTerminalUid(null);
|
||||
@@ -4425,6 +4594,7 @@ export function RetroOffice3D({
|
||||
}
|
||||
if (item.type === "phone_booth") {
|
||||
setFollowAgentId(null);
|
||||
setActiveKanbanUid(null);
|
||||
setActiveAtmUid(null);
|
||||
setActiveGithubTerminalUid(null);
|
||||
setActiveQaTerminalUid(null);
|
||||
@@ -4446,6 +4616,7 @@ export function RetroOffice3D({
|
||||
}
|
||||
if (item.type === "server_terminal") {
|
||||
setFollowAgentId(null);
|
||||
setActiveKanbanUid(null);
|
||||
setActiveAtmUid(null);
|
||||
setActiveQaTerminalUid(null);
|
||||
onMonitorSelect?.(null);
|
||||
@@ -4454,6 +4625,7 @@ export function RetroOffice3D({
|
||||
}
|
||||
if (item.type === "server_rack") {
|
||||
setFollowAgentId(null);
|
||||
setActiveKanbanUid(null);
|
||||
setActiveAtmUid(null);
|
||||
setActiveQaTerminalUid(null);
|
||||
onMonitorSelect?.(null);
|
||||
@@ -4466,6 +4638,7 @@ export function RetroOffice3D({
|
||||
item.type === "test_bench"
|
||||
) {
|
||||
setFollowAgentId(null);
|
||||
setActiveKanbanUid(null);
|
||||
setActiveAtmUid(null);
|
||||
setActiveGithubTerminalUid(null);
|
||||
onMonitorSelect?.(null);
|
||||
@@ -4474,6 +4647,10 @@ export function RetroOffice3D({
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (item.type === "kanban_board") {
|
||||
openKanbanBoard(item);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
item.type === "round_table" &&
|
||||
item.x >= 0 &&
|
||||
@@ -4484,9 +4661,9 @@ export function RetroOffice3D({
|
||||
onStandupStartRequested?.();
|
||||
return;
|
||||
}
|
||||
if (item.type === "computer") {
|
||||
const agentId = resolveAgentIdForDeskItem(uid);
|
||||
if (!agentId) return;
|
||||
if (item.type === "computer") {
|
||||
setActiveGithubTerminalUid(null);
|
||||
setActiveQaTerminalUid(null);
|
||||
setActiveAtmUid(null);
|
||||
@@ -4494,21 +4671,8 @@ export function RetroOffice3D({
|
||||
return;
|
||||
}
|
||||
if (item.type !== "desk_cubicle") return;
|
||||
setActiveGithubTerminalUid(null);
|
||||
setActiveQaTerminalUid(null);
|
||||
setActiveAtmUid(null);
|
||||
const agent = renderAgentLookupRef.current.get(agentId);
|
||||
if (!agent) return;
|
||||
const tx = item.x + 40;
|
||||
const ty = item.y - 5;
|
||||
const path = planPath(agent.x, agent.y, tx, ty);
|
||||
// Mutate the render agent ref directly so the tick loop picks it up.
|
||||
Object.assign(agent, {
|
||||
targetX: tx,
|
||||
targetY: ty,
|
||||
path,
|
||||
state: "walking",
|
||||
});
|
||||
setDeskActionUid(item._uid);
|
||||
setDeskAssignPickerOpen(false);
|
||||
},
|
||||
[
|
||||
closeManualSmsBoothView,
|
||||
@@ -4517,12 +4681,11 @@ export function RetroOffice3D({
|
||||
furniture,
|
||||
manualSmsBoothOpen,
|
||||
manualPhoneBoothOpen,
|
||||
activeKanbanUid,
|
||||
openKanbanBoard,
|
||||
onMonitorSelect,
|
||||
onStandupStartRequested,
|
||||
planPath,
|
||||
qaTerminal,
|
||||
renderAgentsRef,
|
||||
renderAgentLookupRef,
|
||||
resolveAgentIdForDeskItem,
|
||||
serverTerminal,
|
||||
voiceRepliesEnabled,
|
||||
@@ -4531,6 +4694,36 @@ export function RetroOffice3D({
|
||||
],
|
||||
);
|
||||
|
||||
const sendAssignedAgentToDesk = useCallback(
|
||||
(deskItem: FurnitureItem) => {
|
||||
const agentId = deskAssignmentByDeskUid[deskItem._uid];
|
||||
if (!agentId) return;
|
||||
const agent = renderAgentLookupRef.current.get(agentId);
|
||||
if (!agent) return;
|
||||
const tx = deskItem.x + 40;
|
||||
const ty = deskItem.y - 5;
|
||||
const path = planPath(agent.x, agent.y, tx, ty);
|
||||
Object.assign(agent, {
|
||||
targetX: tx,
|
||||
targetY: ty,
|
||||
path,
|
||||
state: "walking",
|
||||
});
|
||||
},
|
||||
[deskAssignmentByDeskUid, planPath, renderAgentLookupRef],
|
||||
);
|
||||
|
||||
const handleGoToDesk = useCallback(() => {
|
||||
if (!selectedDeskActionItem) return;
|
||||
setActiveKanbanUid(null);
|
||||
setActiveGithubTerminalUid(null);
|
||||
setActiveQaTerminalUid(null);
|
||||
setActiveAtmUid(null);
|
||||
sendAssignedAgentToDesk(selectedDeskActionItem);
|
||||
setDeskActionUid(null);
|
||||
setDeskAssignPickerOpen(false);
|
||||
}, [selectedDeskActionItem, sendAssignedAgentToDesk]);
|
||||
|
||||
const handleFurniturePointerOver = useCallback(
|
||||
(uid: string) => setHoverUid(uid),
|
||||
[],
|
||||
@@ -4545,7 +4738,7 @@ export function RetroOffice3D({
|
||||
!activeGithubTerminalUid &&
|
||||
!activeQaTerminalUid
|
||||
) {
|
||||
cameraPresetRef.current = CAMERA_PRESET_MAP.overview;
|
||||
cameraPresetRef.current = overviewPresetRef.current;
|
||||
}
|
||||
}, [
|
||||
activeAtmUid,
|
||||
@@ -4575,6 +4768,7 @@ export function RetroOffice3D({
|
||||
hoveredItem?.type === "device_rack" ||
|
||||
hoveredItem?.type === "test_bench" ||
|
||||
hoveredItem?.type === "server_terminal" ||
|
||||
hoveredItem?.type === "kanban_board" ||
|
||||
hoveredMeetingTable
|
||||
? "pointer"
|
||||
: "";
|
||||
@@ -4747,7 +4941,11 @@ export function RetroOffice3D({
|
||||
setHoverUid(null);
|
||||
setGhostPos(null);
|
||||
setWallDrawStart(null);
|
||||
} else setDrawerOpen(true);
|
||||
} else {
|
||||
setDrawerOpen(true);
|
||||
setDeskActionUid(null);
|
||||
setDeskAssignPickerOpen(false);
|
||||
}
|
||||
return !prev;
|
||||
});
|
||||
};
|
||||
@@ -4887,6 +5085,16 @@ export function RetroOffice3D({
|
||||
return () => window.removeEventListener("pointerdown", dismiss);
|
||||
}, [contextMenu]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!deskActionUid) return;
|
||||
const dismiss = () => {
|
||||
setDeskActionUid(null);
|
||||
setDeskAssignPickerOpen(false);
|
||||
};
|
||||
window.addEventListener("pointerdown", dismiss);
|
||||
return () => window.removeEventListener("pointerdown", dismiss);
|
||||
}, [deskActionUid]);
|
||||
|
||||
// New Idea 3: show speech bubble based on reply length.
|
||||
useEffect(() => {
|
||||
if (feedEvents.length === 0) return;
|
||||
@@ -4951,31 +5159,36 @@ export function RetroOffice3D({
|
||||
}, [spotlightAgentId]);
|
||||
|
||||
// Camera constants.
|
||||
const CAM_POS = DISTRICT_CAMERA_POSITION;
|
||||
const LOCAL_CAMERA_TARGET = useMemo(
|
||||
() =>
|
||||
toWorld(LOCAL_OFFICE_CANVAS_WIDTH / 2, LOCAL_OFFICE_CANVAS_HEIGHT / 2),
|
||||
[],
|
||||
);
|
||||
const CAM_POS = useMemo<[number, number, number]>(() => {
|
||||
if (remoteOfficeEnabled) return DISTRICT_CAMERA_POSITION;
|
||||
return [
|
||||
LOCAL_CAMERA_TARGET[0] + (DISTRICT_CAMERA_POSITION[0] - DISTRICT_CAMERA_TARGET[0]),
|
||||
LOCAL_CAMERA_TARGET[1] + (DISTRICT_CAMERA_POSITION[1] - DISTRICT_CAMERA_TARGET[1]),
|
||||
LOCAL_CAMERA_TARGET[2] + (DISTRICT_CAMERA_POSITION[2] - DISTRICT_CAMERA_TARGET[2]),
|
||||
];
|
||||
}, [remoteOfficeEnabled, LOCAL_CAMERA_TARGET]);
|
||||
const cameraTarget = remoteOfficeEnabled
|
||||
? DISTRICT_CAMERA_TARGET
|
||||
: LOCAL_CAMERA_TARGET;
|
||||
const cameraZoom = remoteOfficeEnabled ? DISTRICT_CAMERA_ZOOM : 56;
|
||||
const overviewPresetRef = useRef({ pos: CAM_POS, target: cameraTarget, zoom: cameraZoom });
|
||||
overviewPresetRef.current = { pos: CAM_POS, target: cameraTarget, zoom: cameraZoom };
|
||||
const lastOfficeCenterSignalRef = useRef(officeCenterSignal);
|
||||
|
||||
useEffect(() => {
|
||||
cameraPresetRef.current = {
|
||||
pos: CAM_POS,
|
||||
target: cameraTarget,
|
||||
zoom: cameraZoom,
|
||||
};
|
||||
cameraPresetRef.current = overviewPresetRef.current;
|
||||
}, [CAM_POS, cameraTarget, cameraZoom]);
|
||||
|
||||
useEffect(() => {
|
||||
if (officeCenterSignal === lastOfficeCenterSignalRef.current) return;
|
||||
lastOfficeCenterSignalRef.current = officeCenterSignal;
|
||||
cameraPresetRef.current = CAMERA_PRESET_MAP.overview;
|
||||
}, [officeCenterSignal]);
|
||||
cameraPresetRef.current = overviewPresetRef.current;
|
||||
}, [officeCenterSignal, CAM_POS, cameraTarget, cameraZoom]);
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full bg-[#1a1008] font-mono text-white overflow-hidden">
|
||||
@@ -5017,13 +5230,14 @@ export function RetroOffice3D({
|
||||
if (drag.kind === "moving") setDrag({ kind: "idle" });
|
||||
}}
|
||||
>
|
||||
{/* Ensure camera looks at origin after mount. */}
|
||||
{/* Ensure camera looks at the active office target after mount. */}
|
||||
<CameraRig target={cameraTarget} />
|
||||
<AdaptiveDprController />
|
||||
|
||||
{/* Orbit / pan / zoom controls — disabled while follow cam is active or while editing furniture. */}
|
||||
<OrbitControls
|
||||
ref={orbitRef}
|
||||
target={cameraTarget}
|
||||
enabled={followAgentId === null && (!editMode || spaceDown)}
|
||||
enableDamping
|
||||
dampingFactor={0.08}
|
||||
@@ -5245,7 +5459,9 @@ export function RetroOffice3D({
|
||||
onPointerDown={handleFurniturePointerDown}
|
||||
onPointerOver={handleFurniturePointerOver}
|
||||
onPointerOut={handleFurniturePointerOut}
|
||||
onClick={editMode ? handleDeskClick : () => onJukeboxInteract?.()}
|
||||
onClick={
|
||||
editMode ? handleDeskClick : () => onJukeboxInteract?.()
|
||||
}
|
||||
/>
|
||||
) : item.type === "sms_booth" ? (
|
||||
<InteractiveSmsBoothModel
|
||||
@@ -5502,6 +5718,11 @@ export function RetroOffice3D({
|
||||
isSelected={item._uid === selectedUid}
|
||||
isHovered={item._uid === hoverUid}
|
||||
editMode={editMode}
|
||||
kanbanTaskCount={
|
||||
item.type === "kanban_board"
|
||||
? kanbanDeskTaskCount
|
||||
: undefined
|
||||
}
|
||||
onPointerDown={handleFurniturePointerDown}
|
||||
onPointerOver={handleFurniturePointerOver}
|
||||
onPointerOut={handleFurniturePointerOut}
|
||||
@@ -5562,13 +5783,6 @@ export function RetroOffice3D({
|
||||
|
||||
<ScenePingPongBall agentsRef={renderAgentsRef} />
|
||||
|
||||
{/* Idea 7: Desk nameplates — small labels showing assigned agent above each desk. */}
|
||||
<DeskNameplateOverlay
|
||||
deskLocations={deskLocations}
|
||||
agents={agents}
|
||||
deskByAgentRef={deskByAgentRef}
|
||||
/>
|
||||
|
||||
{/* New Idea 5: Agent color trails while walking. */}
|
||||
{trailMode ? (
|
||||
<AgentTrailSystem
|
||||
@@ -5693,7 +5907,7 @@ export function RetroOffice3D({
|
||||
? "Gathering in meeting room."
|
||||
: standupMeeting.phase === "in_progress"
|
||||
? `Speaking: ${standupSpeakerCard?.agentName ?? "Team"}`
|
||||
: "Standup complete."}
|
||||
: ""}
|
||||
</div>
|
||||
<div className="mt-1 font-mono text-[10px] text-white/50">
|
||||
{standupMeeting.arrivedAgentIds.length}/
|
||||
@@ -5701,6 +5915,17 @@ export function RetroOffice3D({
|
||||
</div>
|
||||
</button>
|
||||
) : null}
|
||||
{kanbanBoardItem ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openKanbanBoard(kanbanBoardItem)}
|
||||
className="rounded-xl border border-cyan-500/22 bg-[#09111a]/90 px-3 py-2 text-left shadow-lg backdrop-blur-sm transition-colors hover:border-cyan-300/40 hover:bg-[#0d1b28]/95"
|
||||
>
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.16em] text-cyan-200/80">
|
||||
Kanban board
|
||||
</div>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -6057,6 +6282,84 @@ export function RetroOffice3D({
|
||||
);
|
||||
})()}
|
||||
|
||||
{!immersiveOverlayActive && !editMode && selectedDeskActionItem ? (
|
||||
<div className="pointer-events-none absolute inset-0 z-40 flex items-center justify-center">
|
||||
<div
|
||||
className="pointer-events-auto w-[320px] rounded-xl border border-amber-800/25 bg-[#120e08]/95 p-3 shadow-2xl backdrop-blur-sm"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-[10px] font-bold uppercase tracking-[0.22em] text-amber-500/70">
|
||||
Desk actions
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setDeskActionUid(null);
|
||||
setDeskAssignPickerOpen(false);
|
||||
}}
|
||||
className="rounded border border-amber-900/25 px-2 py-0.5 text-[10px] text-amber-200/70 transition-colors hover:border-amber-600/40 hover:text-amber-100"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2 rounded-md border border-amber-900/20 bg-[#1a120b] px-2.5 py-2 text-[11px] text-amber-100/90">
|
||||
{selectedDeskActionAssignedAgent ? (
|
||||
<>
|
||||
Assigned agent:{" "}
|
||||
<span className="font-semibold text-white">
|
||||
{selectedDeskActionAssignedAgent.name}
|
||||
</span>
|
||||
.
|
||||
</>
|
||||
) : (
|
||||
"Assigned agent: Unassigned."
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGoToDesk}
|
||||
disabled={!selectedDeskActionAssignedAgentId}
|
||||
className="rounded-md border border-emerald-700/35 bg-emerald-900/20 px-2 py-2 text-[11px] font-semibold text-emerald-100 transition-colors hover:bg-emerald-800/30 disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
Go to desk
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeskAssignPickerOpen((prev) => !prev)}
|
||||
disabled={!onDeskAssignmentChange}
|
||||
className="rounded-md border border-amber-700/35 bg-amber-900/18 px-2 py-2 text-[11px] font-semibold text-amber-100 transition-colors hover:bg-amber-800/30 disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
Assign agent
|
||||
</button>
|
||||
</div>
|
||||
{deskAssignPickerOpen && onDeskAssignmentChange ? (
|
||||
<div className="mt-2">
|
||||
<select
|
||||
value={selectedDeskActionAssignedAgentId}
|
||||
onChange={(event) => {
|
||||
const nextAgentId = event.target.value.trim();
|
||||
onDeskAssignmentChange(
|
||||
selectedDeskActionItem._uid,
|
||||
nextAgentId || null,
|
||||
);
|
||||
}}
|
||||
className="w-full rounded-md border border-amber-800/25 bg-[#1c1610] px-2 py-2 text-[11px] text-amber-100 outline-none transition-colors focus:border-amber-500/50"
|
||||
>
|
||||
<option value="">Unassigned desk.</option>
|
||||
{agents.map((agent) => (
|
||||
<option key={agent.id} value={agent.id}>
|
||||
{agent.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Follow cam HUD — shown while a third-person follow camera is active. */}
|
||||
{!immersiveOverlayActive &&
|
||||
followAgentId &&
|
||||
@@ -6141,6 +6444,28 @@ export function RetroOffice3D({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{kanbanImmersive ? (
|
||||
<KanbanImmersiveScreen
|
||||
agents={taskBoardAgents}
|
||||
cardsByStatus={taskBoardCardsByStatus}
|
||||
selectedCard={taskBoardSelectedCard}
|
||||
activeRuns={taskBoardActiveRuns}
|
||||
cronJobs={taskBoardCronJobs}
|
||||
cronLoading={taskBoardCronLoading}
|
||||
cronError={taskBoardCronError}
|
||||
taskCaptureDebug={taskBoardCaptureDebug}
|
||||
onCreateCard={() => onTaskBoardCreateCard?.()}
|
||||
onMoveCard={(cardId, status) => onTaskBoardMoveCard?.(cardId, status)}
|
||||
onSelectCard={(cardId) => onTaskBoardSelectCard?.(cardId)}
|
||||
onUpdateCard={(cardId, patch) =>
|
||||
onTaskBoardUpdateCard?.(cardId, patch)
|
||||
}
|
||||
onDeleteCard={(cardId) => onTaskBoardDeleteCard?.(cardId)}
|
||||
onRefreshCronJobs={() => onTaskBoardRefreshCronJobs?.()}
|
||||
onClose={() => setActiveKanbanUid(null)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{githubImmersive ? (
|
||||
<div className="pointer-events-none absolute inset-0 z-20 overflow-hidden">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top,rgba(34,211,238,0.12),rgba(0,0,0,0.84))]" />
|
||||
|
||||
@@ -60,6 +60,13 @@ const DEFAULT_JUKEBOX: FurnitureSeed = {
|
||||
facing: 90,
|
||||
};
|
||||
|
||||
const DEFAULT_KANBAN_BOARD: FurnitureSeed = {
|
||||
type: "kanban_board",
|
||||
x: 460,
|
||||
y: -60,
|
||||
facing: 180,
|
||||
};
|
||||
|
||||
const PREVIOUS_SERVER_ROOM_ITEMS_BOTTOM_RIGHT: FurnitureSeed[] = [
|
||||
{ type: "wall", x: 820, y: 540, w: 280, h: WALL_THICKNESS },
|
||||
{ type: "wall", x: 820, y: 540, w: WALL_THICKNESS, h: 70 },
|
||||
@@ -419,9 +426,8 @@ const DEFAULT_FURNITURE: FurnitureSeed[] = [
|
||||
{ type: "chair", x: 120, y: 480, facing: 180 },
|
||||
{ type: "chair", x: 50, y: 150, facing: 105 },
|
||||
{ type: "chair", x: 60, y: 80, facing: 60 },
|
||||
{ type: "executive_desk", x: 420, y: 60, w: 130, h: 65 },
|
||||
{ type: "chair", x: 540, y: 60, facing: 0 },
|
||||
{ type: "bookshelf", x: 500, y: 30, w: 80, h: 120 },
|
||||
{ type: "chair", x: 550, y: 50, facing: 0 },
|
||||
{ type: "bookshelf", x: 600, y: 30, w: 80, h: 120 },
|
||||
{ type: "couch", x: 270, y: 90, w: 40, h: 80, vertical: true, facing: 180 },
|
||||
{ type: "fridge", x: 1050, y: 20, w: 40, h: 80 },
|
||||
{ type: "stove", x: 920, y: 20 },
|
||||
@@ -433,7 +439,11 @@ const DEFAULT_FURNITURE: FurnitureSeed[] = [
|
||||
{ type: "coffee_machine", x: 880, y: 30, elevation: 0.56 },
|
||||
{ type: "wall_cabinet", x: 960, y: 10, w: 80, h: 20, elevation: 0.9 },
|
||||
{ type: "wall_cabinet", x: 880, y: 10, w: 80, h: 20, elevation: 0.9 },
|
||||
...DEFAULT_DINING_ITEMS,
|
||||
{ type: "round_table", x: 890, y: 100, r: 50 },
|
||||
{ type: "chair", x: 930, y: 100, facing: 0 },
|
||||
{ type: "chair", x: 930, y: 180, facing: 180 },
|
||||
{ type: "chair", x: 880, y: 130, facing: 90 },
|
||||
{ type: "chair", x: 970, y: 130, facing: 270 },
|
||||
{ type: "vending", x: 790, y: 10 },
|
||||
{ type: "trash", x: 210, y: 20 },
|
||||
{ type: "desk_cubicle", x: 100, y: 300, id: "desk_0" },
|
||||
@@ -486,11 +496,12 @@ const DEFAULT_FURNITURE: FurnitureSeed[] = [
|
||||
{ type: "couch", x: 1000, y: 380, w: 100, h: 40, facing: 90 },
|
||||
{ type: "couch", x: 390, y: 630, w: 100, h: 40 },
|
||||
{ type: "table_rect", x: 980, y: 380, w: 60, h: 30, facing: 270 },
|
||||
DEFAULT_PINGPONG_TABLE,
|
||||
{ type: "pingpong", x: 950, y: 600, w: 100, h: 60 },
|
||||
{ type: "beanbag", x: 1000, y: 330, color: "#e65100", facing: 90 },
|
||||
{ type: "beanbag", x: 1000, y: 410, color: "#1565c0", facing: 90 },
|
||||
DEFAULT_ATM_MACHINE,
|
||||
DEFAULT_PHONE_BOOTH,
|
||||
DEFAULT_KANBAN_BOARD,
|
||||
{ type: "whiteboard", x: 40, y: 200, w: 10, h: 60 },
|
||||
{ type: "clock", x: 550, y: 5 },
|
||||
{ type: "lamp", x: 430, y: 100 },
|
||||
@@ -595,6 +606,11 @@ export const ensureOfficeJukebox = (items: FurnitureItem[]): FurnitureItem[] =>
|
||||
return [...items, { ...DEFAULT_JUKEBOX, _uid: nextUid() }];
|
||||
};
|
||||
|
||||
export const ensureOfficeKanbanBoard = (items: FurnitureItem[]): FurnitureItem[] => {
|
||||
if (items.some((item) => item.type === "kanban_board")) return items;
|
||||
return [...items, { ...DEFAULT_KANBAN_BOARD, _uid: nextUid() }];
|
||||
};
|
||||
|
||||
export const ensureOfficePhoneBooth = (
|
||||
items: FurnitureItem[],
|
||||
): FurnitureItem[] => {
|
||||
|
||||
@@ -67,6 +67,7 @@ export const ITEM_FOOTPRINT: Record<string, [number, number]> = {
|
||||
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],
|
||||
@@ -149,6 +150,7 @@ export const ITEM_METADATA: Record<string, { blocksNavigation: boolean; navPaddi
|
||||
phone_booth: { blocksNavigation: true },
|
||||
// ── QA lab ────────────────────────────────────────────────────────────────
|
||||
qa_terminal: { blocksNavigation: true },
|
||||
kanban_board: { blocksNavigation: true },
|
||||
device_rack: { blocksNavigation: true },
|
||||
test_bench: { blocksNavigation: true },
|
||||
// ── gym ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -19,6 +19,7 @@ export type InteractiveFurnitureModelProps = {
|
||||
isSelected: boolean;
|
||||
isHovered: boolean;
|
||||
editMode: boolean;
|
||||
kanbanTaskCount?: number;
|
||||
doorOpen?: boolean;
|
||||
onPointerDown: (uid: string) => void;
|
||||
onPointerOver: (uid: string) => void;
|
||||
|
||||
@@ -245,22 +245,24 @@ export function DeskNameplates({
|
||||
const [wx, , wz] = toWorld(desk.x, desk.y);
|
||||
|
||||
return (
|
||||
<Billboard key={`nameplate-${index}`} position={[wx, 0.55, wz]}>
|
||||
<Billboard key={`nameplate-${index}`} position={[wx, 1.02, wz]}>
|
||||
<mesh position={[0, 0, -0.001]}>
|
||||
<planeGeometry args={[1.1, 0.18]} />
|
||||
<meshBasicMaterial color="#0a0804" transparent opacity={0.75} />
|
||||
<planeGeometry args={[0.74, 0.16]} />
|
||||
<meshBasicMaterial color="#050403" transparent opacity={0.86} />
|
||||
</mesh>
|
||||
<mesh position={[-0.52, 0, 0]}>
|
||||
<planeGeometry args={[0.04, 0.18]} />
|
||||
<mesh position={[-0.35, 0, 0]}>
|
||||
<planeGeometry args={[0.035, 0.16]} />
|
||||
<meshBasicMaterial color={agent.color} />
|
||||
</mesh>
|
||||
<Text
|
||||
position={[0.02, 0, 0.001]}
|
||||
fontSize={0.09}
|
||||
color="#c8a860"
|
||||
position={[0.01, 0.002, 0.001]}
|
||||
fontSize={0.11}
|
||||
color="#fff6d8"
|
||||
anchorX="center"
|
||||
anchorY="middle"
|
||||
maxWidth={1.0}
|
||||
maxWidth={0.64}
|
||||
outlineWidth={0.007}
|
||||
outlineColor="#16110a"
|
||||
font={undefined}
|
||||
overflowWrap="break-word"
|
||||
whiteSpace="nowrap"
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import type { AgentAvatarProfile } from "./profile";
|
||||
|
||||
const AVATAR_BG = "#070b16";
|
||||
const EYE_COLOR = "#111827";
|
||||
const HEADSET_BAND = "#94a3b8";
|
||||
const HEADSET_PAD = "#475569";
|
||||
const MOUTH_COLOR = "#9c4a4a";
|
||||
|
||||
const escapeXml = (value: string) =>
|
||||
value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
|
||||
const normalizeHex = (value: string): string | null => {
|
||||
const trimmed = value.trim();
|
||||
if (/^#[0-9a-f]{6}$/i.test(trimmed)) return trimmed.toLowerCase();
|
||||
if (/^#[0-9a-f]{3}$/i.test(trimmed)) {
|
||||
const [, r, g, b] = trimmed;
|
||||
return `#${r}${r}${g}${g}${b}${b}`.toLowerCase();
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const blendHex = (source: string, target: string, weight: number): string => {
|
||||
const sourceHex = normalizeHex(source);
|
||||
const targetHex = normalizeHex(target);
|
||||
if (!sourceHex || !targetHex) return source;
|
||||
const ratio = Math.max(0, Math.min(1, weight));
|
||||
const sourceChannels = [
|
||||
Number.parseInt(sourceHex.slice(1, 3), 16),
|
||||
Number.parseInt(sourceHex.slice(3, 5), 16),
|
||||
Number.parseInt(sourceHex.slice(5, 7), 16),
|
||||
];
|
||||
const targetChannels = [
|
||||
Number.parseInt(targetHex.slice(1, 3), 16),
|
||||
Number.parseInt(targetHex.slice(3, 5), 16),
|
||||
Number.parseInt(targetHex.slice(5, 7), 16),
|
||||
];
|
||||
const mixed = sourceChannels.map((channel, index) =>
|
||||
Math.round(channel * (1 - ratio) + targetChannels[index] * ratio)
|
||||
);
|
||||
return `#${mixed.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`;
|
||||
};
|
||||
|
||||
const buildHairSvg = (profile: AgentAvatarProfile, hairColor: string) => {
|
||||
if (profile.accessories.hatStyle !== "none") return "";
|
||||
switch (profile.hair.style) {
|
||||
case "short":
|
||||
return `<rect x="22" y="19" width="36" height="12" rx="4" fill="${hairColor}"/>`;
|
||||
case "parted":
|
||||
return [
|
||||
`<rect x="22" y="19" width="36" height="11" rx="4" fill="${hairColor}"/>`,
|
||||
`<path d="M25 25 L46 18 L47 29 L25 30 Z" fill="${blendHex(hairColor, "#ffffff", 0.08)}"/>`,
|
||||
].join("");
|
||||
case "spiky":
|
||||
return [
|
||||
`<rect x="23" y="21" width="34" height="9" rx="3" fill="${hairColor}"/>`,
|
||||
`<path d="M25 22 L30 14 L34 22 Z" fill="${hairColor}"/>`,
|
||||
`<path d="M38 21 L43 11 L47 21 Z" fill="${hairColor}"/>`,
|
||||
`<path d="M49 22 L54 14 L57 22 Z" fill="${hairColor}"/>`,
|
||||
].join("");
|
||||
case "bun":
|
||||
return [
|
||||
`<rect x="22" y="20" width="36" height="10" rx="4" fill="${hairColor}"/>`,
|
||||
`<circle cx="40" cy="15" r="6" fill="${hairColor}"/>`,
|
||||
].join("");
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const buildHatSvg = (profile: AgentAvatarProfile, accessoryColor: string) => {
|
||||
switch (profile.accessories.hatStyle) {
|
||||
case "cap":
|
||||
return [
|
||||
`<rect x="21" y="17" width="38" height="10" rx="4" fill="${accessoryColor}"/>`,
|
||||
`<rect x="29" y="25" width="22" height="5" rx="2.5" fill="${blendHex(accessoryColor, "#000000", 0.08)}"/>`,
|
||||
].join("");
|
||||
case "beanie":
|
||||
return `<path d="M22 27 C22 16, 58 16, 58 27 L58 31 L22 31 Z" fill="${accessoryColor}"/>`;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const buildHeadsetSvg = (enabled: boolean) => {
|
||||
if (!enabled) return "";
|
||||
return [
|
||||
`<path d="M24 33 C24 21, 56 21, 56 33" fill="none" stroke="${HEADSET_BAND}" stroke-width="3" stroke-linecap="round"/>`,
|
||||
`<rect x="20" y="33" width="6" height="14" rx="3" fill="${HEADSET_PAD}"/>`,
|
||||
`<rect x="54" y="33" width="6" height="14" rx="3" fill="${HEADSET_PAD}"/>`,
|
||||
].join("");
|
||||
};
|
||||
|
||||
const buildGlassesSvg = (enabled: boolean) => {
|
||||
if (!enabled) return "";
|
||||
return [
|
||||
`<rect x="26" y="34" width="12" height="10" rx="2" fill="none" stroke="${EYE_COLOR}" stroke-width="2"/>`,
|
||||
`<rect x="42" y="34" width="12" height="10" rx="2" fill="none" stroke="${EYE_COLOR}" stroke-width="2"/>`,
|
||||
`<rect x="38" y="38" width="4" height="2" rx="1" fill="${EYE_COLOR}"/>`,
|
||||
].join("");
|
||||
};
|
||||
|
||||
export const buildAgentAvatarPortraitSvg = (profile: AgentAvatarProfile): string => {
|
||||
const skinTone = profile.body.skinTone;
|
||||
const hairColor = profile.hair.color;
|
||||
const topColor = profile.clothing.topColor;
|
||||
const accessoryColor = blendHex(topColor, "#ffffff", 0.08);
|
||||
const shirtShadow = blendHex(topColor, "#000000", 0.18);
|
||||
const faceShadow = blendHex(skinTone, "#000000", 0.12);
|
||||
const faceHighlight = blendHex(skinTone, "#ffffff", 0.16);
|
||||
|
||||
return [
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 80" role="img" aria-label="${escapeXml(profile.seed)} avatar portrait">`,
|
||||
`<rect width="80" height="80" rx="18" fill="${AVATAR_BG}"/>`,
|
||||
`<circle cx="60" cy="18" r="14" fill="${topColor}" opacity="0.16"/>`,
|
||||
`<circle cx="18" cy="66" r="16" fill="${faceHighlight}" opacity="0.1"/>`,
|
||||
`<ellipse cx="40" cy="72" rx="18" ry="5" fill="#000000" opacity="0.22"/>`,
|
||||
`<rect x="20" y="55" width="40" height="17" rx="8" fill="${topColor}"/>`,
|
||||
`<rect x="24" y="55" width="32" height="5" rx="2.5" fill="${shirtShadow}" opacity="0.22"/>`,
|
||||
`<rect x="36" y="48" width="8" height="9" rx="2" fill="${faceShadow}"/>`,
|
||||
`<rect x="24" y="21" width="32" height="29" rx="6" fill="${skinTone}"/>`,
|
||||
`<rect x="27" y="24" width="26" height="8" rx="3" fill="${faceHighlight}" opacity="0.26"/>`,
|
||||
buildHairSvg(profile, hairColor),
|
||||
buildHatSvg(profile, accessoryColor),
|
||||
buildHeadsetSvg(profile.accessories.headset),
|
||||
`<rect x="29" y="35" width="7" height="7" rx="1.5" fill="${EYE_COLOR}"/>`,
|
||||
`<rect x="44" y="35" width="7" height="7" rx="1.5" fill="${EYE_COLOR}"/>`,
|
||||
buildGlassesSvg(profile.accessories.glasses),
|
||||
`<rect x="34" y="45" width="12" height="3" rx="1.5" fill="${MOUTH_COLOR}"/>`,
|
||||
`</svg>`,
|
||||
].join("");
|
||||
};
|
||||
|
||||
export const buildAgentAvatarPortraitDataUrl = (profile: AgentAvatarProfile): string =>
|
||||
`data:image/svg+xml;utf8,${encodeURIComponent(buildAgentAvatarPortraitSvg(profile))}`;
|
||||
@@ -37,7 +37,6 @@ import {
|
||||
resolveOfficeGithubDirective,
|
||||
resolveOfficeGymDirective,
|
||||
resolveOfficeQaDirective,
|
||||
resolveOfficeStandupDirective,
|
||||
resolveOfficeTextDirective,
|
||||
} from "@/lib/office/deskDirectives";
|
||||
import { extractText, extractThinking } from "@/lib/text/message-extract";
|
||||
@@ -51,6 +50,7 @@ const WORKING_LATCH_MS = 5_000;
|
||||
const GYM_WORKOUT_LATCH_MS = 60_000;
|
||||
const STREAM_ACTIVITY_LATCH_MS = 6_000;
|
||||
const THINKING_ACTIVITY_LATCH_MS = 6_000;
|
||||
const STANDUP_TRIGGER_MAX_AGE_MS = 30_000;
|
||||
const CLEANING_CUE_LIMIT = 24;
|
||||
const TRANSIENT_BOOTH_RESTORE_MAX_AGE_MS = 2 * 60_000;
|
||||
|
||||
@@ -807,8 +807,7 @@ const applyUserMessageTriggers = (params: {
|
||||
};
|
||||
}
|
||||
if (params.agentId === "main" && intentSnapshot.standup === "standup") {
|
||||
const requestKey = normalizeCommandText(params.message);
|
||||
if (next.pendingStandupRequest?.key !== requestKey) {
|
||||
const requestKey = `${normalizeCommandText(params.message)}:${params.nowMs}`;
|
||||
next = {
|
||||
...next,
|
||||
pendingStandupRequest: {
|
||||
@@ -818,7 +817,6 @@ const applyUserMessageTriggers = (params: {
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
if (intentSnapshot.call) {
|
||||
const request = createPhoneCallRequest({
|
||||
directive: intentSnapshot.call,
|
||||
@@ -1108,6 +1106,12 @@ export const reconcileOfficeAnimationTriggerState = (params: {
|
||||
let workingUntilByAgentId = next.workingUntilByAgentId;
|
||||
const manualGymUntilByAgentId = next.manualGymUntilByAgentId;
|
||||
let pendingStandupRequest = next.pendingStandupRequest;
|
||||
if (
|
||||
pendingStandupRequest &&
|
||||
nowMs - pendingStandupRequest.requestedAt > STANDUP_TRIGGER_MAX_AGE_MS
|
||||
) {
|
||||
pendingStandupRequest = null;
|
||||
}
|
||||
|
||||
for (const agent of params.agents) {
|
||||
const agentId = agent.agentId;
|
||||
@@ -1192,23 +1196,6 @@ export const reconcileOfficeAnimationTriggerState = (params: {
|
||||
skillGymHoldByAgentId[agentId] = true;
|
||||
}
|
||||
|
||||
const standupDirective = resolveLatestDirective({
|
||||
lastUserMessage: agent.lastUserMessage,
|
||||
transcriptEntries: agent.transcriptEntries,
|
||||
resolver: resolveOfficeStandupDirective,
|
||||
});
|
||||
if (
|
||||
agentId === "main" &&
|
||||
standupDirective &&
|
||||
pendingStandupRequest?.key !== standupDirective.key
|
||||
) {
|
||||
pendingStandupRequest = {
|
||||
key: standupDirective.key,
|
||||
message: standupDirective.text,
|
||||
requestedAt: nowMs,
|
||||
};
|
||||
}
|
||||
|
||||
const phoneCallRequest = resolveLatestPhoneCallRequest({
|
||||
lastUserMessage: agent.lastUserMessage,
|
||||
transcriptEntries: agent.transcriptEntries,
|
||||
|
||||
@@ -94,6 +94,19 @@ export const DEFAULT_SKILL_TRIGGER_FALLBACKS_BY_SKILL_KEY: Record<
|
||||
movementTarget: "desk",
|
||||
skipIfAlreadyThere: true,
|
||||
},
|
||||
"task-manager": {
|
||||
anyPhrases: [
|
||||
"add a task",
|
||||
"create a task",
|
||||
"track this task",
|
||||
"task status",
|
||||
"mark this done",
|
||||
"block this task",
|
||||
"what tasks do we have",
|
||||
],
|
||||
movementTarget: "desk",
|
||||
skipIfAlreadyThere: true,
|
||||
},
|
||||
soundclaw: {
|
||||
anyPhrases: [
|
||||
"spotify",
|
||||
|
||||
@@ -6,6 +6,8 @@ import type { StandupMeeting, StandupMeetingStore } from "@/lib/office/standup/t
|
||||
|
||||
const STORE_DIR = "claw3d";
|
||||
const STORE_FILE = "standup-store.json";
|
||||
const GATHERING_MEETING_MAX_AGE_MS = 5 * 60 * 1000;
|
||||
const ACTIVE_MEETING_MAX_AGE_MS = 20 * 60 * 1000;
|
||||
|
||||
const ensureDirectory = (dirPath: string) => {
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
@@ -36,6 +38,24 @@ const normalizeMeeting = (value: unknown): StandupMeeting | null => {
|
||||
return value as StandupMeeting;
|
||||
};
|
||||
|
||||
const isActiveMeetingStale = (
|
||||
meeting: StandupMeeting | null,
|
||||
nowMs: number = Date.now()
|
||||
): boolean => {
|
||||
if (!meeting) return false;
|
||||
if (meeting.phase === "gathering") {
|
||||
const startedAtMs = Date.parse(meeting.startedAt);
|
||||
if (!Number.isFinite(startedAtMs)) return false;
|
||||
return nowMs - startedAtMs > GATHERING_MEETING_MAX_AGE_MS;
|
||||
}
|
||||
if (meeting.phase !== "in_progress") {
|
||||
return false;
|
||||
}
|
||||
const updatedAtMs = Date.parse(meeting.updatedAt);
|
||||
if (!Number.isFinite(updatedAtMs)) return false;
|
||||
return nowMs - updatedAtMs > ACTIVE_MEETING_MAX_AGE_MS;
|
||||
};
|
||||
|
||||
const readStore = (): StandupMeetingStore => {
|
||||
const storePath = resolveStorePath();
|
||||
if (!fs.existsSync(storePath)) {
|
||||
@@ -44,9 +64,11 @@ const readStore = (): StandupMeetingStore => {
|
||||
const raw = fs.readFileSync(storePath, "utf8");
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!isRecord(parsed)) return defaultStore();
|
||||
const activeMeeting = normalizeMeeting(parsed.activeMeeting);
|
||||
const lastMeeting = normalizeMeeting(parsed.lastMeeting);
|
||||
return {
|
||||
activeMeeting: normalizeMeeting(parsed.activeMeeting),
|
||||
lastMeeting: normalizeMeeting(parsed.lastMeeting),
|
||||
activeMeeting: isActiveMeetingStale(activeMeeting) ? null : activeMeeting,
|
||||
lastMeeting,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import type {
|
||||
SkillStatusEntry,
|
||||
} from "@/lib/skills/types";
|
||||
|
||||
export type PackagedSkillId = "soundclaw" | "todo-board";
|
||||
export type PackagedSkillId = "soundclaw" | "task-manager" | "todo-board";
|
||||
|
||||
export type PackagedSkillDefinition = {
|
||||
packageId: PackagedSkillId;
|
||||
@@ -33,6 +33,16 @@ const PACKAGED_SKILLS: PackagedSkillDefinition[] = [
|
||||
creatorName: "iamlukethedev",
|
||||
creatorUrl: "http://x.com/iamlukethedev/",
|
||||
},
|
||||
{
|
||||
packageId: "task-manager",
|
||||
skillKey: "task-manager",
|
||||
name: "task-manager",
|
||||
description:
|
||||
"Capture actionable requests as persistent tasks and keep a shared Kanban task store in sync.",
|
||||
installSource: "openclaw-workspace",
|
||||
creatorName: "iamlukethedev",
|
||||
creatorUrl: "https://github.com/iamlukethedev",
|
||||
},
|
||||
{
|
||||
packageId: "soundclaw",
|
||||
skillKey: "soundclaw",
|
||||
|
||||
@@ -102,6 +102,19 @@ const SKILL_MARKETPLACE_OVERRIDES: Record<
|
||||
editorBadge: "Claw3D test",
|
||||
hideStats: true,
|
||||
},
|
||||
"task-manager": {
|
||||
category: "Productivity",
|
||||
tagline:
|
||||
"Turns actionable requests into persistent shared tasks that power the Claw3D Kanban board.",
|
||||
capabilities: [
|
||||
"Automatic task capture",
|
||||
"Task lifecycle tracking",
|
||||
"Shared Kanban state",
|
||||
],
|
||||
featured: true,
|
||||
editorBadge: "Kanban core",
|
||||
hideStats: true,
|
||||
},
|
||||
soundclaw: {
|
||||
category: "Audio",
|
||||
tagline:
|
||||
|
||||
@@ -140,6 +140,195 @@ const TODO_BOARD_EXAMPLE_JSON = `{
|
||||
}
|
||||
`;
|
||||
|
||||
// Keep this string synchronized with assets/skills/task-manager/SKILL.md.
|
||||
const TASK_MANAGER_SKILL_MD = `---
|
||||
name: task-manager
|
||||
description: Capture actionable user requests as persistent tasks, update task status as work progresses, and keep a shared task store in sync. Use when a user asks an agent to do work, check progress, block a task, complete a task, or manage the Kanban board.
|
||||
metadata: {"openclaw":{"skillKey":"task-manager"}}
|
||||
---
|
||||
|
||||
# Task Manager
|
||||
|
||||
Use this skill for task capture and task lifecycle updates.
|
||||
|
||||
## Trigger
|
||||
|
||||
\`\`\`json
|
||||
{
|
||||
"activation": {
|
||||
"anyPhrases": [
|
||||
"add a task",
|
||||
"create a task",
|
||||
"track this task",
|
||||
"task status",
|
||||
"mark this done",
|
||||
"block this task",
|
||||
"what tasks do we have"
|
||||
]
|
||||
},
|
||||
"movement": {
|
||||
"target": "desk",
|
||||
"skipIfAlreadyThere": true
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
Also use this skill even when those exact phrases are absent if the latest user message is an actionable work request. If the user asks the agent to do something, that request must become a task before the agent proceeds.
|
||||
|
||||
## Storage location
|
||||
|
||||
The authoritative task file is:
|
||||
|
||||
- \`\${OPENCLAW_STATE_DIR}/claw3d/task-manager/tasks.json\` when \`OPENCLAW_STATE_DIR\` is set.
|
||||
- \`~/.openclaw/claw3d/task-manager/tasks.json\` otherwise.
|
||||
|
||||
Always treat that file as the shared source of truth for the Kanban board.
|
||||
|
||||
## Required workflow
|
||||
|
||||
1. Read the task file before handling an actionable request.
|
||||
2. If the file does not exist, create it with the schema in this document.
|
||||
3. If the latest user message is actionable and no matching active task exists, create one immediately.
|
||||
4. Before starting execution, ensure the task is \`todo\` or move it to \`in_progress\`.
|
||||
5. If work cannot continue, set the task to \`blocked\` and record a short reason in \`notes\`.
|
||||
6. When work is finished, set the task to \`done\`.
|
||||
7. When work needs user review or confirmation, set the task to \`review\`.
|
||||
8. After every mutation, write the full updated JSON back to disk.
|
||||
|
||||
## Matching rules
|
||||
|
||||
- Match first by \`externalThreadId\` when the request comes from a stable thread or conversation.
|
||||
- Otherwise match by a concise normalized title that preserves user intent.
|
||||
- Avoid creating duplicate active tasks for the same request.
|
||||
|
||||
## Task fields
|
||||
|
||||
Each task must include:
|
||||
|
||||
- \`id\`
|
||||
- \`title\`
|
||||
- \`description\`
|
||||
- \`status\`
|
||||
- \`source\`
|
||||
- \`sourceEventId\`
|
||||
- \`assignedAgentId\`
|
||||
- \`createdAt\`
|
||||
- \`updatedAt\`
|
||||
- \`playbookJobId\`
|
||||
- \`runId\`
|
||||
- \`channel\`
|
||||
- \`externalThreadId\`
|
||||
- \`lastActivityAt\`
|
||||
- \`notes\`
|
||||
- \`isArchived\`
|
||||
- \`isInferred\`
|
||||
- \`history\`
|
||||
|
||||
## Status rules
|
||||
|
||||
- New actionable requests start as \`todo\` unless work has already begun.
|
||||
- Move to \`in_progress\` when the agent is actively working.
|
||||
- Move to \`blocked\` when progress depends on missing input, credentials, approvals, or failures.
|
||||
- Move to \`review\` when the work is ready for inspection or handoff.
|
||||
- Move to \`done\` only when the requested work is complete.
|
||||
|
||||
## File format
|
||||
|
||||
\`\`\`json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"updatedAt": "2026-03-30T00:00:00.000Z",
|
||||
"tasks": [
|
||||
{
|
||||
"id": "research-mtulsa-com",
|
||||
"title": "Research mtulsa.com",
|
||||
"description": "Review mtulsa.com and summarize the site, positioning, and improvement opportunities.",
|
||||
"status": "in_progress",
|
||||
"source": "claw3d_manual",
|
||||
"sourceEventId": null,
|
||||
"assignedAgentId": "main",
|
||||
"createdAt": "2026-03-30T00:00:00.000Z",
|
||||
"updatedAt": "2026-03-30T00:10:00.000Z",
|
||||
"playbookJobId": null,
|
||||
"runId": null,
|
||||
"channel": "telegram",
|
||||
"externalThreadId": "telegram:direct:6866695577",
|
||||
"lastActivityAt": "2026-03-30T00:10:00.000Z",
|
||||
"notes": [],
|
||||
"isArchived": false,
|
||||
"isInferred": false,
|
||||
"history": [
|
||||
{
|
||||
"at": "2026-03-30T00:00:00.000Z",
|
||||
"type": "created",
|
||||
"note": "Task created.",
|
||||
"fromStatus": null,
|
||||
"toStatus": "todo"
|
||||
},
|
||||
{
|
||||
"at": "2026-03-30T00:10:00.000Z",
|
||||
"type": "status_changed",
|
||||
"note": null,
|
||||
"fromStatus": "todo",
|
||||
"toStatus": "in_progress"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
## Response rules
|
||||
|
||||
- Briefly confirm which task was created or updated.
|
||||
- If the request is ambiguous, ask a clarifying question instead of guessing.
|
||||
- Do not claim work is complete without updating the task status.
|
||||
`;
|
||||
|
||||
// Keep this string synchronized with assets/skills/task-manager/tasks.example.json.
|
||||
const TASK_MANAGER_EXAMPLE_JSON = `{
|
||||
"schemaVersion": 1,
|
||||
"updatedAt": "2026-03-30T00:10:00.000Z",
|
||||
"tasks": [
|
||||
{
|
||||
"id": "research-mtulsa-com",
|
||||
"title": "Research mtulsa.com",
|
||||
"description": "Review mtulsa.com and summarize the site, positioning, and improvement opportunities.",
|
||||
"status": "in_progress",
|
||||
"source": "claw3d_manual",
|
||||
"sourceEventId": null,
|
||||
"assignedAgentId": "main",
|
||||
"createdAt": "2026-03-30T00:00:00.000Z",
|
||||
"updatedAt": "2026-03-30T00:10:00.000Z",
|
||||
"playbookJobId": null,
|
||||
"runId": null,
|
||||
"channel": "telegram",
|
||||
"externalThreadId": "telegram:direct:6866695577",
|
||||
"lastActivityAt": "2026-03-30T00:10:00.000Z",
|
||||
"notes": [],
|
||||
"isArchived": false,
|
||||
"isInferred": false,
|
||||
"history": [
|
||||
{
|
||||
"at": "2026-03-30T00:00:00.000Z",
|
||||
"type": "created",
|
||||
"note": "Task created.",
|
||||
"fromStatus": null,
|
||||
"toStatus": "todo"
|
||||
},
|
||||
{
|
||||
"at": "2026-03-30T00:10:00.000Z",
|
||||
"type": "status_changed",
|
||||
"note": null,
|
||||
"fromStatus": "todo",
|
||||
"toStatus": "in_progress"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
`;
|
||||
|
||||
// Keep this string synchronized with assets/skills/soundclaw/SKILL.md.
|
||||
const SOUNDCLAW_SKILL_MD = `---
|
||||
name: soundclaw
|
||||
@@ -246,6 +435,16 @@ const PACKAGED_SKILL_FILES: Record<string, PackagedSkillFile[]> = {
|
||||
content: TODO_BOARD_EXAMPLE_JSON,
|
||||
},
|
||||
],
|
||||
"task-manager": [
|
||||
{
|
||||
relativePath: "SKILL.md",
|
||||
content: TASK_MANAGER_SKILL_MD,
|
||||
},
|
||||
{
|
||||
relativePath: "tasks.example.json",
|
||||
content: TASK_MANAGER_EXAMPLE_JSON,
|
||||
},
|
||||
],
|
||||
soundclaw: [
|
||||
{
|
||||
relativePath: "SKILL.md",
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
StudioSettingsPublic,
|
||||
StudioSettingsPatch,
|
||||
StudioStandupPreferencePatch,
|
||||
StudioTaskBoardPreferencePatch,
|
||||
StudioVoiceRepliesPreferencePatch,
|
||||
} from "@/lib/studio/settings";
|
||||
|
||||
@@ -28,6 +29,7 @@ type AnalyticsPatch = Record<string, StudioAnalyticsPreferencePatch | null>;
|
||||
type VoiceRepliesPatch = Record<string, StudioVoiceRepliesPreferencePatch | null>;
|
||||
type OfficePatch = Record<string, StudioOfficePreferencePatch | null>;
|
||||
type StandupPatch = Record<string, StudioStandupPreferencePatch | null>;
|
||||
type TaskBoardPatch = Record<string, StudioTaskBoardPreferencePatch | null>;
|
||||
|
||||
export type StudioSettingsCoordinatorTransport = {
|
||||
fetchSettings: () => Promise<StudioSettingsResponse>;
|
||||
@@ -225,6 +227,34 @@ const mergeStandupPatch = (
|
||||
return merged;
|
||||
};
|
||||
|
||||
const mergeTaskBoardPatch = (
|
||||
current: TaskBoardPatch | undefined,
|
||||
next: TaskBoardPatch | undefined
|
||||
): TaskBoardPatch | undefined => {
|
||||
if (!current && !next) return undefined;
|
||||
const merged: TaskBoardPatch = { ...(current ?? {}) };
|
||||
for (const [gatewayKey, value] of Object.entries(next ?? {})) {
|
||||
if (value === null) {
|
||||
merged[gatewayKey] = null;
|
||||
continue;
|
||||
}
|
||||
const existing = merged[gatewayKey];
|
||||
if (existing && existing !== null) {
|
||||
merged[gatewayKey] = {
|
||||
...existing,
|
||||
...value,
|
||||
...(value.cards ? { cards: [...value.cards] } : {}),
|
||||
};
|
||||
continue;
|
||||
}
|
||||
merged[gatewayKey] = {
|
||||
...value,
|
||||
...(value.cards ? { cards: [...value.cards] } : {}),
|
||||
};
|
||||
}
|
||||
return merged;
|
||||
};
|
||||
|
||||
const mergeStudioPatch = (
|
||||
current: StudioSettingsPatch | null,
|
||||
next: StudioSettingsPatch
|
||||
@@ -251,6 +281,7 @@ const mergeStudioPatch = (
|
||||
const voiceReplies = mergeVoiceRepliesPatch(current.voiceReplies, next.voiceReplies);
|
||||
const office = mergeOfficePatch(current.office, next.office);
|
||||
const standup = mergeStandupPatch(current.standup, next.standup);
|
||||
const taskBoard = mergeTaskBoardPatch(current.taskBoard, next.taskBoard);
|
||||
return {
|
||||
...(next.gateway !== undefined
|
||||
? { gateway: next.gateway }
|
||||
@@ -264,6 +295,7 @@ const mergeStudioPatch = (
|
||||
...(voiceReplies ? { voiceReplies } : {}),
|
||||
...(office ? { office } : {}),
|
||||
...(standup ? { standup } : {}),
|
||||
...(taskBoard ? { taskBoard } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -6,6 +6,14 @@ import type {
|
||||
} from "@/lib/office/standup/types";
|
||||
import type { AgentAvatarProfile } from "@/lib/avatars/profile";
|
||||
import { normalizeAgentAvatarProfile } from "@/lib/avatars/profile";
|
||||
import {
|
||||
defaultTaskBoardPreference,
|
||||
isTaskBoardSource,
|
||||
isTaskBoardStatus,
|
||||
type TaskBoardCard,
|
||||
type TaskBoardPreference,
|
||||
type TaskBoardPreferencePatch,
|
||||
} from "@/features/office/tasks/types";
|
||||
|
||||
export type StudioGatewaySettings = {
|
||||
url: string;
|
||||
@@ -133,6 +141,10 @@ export type StandupJiraConfigPublic = Omit<StandupJiraConfig, "apiToken"> & {
|
||||
apiTokenConfigured: boolean;
|
||||
};
|
||||
|
||||
export type StudioTaskBoardPreference = TaskBoardPreference;
|
||||
export type StudioTaskBoardPreferencePublic = TaskBoardPreference;
|
||||
export type StudioTaskBoardPreferencePatch = TaskBoardPreferencePatch;
|
||||
|
||||
export type StudioSettings = {
|
||||
version: 1;
|
||||
gateway: StudioGatewaySettings | null;
|
||||
@@ -143,12 +155,14 @@ export type StudioSettings = {
|
||||
voiceReplies: Record<string, StudioVoiceRepliesPreference>;
|
||||
office: Record<string, StudioOfficePreference>;
|
||||
standup?: Record<string, StudioStandupPreference>;
|
||||
taskBoard?: Record<string, StudioTaskBoardPreference>;
|
||||
};
|
||||
|
||||
export type StudioSettingsPublic = Omit<StudioSettings, "gateway" | "office" | "standup"> & {
|
||||
gateway: StudioGatewaySettingsPublic | null;
|
||||
office: Record<string, StudioOfficePreferencePublic>;
|
||||
standup?: Record<string, StudioStandupPreferencePublic>;
|
||||
taskBoard?: Record<string, StudioTaskBoardPreferencePublic>;
|
||||
};
|
||||
|
||||
export type StudioSettingsPatch = {
|
||||
@@ -160,6 +174,7 @@ export type StudioSettingsPatch = {
|
||||
voiceReplies?: Record<string, StudioVoiceRepliesPreferencePatch | null>;
|
||||
office?: Record<string, StudioOfficePreferencePatch | null>;
|
||||
standup?: Record<string, StudioStandupPreferencePatch | null>;
|
||||
taskBoard?: Record<string, StudioTaskBoardPreferencePatch | null>;
|
||||
};
|
||||
|
||||
const SETTINGS_VERSION = 1 as const;
|
||||
@@ -299,6 +314,9 @@ export const defaultStudioStandupPreference = (): StudioStandupPreference => ({
|
||||
manualByAgentId: {},
|
||||
});
|
||||
|
||||
export const defaultStudioTaskBoardPreference =
|
||||
(): StudioTaskBoardPreference => defaultTaskBoardPreference();
|
||||
|
||||
const normalizeVoiceReplySpeed = (value: unknown, fallback: number = 1): number => {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
|
||||
return Math.min(1.2, Math.max(0.7, value));
|
||||
@@ -314,6 +332,70 @@ const normalizeOptionalIsoString = (
|
||||
return trimmed ? trimmed : null;
|
||||
};
|
||||
|
||||
const normalizeTaskBoardNotes = (value: unknown, fallback: string[] = []) => {
|
||||
if (!Array.isArray(value)) return [...fallback];
|
||||
return value
|
||||
.filter((entry): entry is string => typeof entry === "string")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 24);
|
||||
};
|
||||
|
||||
const normalizeTaskBoardCard = (
|
||||
value: unknown,
|
||||
fallback?: TaskBoardCard
|
||||
): TaskBoardCard => {
|
||||
const nowIso = new Date().toISOString();
|
||||
const record = isRecord(value) ? value : {};
|
||||
return {
|
||||
id: coerceString(record.id) || fallback?.id || "",
|
||||
title: coerceString(record.title) || fallback?.title || "Untitled task",
|
||||
description: coerceString(record.description) || fallback?.description || "",
|
||||
status: isTaskBoardStatus(record.status) ? record.status : (fallback?.status ?? "todo"),
|
||||
source: isTaskBoardSource(record.source)
|
||||
? record.source
|
||||
: (fallback?.source ?? "claw3d_manual"),
|
||||
sourceEventId:
|
||||
normalizeOptionalIsoString(record.sourceEventId, fallback?.sourceEventId ?? null) ??
|
||||
null,
|
||||
assignedAgentId:
|
||||
normalizeSelectedAgentId(record.assignedAgentId, fallback?.assignedAgentId ?? null) ?? null,
|
||||
createdAt:
|
||||
normalizeOptionalIsoString(record.createdAt, fallback?.createdAt ?? nowIso) ?? nowIso,
|
||||
updatedAt:
|
||||
normalizeOptionalIsoString(record.updatedAt, fallback?.updatedAt ?? nowIso) ?? nowIso,
|
||||
playbookJobId:
|
||||
normalizeSelectedAgentId(record.playbookJobId, fallback?.playbookJobId ?? null) ?? null,
|
||||
runId: normalizeSelectedAgentId(record.runId, fallback?.runId ?? null) ?? null,
|
||||
channel: normalizeSelectedAgentId(record.channel, fallback?.channel ?? null) ?? null,
|
||||
externalThreadId:
|
||||
normalizeSelectedAgentId(record.externalThreadId, fallback?.externalThreadId ?? null) ??
|
||||
null,
|
||||
lastActivityAt:
|
||||
normalizeOptionalIsoString(record.lastActivityAt, fallback?.lastActivityAt ?? null) ?? null,
|
||||
notes: normalizeTaskBoardNotes(record.notes, fallback?.notes ?? []),
|
||||
isArchived:
|
||||
typeof record.isArchived === "boolean" ? record.isArchived : (fallback?.isArchived ?? false),
|
||||
isInferred:
|
||||
typeof record.isInferred === "boolean" ? record.isInferred : (fallback?.isInferred ?? false),
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeTaskBoardPreference = (
|
||||
value: unknown,
|
||||
fallback: StudioTaskBoardPreference = defaultStudioTaskBoardPreference()
|
||||
): StudioTaskBoardPreference => {
|
||||
const record = isRecord(value) ? value : {};
|
||||
const rawCards = Array.isArray(record.cards) ? record.cards : fallback.cards;
|
||||
return {
|
||||
cards: rawCards
|
||||
.map((entry) => normalizeTaskBoardCard(entry))
|
||||
.filter((entry) => entry.id.length > 0),
|
||||
selectedCardId:
|
||||
normalizeSelectedAgentId(record.selectedCardId, fallback.selectedCardId) ?? null,
|
||||
};
|
||||
};
|
||||
|
||||
const DEFAULT_OFFICE_TITLE = "Luke Headquarters";
|
||||
const DEFAULT_REMOTE_OFFICE_LABEL = "Remote Office";
|
||||
const DEFAULT_REMOTE_OFFICE_SOURCE_KIND = "presence_endpoint" as const;
|
||||
@@ -525,6 +607,19 @@ const normalizeStandup = (
|
||||
return standup;
|
||||
};
|
||||
|
||||
const normalizeTaskBoard = (
|
||||
value: unknown
|
||||
): Record<string, StudioTaskBoardPreference> => {
|
||||
if (!isRecord(value)) return {};
|
||||
const taskBoard: Record<string, StudioTaskBoardPreference> = {};
|
||||
for (const [gatewayKeyRaw, taskBoardRaw] of Object.entries(value)) {
|
||||
const gatewayKey = normalizeGatewayKey(gatewayKeyRaw);
|
||||
if (!gatewayKey) continue;
|
||||
taskBoard[gatewayKey] = normalizeTaskBoardPreference(taskBoardRaw);
|
||||
}
|
||||
return taskBoard;
|
||||
};
|
||||
|
||||
const normalizeFocusedPreference = (
|
||||
value: unknown,
|
||||
fallback: StudioFocusedPreference = defaultFocusedPreference()
|
||||
@@ -761,6 +856,7 @@ export const defaultStudioSettings = (): StudioSettings => ({
|
||||
voiceReplies: {},
|
||||
office: {},
|
||||
standup: {},
|
||||
taskBoard: {},
|
||||
});
|
||||
|
||||
export const sanitizeStudioGatewaySettings = (
|
||||
@@ -788,6 +884,13 @@ export const sanitizeStandupPreference = (
|
||||
jira: sanitizeStandupJiraConfig(value.jira),
|
||||
});
|
||||
|
||||
export const sanitizeTaskBoardPreference = (
|
||||
value: StudioTaskBoardPreference
|
||||
): StudioTaskBoardPreferencePublic => ({
|
||||
cards: value.cards.map((card) => ({ ...card, notes: [...card.notes] })),
|
||||
selectedCardId: value.selectedCardId,
|
||||
});
|
||||
|
||||
export const sanitizeStudioSettings = (
|
||||
value: StudioSettings,
|
||||
): StudioSettingsPublic => ({
|
||||
@@ -805,6 +908,12 @@ export const sanitizeStudioSettings = (
|
||||
sanitizeStandupPreference(preference),
|
||||
]),
|
||||
),
|
||||
taskBoard: Object.fromEntries(
|
||||
Object.entries(value.taskBoard ?? {}).map(([gatewayKey, preference]) => [
|
||||
gatewayKey,
|
||||
sanitizeTaskBoardPreference(preference),
|
||||
]),
|
||||
),
|
||||
});
|
||||
|
||||
export const normalizeStudioSettings = (raw: unknown): StudioSettings => {
|
||||
@@ -817,6 +926,7 @@ export const normalizeStudioSettings = (raw: unknown): StudioSettings => {
|
||||
const voiceReplies = normalizeVoiceReplies(raw.voiceReplies);
|
||||
const office = normalizeOffice(raw.office);
|
||||
const standup = normalizeStandup(raw.standup);
|
||||
const taskBoard = normalizeTaskBoard(raw.taskBoard);
|
||||
return {
|
||||
version: SETTINGS_VERSION,
|
||||
gateway,
|
||||
@@ -827,6 +937,7 @@ export const normalizeStudioSettings = (raw: unknown): StudioSettings => {
|
||||
voiceReplies,
|
||||
office,
|
||||
standup,
|
||||
taskBoard,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -843,6 +954,7 @@ export const mergeStudioSettings = (
|
||||
const nextVoiceReplies = { ...current.voiceReplies };
|
||||
const nextOffice = { ...current.office };
|
||||
const nextStandup = { ...(current.standup ?? {}) };
|
||||
const nextTaskBoard = { ...(current.taskBoard ?? {}) };
|
||||
if (patch.focused) {
|
||||
for (const [keyRaw, value] of Object.entries(patch.focused)) {
|
||||
const key = normalizeGatewayKey(keyRaw);
|
||||
@@ -1013,6 +1125,28 @@ export const mergeStudioSettings = (
|
||||
);
|
||||
}
|
||||
}
|
||||
if (patch.taskBoard) {
|
||||
for (const [gatewayKeyRaw, taskBoardPatch] of Object.entries(patch.taskBoard)) {
|
||||
const gatewayKey = normalizeGatewayKey(gatewayKeyRaw);
|
||||
if (!gatewayKey) continue;
|
||||
if (taskBoardPatch === null) {
|
||||
delete nextTaskBoard[gatewayKey];
|
||||
continue;
|
||||
}
|
||||
const fallback =
|
||||
nextTaskBoard[gatewayKey] ?? defaultStudioTaskBoardPreference();
|
||||
nextTaskBoard[gatewayKey] = normalizeTaskBoardPreference(
|
||||
{
|
||||
...fallback,
|
||||
...taskBoardPatch,
|
||||
cards: Array.isArray(taskBoardPatch.cards)
|
||||
? taskBoardPatch.cards
|
||||
: fallback.cards,
|
||||
},
|
||||
fallback
|
||||
);
|
||||
}
|
||||
}
|
||||
return {
|
||||
version: SETTINGS_VERSION,
|
||||
gateway: nextGateway ?? null,
|
||||
@@ -1023,6 +1157,7 @@ export const mergeStudioSettings = (
|
||||
voiceReplies: nextVoiceReplies,
|
||||
office: nextOffice,
|
||||
standup: nextStandup,
|
||||
taskBoard: nextTaskBoard,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1109,3 +1244,12 @@ export const resolveStandupPreference = (
|
||||
if (!gatewayKey) return defaultStudioStandupPreference();
|
||||
return settings.standup?.[gatewayKey] ?? defaultStudioStandupPreference();
|
||||
};
|
||||
|
||||
export const resolveTaskBoardPreference = (
|
||||
settings: StudioSettings | StudioSettingsPublic,
|
||||
gatewayUrl: string
|
||||
): StudioTaskBoardPreference => {
|
||||
const gatewayKey = normalizeGatewayKey(gatewayUrl);
|
||||
if (!gatewayKey) return defaultStudioTaskBoardPreference();
|
||||
return settings.taskBoard?.[gatewayKey] ?? defaultStudioTaskBoardPreference();
|
||||
};
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
|
||||
import { GatewayResponseError } from "@/lib/gateway/errors";
|
||||
import type { TaskBoardCard, TaskBoardStatus } from "@/features/office/tasks/types";
|
||||
|
||||
export type GatewayTaskRecord = {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
status: TaskBoardStatus;
|
||||
source?: TaskBoardCard["source"];
|
||||
sourceEventId?: string | null;
|
||||
assignedAgentId?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
playbookJobId?: string | null;
|
||||
runId?: string | null;
|
||||
channel?: string | null;
|
||||
externalThreadId?: string | null;
|
||||
lastActivityAt?: string | null;
|
||||
notes?: string[];
|
||||
archived?: boolean;
|
||||
};
|
||||
|
||||
export type GatewayTasksListResult = {
|
||||
tasks: GatewayTaskRecord[];
|
||||
};
|
||||
|
||||
export type GatewayTaskCreateInput = {
|
||||
title: string;
|
||||
description?: string;
|
||||
status?: TaskBoardStatus;
|
||||
assignedAgentId?: string | null;
|
||||
playbookJobId?: string | null;
|
||||
runId?: string | null;
|
||||
channel?: string | null;
|
||||
externalThreadId?: string | null;
|
||||
notes?: string[];
|
||||
source?: TaskBoardCard["source"];
|
||||
sourceEventId?: string | null;
|
||||
};
|
||||
|
||||
export type GatewayTaskUpdateInput = {
|
||||
title?: string;
|
||||
description?: string;
|
||||
status?: TaskBoardStatus;
|
||||
assignedAgentId?: string | null;
|
||||
playbookJobId?: string | null;
|
||||
runId?: string | null;
|
||||
channel?: string | null;
|
||||
externalThreadId?: string | null;
|
||||
notes?: string[];
|
||||
archived?: boolean;
|
||||
};
|
||||
|
||||
const trimOrUndefined = (value: string | null | undefined) => {
|
||||
const trimmed = value?.trim() ?? "";
|
||||
return trimmed || undefined;
|
||||
};
|
||||
|
||||
export const isUnsupportedTaskGatewayError = (error: unknown): boolean => {
|
||||
if (!(error instanceof GatewayResponseError)) return false;
|
||||
const code = error.code.trim().toUpperCase();
|
||||
const message = error.message.trim().toLowerCase();
|
||||
if (code === "METHOD_NOT_FOUND" || code === "NOT_IMPLEMENTED") return true;
|
||||
if (code !== "INVALID_REQUEST" && code !== "NOT_FOUND") {
|
||||
return message.includes("unknown method") || message.includes("not implemented");
|
||||
}
|
||||
return (
|
||||
message.includes("unknown method") ||
|
||||
message.includes("not implemented") ||
|
||||
message.includes("tasks.") ||
|
||||
message.includes("task ")
|
||||
);
|
||||
};
|
||||
|
||||
export const listGatewayTasks = async (
|
||||
client: GatewayClient,
|
||||
params: { includeArchived?: boolean } = {}
|
||||
): Promise<GatewayTasksListResult> => {
|
||||
return client.call<GatewayTasksListResult>("tasks.list", {
|
||||
includeArchived: params.includeArchived ?? true,
|
||||
});
|
||||
};
|
||||
|
||||
export const createGatewayTask = async (
|
||||
client: GatewayClient,
|
||||
input: GatewayTaskCreateInput
|
||||
): Promise<GatewayTaskRecord> => {
|
||||
const title = trimOrUndefined(input.title);
|
||||
if (!title) throw new Error("Task title is required.");
|
||||
return client.call<GatewayTaskRecord>("tasks.create", {
|
||||
title,
|
||||
...(trimOrUndefined(input.description) ? { description: trimOrUndefined(input.description) } : {}),
|
||||
...(input.status ? { status: input.status } : {}),
|
||||
...(input.assignedAgentId !== undefined ? { assignedAgentId: input.assignedAgentId } : {}),
|
||||
...(input.playbookJobId !== undefined ? { playbookJobId: input.playbookJobId } : {}),
|
||||
...(input.runId !== undefined ? { runId: input.runId } : {}),
|
||||
...(input.channel !== undefined ? { channel: trimOrUndefined(input.channel) ?? null } : {}),
|
||||
...(input.externalThreadId !== undefined
|
||||
? { externalThreadId: trimOrUndefined(input.externalThreadId) ?? null }
|
||||
: {}),
|
||||
...(input.notes ? { notes: input.notes } : {}),
|
||||
...(input.source ? { source: input.source } : {}),
|
||||
...(input.sourceEventId !== undefined ? { sourceEventId: input.sourceEventId } : {}),
|
||||
});
|
||||
};
|
||||
|
||||
export const updateGatewayTask = async (
|
||||
client: GatewayClient,
|
||||
id: string,
|
||||
patch: GatewayTaskUpdateInput
|
||||
): Promise<GatewayTaskRecord> => {
|
||||
const taskId = trimOrUndefined(id);
|
||||
if (!taskId) throw new Error("Task id is required.");
|
||||
return client.call<GatewayTaskRecord>("tasks.update", {
|
||||
id: taskId,
|
||||
...(patch.title !== undefined ? { title: trimOrUndefined(patch.title) ?? "" } : {}),
|
||||
...(patch.description !== undefined
|
||||
? { description: trimOrUndefined(patch.description) ?? "" }
|
||||
: {}),
|
||||
...(patch.status !== undefined ? { status: patch.status } : {}),
|
||||
...(patch.assignedAgentId !== undefined ? { assignedAgentId: patch.assignedAgentId } : {}),
|
||||
...(patch.playbookJobId !== undefined ? { playbookJobId: patch.playbookJobId } : {}),
|
||||
...(patch.runId !== undefined ? { runId: patch.runId } : {}),
|
||||
...(patch.channel !== undefined ? { channel: trimOrUndefined(patch.channel) ?? null } : {}),
|
||||
...(patch.externalThreadId !== undefined
|
||||
? { externalThreadId: trimOrUndefined(patch.externalThreadId) ?? null }
|
||||
: {}),
|
||||
...(patch.notes !== undefined ? { notes: patch.notes } : {}),
|
||||
...(patch.archived !== undefined ? { archived: patch.archived } : {}),
|
||||
});
|
||||
};
|
||||
|
||||
export const deleteGatewayTask = async (client: GatewayClient, id: string) => {
|
||||
const taskId = trimOrUndefined(id);
|
||||
if (!taskId) throw new Error("Task id is required.");
|
||||
return client.call<{ ok: boolean; removed?: boolean }>("tasks.delete", { id: taskId });
|
||||
};
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { SharedTaskRecord } from "@/lib/tasks/shared-store";
|
||||
|
||||
const TASK_STORE_ROUTE = "/api/task-store";
|
||||
const REQUEST_TIMEOUT_MS = 8_000;
|
||||
const MAX_RETRIES = 2;
|
||||
const RETRY_BASE_DELAY_MS = 500;
|
||||
|
||||
export class TaskStoreRequestError extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(message: string, status: number) {
|
||||
super(message);
|
||||
this.name = "TaskStoreRequestError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
const isRetryable = (error: unknown): boolean => {
|
||||
if (error instanceof TaskStoreRequestError) {
|
||||
return error.status >= 500 || error.status === 429;
|
||||
}
|
||||
if (error instanceof DOMException && error.name === "AbortError") return false;
|
||||
return error instanceof TypeError;
|
||||
};
|
||||
|
||||
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const fetchWithTimeout = (
|
||||
input: RequestInfo,
|
||||
init?: RequestInit
|
||||
): Promise<Response> => {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
return fetch(input, { ...init, signal: controller.signal }).finally(() =>
|
||||
clearTimeout(timer)
|
||||
);
|
||||
};
|
||||
|
||||
const parseResponse = async <T>(response: Response): Promise<T> => {
|
||||
const body = (await response.json().catch(() => null)) as { error?: string } & T;
|
||||
if (!response.ok) {
|
||||
throw new TaskStoreRequestError(
|
||||
body?.error || "Task store request failed.",
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
return body;
|
||||
};
|
||||
|
||||
const withRetry = async <T>(fn: () => Promise<T>): Promise<T> => {
|
||||
let lastError: unknown;
|
||||
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (!isRetryable(error) || attempt === MAX_RETRIES) break;
|
||||
await sleep(RETRY_BASE_DELAY_MS * 2 ** attempt);
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
};
|
||||
|
||||
export const listSharedTaskRecords = async (): Promise<SharedTaskRecord[]> =>
|
||||
withRetry(async () => {
|
||||
const response = await fetchWithTimeout(TASK_STORE_ROUTE, {
|
||||
method: "GET",
|
||||
cache: "no-store",
|
||||
});
|
||||
const body = await parseResponse<{ tasks: SharedTaskRecord[] }>(response);
|
||||
return Array.isArray(body.tasks) ? body.tasks : [];
|
||||
});
|
||||
|
||||
export const upsertSharedTaskRecord = async (
|
||||
task: Partial<SharedTaskRecord> & Pick<SharedTaskRecord, "id" | "title">
|
||||
): Promise<SharedTaskRecord> =>
|
||||
withRetry(async () => {
|
||||
const response = await fetchWithTimeout(TASK_STORE_ROUTE, {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ task }),
|
||||
});
|
||||
const body = await parseResponse<{ task: SharedTaskRecord }>(response);
|
||||
return body.task;
|
||||
});
|
||||
|
||||
export const archiveSharedTaskRecord = async (
|
||||
taskId: string
|
||||
): Promise<SharedTaskRecord> =>
|
||||
withRetry(async () => {
|
||||
const response = await fetchWithTimeout(TASK_STORE_ROUTE, {
|
||||
method: "DELETE",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ id: taskId }),
|
||||
});
|
||||
const body = await parseResponse<{ task: SharedTaskRecord }>(response);
|
||||
return body.task;
|
||||
});
|
||||
@@ -0,0 +1,284 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import type { TaskBoardCard, TaskBoardSource, TaskBoardStatus } from "@/features/office/tasks/types";
|
||||
import { isTaskBoardSource, isTaskBoardStatus } from "@/features/office/tasks/types";
|
||||
import { resolveStateDir } from "@/lib/clawdbot/paths";
|
||||
|
||||
export type SharedTaskHistoryEntry = {
|
||||
at: string;
|
||||
type: "created" | "updated" | "status_changed" | "archived";
|
||||
note: string | null;
|
||||
fromStatus: TaskBoardStatus | null;
|
||||
toStatus: TaskBoardStatus | null;
|
||||
};
|
||||
|
||||
export type SharedTaskRecord = TaskBoardCard & {
|
||||
history: SharedTaskHistoryEntry[];
|
||||
};
|
||||
|
||||
type SharedTaskStore = {
|
||||
schemaVersion: 1;
|
||||
updatedAt: string;
|
||||
tasks: SharedTaskRecord[];
|
||||
};
|
||||
|
||||
const STORE_DIR = path.join("claw3d", "task-manager");
|
||||
const STORE_FILE = "tasks.json";
|
||||
|
||||
const ensureDirectory = (dirPath: string) => {
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
}
|
||||
};
|
||||
|
||||
const resolveStorePath = () => {
|
||||
const stateDir = resolveStateDir();
|
||||
const dir = path.join(stateDir, STORE_DIR);
|
||||
ensureDirectory(dir);
|
||||
return path.join(dir, STORE_FILE);
|
||||
};
|
||||
|
||||
const trimString = (value: unknown) => (typeof value === "string" ? value.trim() : "");
|
||||
|
||||
const normalizeStringArray = (value: unknown): string[] =>
|
||||
Array.isArray(value)
|
||||
? value
|
||||
.filter((entry): entry is string => typeof entry === "string")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
const normalizeHistoryEntry = (value: unknown): SharedTaskHistoryEntry | null => {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
const record = value as Record<string, unknown>;
|
||||
const at = trimString(record.at);
|
||||
const type = trimString(record.type);
|
||||
if (!at) return null;
|
||||
if (!["created", "updated", "status_changed", "archived"].includes(type)) return null;
|
||||
return {
|
||||
at,
|
||||
type: type as SharedTaskHistoryEntry["type"],
|
||||
note: trimString(record.note) || null,
|
||||
fromStatus: isTaskBoardStatus(record.fromStatus) ? record.fromStatus : null,
|
||||
toStatus: isTaskBoardStatus(record.toStatus) ? record.toStatus : null,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeTaskRecord = (value: unknown): SharedTaskRecord | null => {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
const record = value as Record<string, unknown>;
|
||||
const id = trimString(record.id);
|
||||
const title = trimString(record.title);
|
||||
const createdAt = trimString(record.createdAt);
|
||||
const updatedAt = trimString(record.updatedAt);
|
||||
if (!id || !title || !createdAt || !updatedAt) return null;
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
description: trimString(record.description),
|
||||
status: isTaskBoardStatus(record.status) ? record.status : "todo",
|
||||
source: isTaskBoardSource(record.source) ? record.source : "claw3d_manual",
|
||||
sourceEventId: trimString(record.sourceEventId) || null,
|
||||
assignedAgentId: trimString(record.assignedAgentId) || null,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
playbookJobId: trimString(record.playbookJobId) || null,
|
||||
runId: trimString(record.runId) || null,
|
||||
channel: trimString(record.channel) || null,
|
||||
externalThreadId: trimString(record.externalThreadId) || null,
|
||||
lastActivityAt: trimString(record.lastActivityAt) || null,
|
||||
notes: normalizeStringArray(record.notes),
|
||||
isArchived: Boolean(record.isArchived),
|
||||
isInferred: false,
|
||||
history: Array.isArray(record.history)
|
||||
? record.history
|
||||
.map((entry) => normalizeHistoryEntry(entry))
|
||||
.filter((entry): entry is SharedTaskHistoryEntry => Boolean(entry))
|
||||
: [],
|
||||
};
|
||||
};
|
||||
|
||||
const defaultStore = (): SharedTaskStore => ({
|
||||
schemaVersion: 1,
|
||||
updatedAt: new Date(0).toISOString(),
|
||||
tasks: [],
|
||||
});
|
||||
|
||||
const normalizeStore = (value: unknown): SharedTaskStore => {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return defaultStore();
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
const updatedAt = trimString(record.updatedAt) || new Date(0).toISOString();
|
||||
const tasks = Array.isArray(record.tasks)
|
||||
? record.tasks
|
||||
.map((entry) => normalizeTaskRecord(entry))
|
||||
.filter((entry): entry is SharedTaskRecord => Boolean(entry))
|
||||
: [];
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
updatedAt,
|
||||
tasks,
|
||||
};
|
||||
};
|
||||
|
||||
const readStore = (): SharedTaskStore => {
|
||||
const storePath = resolveStorePath();
|
||||
if (!fs.existsSync(storePath)) return defaultStore();
|
||||
try {
|
||||
const raw = fs.readFileSync(storePath, "utf8");
|
||||
return normalizeStore(JSON.parse(raw));
|
||||
} catch {
|
||||
return defaultStore();
|
||||
}
|
||||
};
|
||||
|
||||
const MAX_TITLE_LENGTH = 500;
|
||||
const MAX_DESCRIPTION_LENGTH = 5_000;
|
||||
const MAX_NOTE_LENGTH = 2_000;
|
||||
const MAX_NOTES_COUNT = 50;
|
||||
const MAX_TASKS = 500;
|
||||
|
||||
const writeStore = (store: SharedTaskStore) => {
|
||||
const storePath = resolveStorePath();
|
||||
const dir = path.dirname(storePath);
|
||||
const tmpPath = path.join(dir, `.tasks-${crypto.randomUUID()}.tmp`);
|
||||
try {
|
||||
fs.writeFileSync(tmpPath, JSON.stringify(store, null, 2), "utf8");
|
||||
fs.renameSync(tmpPath, storePath);
|
||||
} catch (error) {
|
||||
try {
|
||||
fs.unlinkSync(tmpPath);
|
||||
} catch {
|
||||
// Best-effort cleanup.
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const truncateField = (value: string, max: number) =>
|
||||
value.length <= max ? value : value.slice(0, max);
|
||||
|
||||
const appendHistory = (
|
||||
existing: SharedTaskRecord | null,
|
||||
next: SharedTaskRecord
|
||||
): SharedTaskHistoryEntry[] => {
|
||||
if (!existing) {
|
||||
return [
|
||||
{
|
||||
at: next.updatedAt,
|
||||
type: "created",
|
||||
note: "Task created.",
|
||||
fromStatus: null,
|
||||
toStatus: next.status,
|
||||
},
|
||||
];
|
||||
}
|
||||
const prior = existing.history ?? [];
|
||||
if (existing.isArchived !== next.isArchived && next.isArchived) {
|
||||
return [
|
||||
...prior,
|
||||
{
|
||||
at: next.updatedAt,
|
||||
type: "archived",
|
||||
note: "Task archived.",
|
||||
fromStatus: existing.status,
|
||||
toStatus: existing.status,
|
||||
},
|
||||
];
|
||||
}
|
||||
if (existing.status !== next.status) {
|
||||
return [
|
||||
...prior,
|
||||
{
|
||||
at: next.updatedAt,
|
||||
type: "status_changed",
|
||||
note: null,
|
||||
fromStatus: existing.status,
|
||||
toStatus: next.status,
|
||||
},
|
||||
];
|
||||
}
|
||||
if (existing.updatedAt !== next.updatedAt) {
|
||||
return [
|
||||
...prior,
|
||||
{
|
||||
at: next.updatedAt,
|
||||
type: "updated",
|
||||
note: null,
|
||||
fromStatus: existing.status,
|
||||
toStatus: next.status,
|
||||
},
|
||||
];
|
||||
}
|
||||
return prior;
|
||||
};
|
||||
|
||||
export const listSharedTasks = (): SharedTaskRecord[] => readStore().tasks;
|
||||
|
||||
export const upsertSharedTask = (
|
||||
task: Partial<SharedTaskRecord> & Pick<SharedTaskRecord, "id" | "title">
|
||||
): SharedTaskRecord => {
|
||||
const store = readStore();
|
||||
const existing = store.tasks.find((entry) => entry.id === task.id) ?? null;
|
||||
const nowIso = task.updatedAt?.trim() || new Date().toISOString();
|
||||
const rawStatus = task.status ?? existing?.status ?? "todo";
|
||||
const rawSource = (task.source as TaskBoardSource | undefined) ?? existing?.source ?? "claw3d_manual";
|
||||
const notes = (task.notes ? [...task.notes] : [...(existing?.notes ?? [])])
|
||||
.slice(0, MAX_NOTES_COUNT)
|
||||
.map((n) => truncateField(n, MAX_NOTE_LENGTH));
|
||||
|
||||
const next: SharedTaskRecord = {
|
||||
id: task.id.trim(),
|
||||
title: truncateField(task.title.trim() || existing?.title || "Untitled task", MAX_TITLE_LENGTH),
|
||||
description: truncateField(task.description?.trim() ?? existing?.description ?? "", MAX_DESCRIPTION_LENGTH),
|
||||
status: isTaskBoardStatus(rawStatus) ? rawStatus : "todo",
|
||||
source: isTaskBoardSource(rawSource) ? rawSource : "claw3d_manual",
|
||||
sourceEventId: task.sourceEventId ?? existing?.sourceEventId ?? null,
|
||||
assignedAgentId: task.assignedAgentId ?? existing?.assignedAgentId ?? null,
|
||||
createdAt: task.createdAt?.trim() || existing?.createdAt || nowIso,
|
||||
updatedAt: nowIso,
|
||||
playbookJobId: task.playbookJobId ?? existing?.playbookJobId ?? null,
|
||||
runId: task.runId ?? existing?.runId ?? null,
|
||||
channel: task.channel ?? existing?.channel ?? null,
|
||||
externalThreadId: task.externalThreadId ?? existing?.externalThreadId ?? null,
|
||||
lastActivityAt: task.lastActivityAt ?? existing?.lastActivityAt ?? nowIso,
|
||||
notes,
|
||||
isArchived: task.isArchived ?? existing?.isArchived ?? false,
|
||||
isInferred: false,
|
||||
history: [],
|
||||
};
|
||||
next.history = appendHistory(existing, next);
|
||||
const index = store.tasks.findIndex((entry) => entry.id === next.id);
|
||||
if (index >= 0) {
|
||||
store.tasks[index] = next;
|
||||
} else {
|
||||
if (store.tasks.length >= MAX_TASKS) {
|
||||
const archivedIndex = store.tasks.findIndex((t) => t.isArchived);
|
||||
if (archivedIndex >= 0) {
|
||||
store.tasks.splice(archivedIndex, 1);
|
||||
} else {
|
||||
store.tasks.shift();
|
||||
}
|
||||
}
|
||||
store.tasks.push(next);
|
||||
}
|
||||
store.updatedAt = nowIso;
|
||||
writeStore(store);
|
||||
return next;
|
||||
};
|
||||
|
||||
export const archiveSharedTask = (taskId: string): SharedTaskRecord | null => {
|
||||
const existing = readStore().tasks.find((entry) => entry.id === taskId.trim()) ?? null;
|
||||
if (!existing) return null;
|
||||
return upsertSharedTask({
|
||||
...existing,
|
||||
isArchived: true,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
};
|
||||
|
||||
export const resolveSharedTaskStorePath = () => resolveStorePath();
|
||||
@@ -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);
|
||||
});
|
||||
@@ -182,6 +182,35 @@ describe("office event triggers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("does not replay standup commands from old transcript history", () => {
|
||||
const sessionKey = "agent:main:main";
|
||||
const agents = [
|
||||
makeAgent({
|
||||
agentId: "main",
|
||||
name: "Main",
|
||||
sessionKey,
|
||||
lastUserMessage: "Start the standup meeting.",
|
||||
transcriptEntries: [
|
||||
makeTranscriptEntry({
|
||||
line: "Start the standup meeting.",
|
||||
role: "user",
|
||||
sequenceKey: 1,
|
||||
sessionKey,
|
||||
timestampMs: 10_000,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
const state = reconcileOfficeAnimationTriggerState({
|
||||
state: createOfficeAnimationTriggerState(),
|
||||
agents,
|
||||
nowMs: 100_000,
|
||||
});
|
||||
|
||||
expect(state.pendingStandupRequest).toBeNull();
|
||||
});
|
||||
|
||||
it("treats final transport chat messages without an explicit user role as commands", () => {
|
||||
const agents = [
|
||||
makeAgent({
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
archiveSharedTask,
|
||||
listSharedTasks,
|
||||
resolveSharedTaskStorePath,
|
||||
upsertSharedTask,
|
||||
} from "@/lib/tasks/shared-store";
|
||||
|
||||
const makeTempDir = (name: string) => fs.mkdtempSync(path.join(os.tmpdir(), `${name}-`));
|
||||
|
||||
describe("shared task store", () => {
|
||||
const priorStateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
let tempDir: string | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
process.env.OPENCLAW_STATE_DIR = priorStateDir;
|
||||
if (tempDir) {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
tempDir = null;
|
||||
}
|
||||
});
|
||||
|
||||
it("creates and lists persisted tasks", () => {
|
||||
tempDir = makeTempDir("shared-task-store-create");
|
||||
process.env.OPENCLAW_STATE_DIR = tempDir;
|
||||
|
||||
const created = upsertSharedTask({
|
||||
id: "task-1",
|
||||
title: "Research mtulsa.com",
|
||||
description: "Check site positioning.",
|
||||
status: "todo",
|
||||
source: "claw3d_manual",
|
||||
});
|
||||
|
||||
expect(created.history).toHaveLength(1);
|
||||
expect(created.history[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
type: "created",
|
||||
toStatus: "todo",
|
||||
})
|
||||
);
|
||||
|
||||
const stored = listSharedTasks();
|
||||
expect(stored).toHaveLength(1);
|
||||
expect(stored[0]?.title).toBe("Research mtulsa.com");
|
||||
expect(fs.existsSync(resolveSharedTaskStorePath())).toBe(true);
|
||||
});
|
||||
|
||||
it("appends history when task status changes and archives instead of deleting", () => {
|
||||
tempDir = makeTempDir("shared-task-store-history");
|
||||
process.env.OPENCLAW_STATE_DIR = tempDir;
|
||||
|
||||
upsertSharedTask({
|
||||
id: "task-1",
|
||||
title: "Research mtulsa.com",
|
||||
status: "todo",
|
||||
source: "claw3d_manual",
|
||||
});
|
||||
const updated = upsertSharedTask({
|
||||
id: "task-1",
|
||||
title: "Research mtulsa.com",
|
||||
status: "in_progress",
|
||||
source: "claw3d_manual",
|
||||
});
|
||||
const archived = archiveSharedTask("task-1");
|
||||
|
||||
expect(updated.history.map((entry) => entry.type)).toContain("status_changed");
|
||||
expect(archived?.isArchived).toBe(true);
|
||||
expect(archived?.history.map((entry) => entry.type)).toContain("archived");
|
||||
});
|
||||
|
||||
it("recovers gracefully from corrupted JSON on disk", () => {
|
||||
tempDir = makeTempDir("shared-task-store-corrupt");
|
||||
process.env.OPENCLAW_STATE_DIR = tempDir;
|
||||
|
||||
upsertSharedTask({ id: "t-1", title: "Valid task", status: "todo", source: "claw3d_manual" });
|
||||
const storePath = resolveSharedTaskStorePath();
|
||||
fs.writeFileSync(storePath, "{invalid json!!!", "utf8");
|
||||
|
||||
const tasks = listSharedTasks();
|
||||
expect(tasks).toEqual([]);
|
||||
|
||||
const afterCorrupt = upsertSharedTask({ id: "t-2", title: "After recovery", status: "todo", source: "claw3d_manual" });
|
||||
expect(afterCorrupt.id).toBe("t-2");
|
||||
expect(listSharedTasks()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("performs atomic writes so partial failures don't corrupt the store", () => {
|
||||
tempDir = makeTempDir("shared-task-store-atomic");
|
||||
process.env.OPENCLAW_STATE_DIR = tempDir;
|
||||
|
||||
upsertSharedTask({ id: "t-1", title: "Safe task", status: "todo", source: "claw3d_manual" });
|
||||
const storePath = resolveSharedTaskStorePath();
|
||||
const original = fs.readFileSync(storePath, "utf8");
|
||||
|
||||
expect(JSON.parse(original)).toEqual(
|
||||
expect.objectContaining({ schemaVersion: 1 })
|
||||
);
|
||||
expect(listSharedTasks()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("coerces invalid status and source to defaults", () => {
|
||||
tempDir = makeTempDir("shared-task-store-coerce");
|
||||
process.env.OPENCLAW_STATE_DIR = tempDir;
|
||||
|
||||
const task = upsertSharedTask({
|
||||
id: "t-coerce",
|
||||
title: "Coerce test",
|
||||
status: "banana" as never,
|
||||
source: "alien" as never,
|
||||
});
|
||||
expect(task.status).toBe("todo");
|
||||
expect(task.source).toBe("claw3d_manual");
|
||||
});
|
||||
|
||||
it("truncates oversized title and description", () => {
|
||||
tempDir = makeTempDir("shared-task-store-truncate");
|
||||
process.env.OPENCLAW_STATE_DIR = tempDir;
|
||||
|
||||
const longTitle = "A".repeat(1000);
|
||||
const longDesc = "B".repeat(10_000);
|
||||
const task = upsertSharedTask({
|
||||
id: "t-long",
|
||||
title: longTitle,
|
||||
description: longDesc,
|
||||
status: "todo",
|
||||
source: "claw3d_manual",
|
||||
});
|
||||
|
||||
expect(task.title.length).toBeLessThanOrEqual(500);
|
||||
expect(task.description.length).toBeLessThanOrEqual(5000);
|
||||
});
|
||||
|
||||
it("returns null when archiving a non-existent task", () => {
|
||||
tempDir = makeTempDir("shared-task-store-archive-missing");
|
||||
process.env.OPENCLAW_STATE_DIR = tempDir;
|
||||
|
||||
const result = archiveSharedTask("does-not-exist");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns an empty list when store file does not exist", () => {
|
||||
tempDir = makeTempDir("shared-task-store-missing-file");
|
||||
process.env.OPENCLAW_STATE_DIR = tempDir;
|
||||
|
||||
expect(listSharedTasks()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -15,11 +15,17 @@ describe("skill triggers", () => {
|
||||
const todoTrigger = listPackagedSkillTriggerDefinitions().find(
|
||||
(entry) => entry.skillKey === "todo-board",
|
||||
);
|
||||
const taskManagerTrigger = listPackagedSkillTriggerDefinitions().find(
|
||||
(entry) => entry.skillKey === "task-manager",
|
||||
);
|
||||
|
||||
expect(todoTrigger).not.toBeUndefined();
|
||||
expect(todoTrigger?.movementTarget).toBe("desk");
|
||||
expect(todoTrigger?.activationPhrases).toContain("todo");
|
||||
expect(todoTrigger?.activationPhrases).toContain("blocked tasks");
|
||||
expect(taskManagerTrigger).not.toBeUndefined();
|
||||
expect(taskManagerTrigger?.movementTarget).toBe("desk");
|
||||
expect(taskManagerTrigger?.activationPhrases).toContain("add a task");
|
||||
});
|
||||
|
||||
it("matches the running agent's latest request against enabled skill triggers", () => {
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { loadActiveStandupMeeting } from "@/lib/office/standup/store";
|
||||
import type { StandupMeetingStore } from "@/lib/office/standup/types";
|
||||
|
||||
const makeTempDir = (name: string) => fs.mkdtempSync(path.join(os.tmpdir(), `${name}-`));
|
||||
|
||||
const writeStandupStore = (stateDir: string, store: StandupMeetingStore) => {
|
||||
const storeDir = path.join(stateDir, "claw3d");
|
||||
fs.mkdirSync(storeDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(storeDir, "standup-store.json"),
|
||||
JSON.stringify(store, null, 2),
|
||||
"utf8"
|
||||
);
|
||||
};
|
||||
|
||||
describe("standup store", () => {
|
||||
const priorStateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
let tempDir: string | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
process.env.OPENCLAW_STATE_DIR = priorStateDir;
|
||||
if (tempDir) {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
tempDir = null;
|
||||
}
|
||||
});
|
||||
|
||||
it("drops stale active gathering meetings on load", () => {
|
||||
tempDir = makeTempDir("standup-store-stale");
|
||||
process.env.OPENCLAW_STATE_DIR = tempDir;
|
||||
const staleIso = new Date(Date.now() - 60 * 60 * 1000).toISOString();
|
||||
writeStandupStore(tempDir, {
|
||||
activeMeeting: {
|
||||
id: "meeting-stale",
|
||||
trigger: "manual",
|
||||
phase: "gathering",
|
||||
scheduledFor: null,
|
||||
startedAt: staleIso,
|
||||
updatedAt: staleIso,
|
||||
completedAt: null,
|
||||
currentSpeakerAgentId: null,
|
||||
speakerStartedAt: null,
|
||||
speakerDurationMs: 8000,
|
||||
participantOrder: ["main"],
|
||||
arrivedAgentIds: ["main"],
|
||||
cards: [],
|
||||
},
|
||||
lastMeeting: null,
|
||||
});
|
||||
|
||||
expect(loadActiveStandupMeeting()).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps fresh active gatherings on load", () => {
|
||||
tempDir = makeTempDir("standup-store-fresh");
|
||||
process.env.OPENCLAW_STATE_DIR = tempDir;
|
||||
const freshIso = new Date().toISOString();
|
||||
writeStandupStore(tempDir, {
|
||||
activeMeeting: {
|
||||
id: "meeting-fresh",
|
||||
trigger: "manual",
|
||||
phase: "gathering",
|
||||
scheduledFor: null,
|
||||
startedAt: freshIso,
|
||||
updatedAt: freshIso,
|
||||
completedAt: null,
|
||||
currentSpeakerAgentId: null,
|
||||
speakerStartedAt: null,
|
||||
speakerDurationMs: 8000,
|
||||
participantOrder: ["main"],
|
||||
arrivedAgentIds: [],
|
||||
cards: [],
|
||||
},
|
||||
lastMeeting: null,
|
||||
});
|
||||
|
||||
expect(loadActiveStandupMeeting()?.id).toBe("meeting-fresh");
|
||||
});
|
||||
|
||||
it("drops stale gathering meetings even if arrivals refreshed updatedAt", () => {
|
||||
tempDir = makeTempDir("standup-store-gathering-updated");
|
||||
process.env.OPENCLAW_STATE_DIR = tempDir;
|
||||
const staleStartedIso = new Date(Date.now() - 10 * 60 * 1000).toISOString();
|
||||
const freshUpdatedIso = new Date().toISOString();
|
||||
writeStandupStore(tempDir, {
|
||||
activeMeeting: {
|
||||
id: "meeting-gathering-stale",
|
||||
trigger: "manual",
|
||||
phase: "gathering",
|
||||
scheduledFor: null,
|
||||
startedAt: staleStartedIso,
|
||||
updatedAt: freshUpdatedIso,
|
||||
completedAt: null,
|
||||
currentSpeakerAgentId: null,
|
||||
speakerStartedAt: null,
|
||||
speakerDurationMs: 8000,
|
||||
participantOrder: ["main"],
|
||||
arrivedAgentIds: ["main"],
|
||||
cards: [],
|
||||
},
|
||||
lastMeeting: null,
|
||||
});
|
||||
|
||||
expect(loadActiveStandupMeeting()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -191,4 +191,113 @@ describe("studio settings normalization", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes task board cards per gateway", () => {
|
||||
const normalized = normalizeStudioSettings({
|
||||
taskBoard: {
|
||||
" ws://localhost:18789 ": {
|
||||
cards: [
|
||||
{
|
||||
id: " task-1 ",
|
||||
title: " Review kanban interaction ",
|
||||
status: "review",
|
||||
source: "openclaw_event",
|
||||
assignedAgentId: " agent-1 ",
|
||||
createdAt: "2026-03-29T10:00:00.000Z",
|
||||
updatedAt: "2026-03-29T10:05:00.000Z",
|
||||
notes: [" note one ", " ", "note two"],
|
||||
},
|
||||
],
|
||||
selectedCardId: " task-1 ",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(normalized.taskBoard?.["ws://localhost:18789"]).toEqual(
|
||||
expect.objectContaining({
|
||||
selectedCardId: "task-1",
|
||||
cards: [
|
||||
expect.objectContaining({
|
||||
id: "task-1",
|
||||
title: "Review kanban interaction",
|
||||
assignedAgentId: "agent-1",
|
||||
notes: ["note one", "note two"],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("merges task board patches", () => {
|
||||
const current = normalizeStudioSettings({
|
||||
taskBoard: {
|
||||
"ws://localhost:18789": {
|
||||
cards: [
|
||||
{
|
||||
id: "task-1",
|
||||
title: "Initial task",
|
||||
description: "",
|
||||
status: "todo",
|
||||
source: "claw3d_manual",
|
||||
sourceEventId: null,
|
||||
assignedAgentId: null,
|
||||
createdAt: "2026-03-29T10:00:00.000Z",
|
||||
updatedAt: "2026-03-29T10:00:00.000Z",
|
||||
playbookJobId: null,
|
||||
runId: null,
|
||||
channel: null,
|
||||
externalThreadId: null,
|
||||
lastActivityAt: null,
|
||||
notes: [],
|
||||
isArchived: false,
|
||||
isInferred: false,
|
||||
},
|
||||
],
|
||||
selectedCardId: "task-1",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const merged = mergeStudioSettings(current, {
|
||||
taskBoard: {
|
||||
"ws://localhost:18789": {
|
||||
cards: [
|
||||
{
|
||||
id: "task-2",
|
||||
title: "Replacement task",
|
||||
description: "",
|
||||
status: "in_progress",
|
||||
source: "claw3d_manual",
|
||||
sourceEventId: null,
|
||||
assignedAgentId: null,
|
||||
createdAt: "2026-03-29T10:10:00.000Z",
|
||||
updatedAt: "2026-03-29T10:10:00.000Z",
|
||||
playbookJobId: null,
|
||||
runId: null,
|
||||
channel: null,
|
||||
externalThreadId: null,
|
||||
lastActivityAt: null,
|
||||
notes: [],
|
||||
isArchived: false,
|
||||
isInferred: false,
|
||||
},
|
||||
],
|
||||
selectedCardId: "task-2",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(merged.taskBoard?.["ws://localhost:18789"]).toEqual(
|
||||
expect.objectContaining({
|
||||
selectedCardId: "task-2",
|
||||
cards: [
|
||||
expect.objectContaining({
|
||||
id: "task-2",
|
||||
title: "Replacement task",
|
||||
status: "in_progress",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
import type { RunRecord } from "@/features/office/hooks/useRunLog";
|
||||
import {
|
||||
deriveFallbackChatCard,
|
||||
deriveRecoveredAgentRequestCard,
|
||||
deriveLiveSessionTaskCard,
|
||||
isActionableTaskRequest,
|
||||
parseExplicitTaskEvent,
|
||||
syncCardWithLinkedRun,
|
||||
} from "@/features/office/tasks/useTaskBoardController";
|
||||
|
||||
const makeAgent = (overrides: Partial<AgentState> = {}) =>
|
||||
({
|
||||
agentId: "agent-1",
|
||||
name: "Agent One",
|
||||
sessionKey: "agent:agent-1:main",
|
||||
awaitingUserInput: false,
|
||||
...overrides,
|
||||
}) as AgentState;
|
||||
|
||||
describe("task board controller helpers", () => {
|
||||
it("parses explicit OpenClaw task events", () => {
|
||||
const parsed = parseExplicitTaskEvent({
|
||||
type: "event",
|
||||
event: "task_status_changed",
|
||||
seq: 42,
|
||||
payload: {
|
||||
taskId: "task-42",
|
||||
title: "Ship the kanban board",
|
||||
status: "review",
|
||||
assignedAgentId: "agent-1",
|
||||
runId: "run-1",
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed).toEqual(
|
||||
expect.objectContaining({
|
||||
taskId: "task-42",
|
||||
title: "Ship the kanban board",
|
||||
status: "review",
|
||||
assignedAgentId: "agent-1",
|
||||
runId: "run-1",
|
||||
sourceEventId: "task_status_changed:42",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("derives fallback cards from user chat requests", () => {
|
||||
const card = deriveFallbackChatCard(
|
||||
{
|
||||
type: "event",
|
||||
event: "chat",
|
||||
payload: {
|
||||
sessionKey: "agent:agent-1:main",
|
||||
seq: 7,
|
||||
channel: "telegram",
|
||||
message: {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Create a website for me." }],
|
||||
},
|
||||
},
|
||||
},
|
||||
[makeAgent()],
|
||||
);
|
||||
|
||||
expect(card).toEqual(
|
||||
expect.objectContaining({
|
||||
id: "chat:agent:agent-1:main:7",
|
||||
title: "Create a website for me.",
|
||||
assignedAgentId: "agent-1",
|
||||
channel: "telegram",
|
||||
source: "fallback_inferred",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("treats plain inbound user asks as live session tasks", () => {
|
||||
const card = deriveLiveSessionTaskCard(
|
||||
{
|
||||
type: "event",
|
||||
event: "chat",
|
||||
payload: {
|
||||
sessionKey: "agent:agent-1:main",
|
||||
seq: 8,
|
||||
channel: "telegram",
|
||||
message: {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Can you check the latest news on OpenClaw?" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
[makeAgent()],
|
||||
);
|
||||
|
||||
expect(card).toEqual(
|
||||
expect.objectContaining({
|
||||
id: "chat:agent:agent-1:main:8",
|
||||
title: "Can you check the latest news on OpenClaw?",
|
||||
assignedAgentId: "agent-1",
|
||||
channel: "telegram",
|
||||
externalThreadId: "agent:agent-1:main",
|
||||
source: "openclaw_event",
|
||||
isInferred: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("filters conversational messages out of task capture", () => {
|
||||
expect(isActionableTaskRequest("?")).toBe(false);
|
||||
expect(isActionableTaskRequest("are you there")).toBe(false);
|
||||
expect(isActionableTaskRequest("thanks")).toBe(false);
|
||||
expect(isActionableTaskRequest("Can you research about Paul Brady in Tulsa, OK?")).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts messages with common verb typos", () => {
|
||||
expect(isActionableTaskRequest("Rearch who is Luke the dev")).toBe(true);
|
||||
expect(isActionableTaskRequest("Reserch best practices for React")).toBe(true);
|
||||
expect(isActionableTaskRequest("Resarch the latest trends")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts 5+ word messages without punctuation", () => {
|
||||
expect(isActionableTaskRequest("do a deep dive into kubernetes networking")).toBe(true);
|
||||
expect(isActionableTaskRequest("check the logs from last deployment")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects very short non-verb messages", () => {
|
||||
expect(isActionableTaskRequest("ok sure")).toBe(false);
|
||||
expect(isActionableTaskRequest("hi")).toBe(false);
|
||||
});
|
||||
|
||||
it("recovers latest user asks from agent transcript history", () => {
|
||||
const card = deriveRecoveredAgentRequestCard(
|
||||
makeAgent({
|
||||
lastActivityAt: Date.parse("2026-03-30T20:00:00.000Z"),
|
||||
transcriptEntries: [
|
||||
{
|
||||
entryId: "assistant-1",
|
||||
role: "assistant",
|
||||
kind: "assistant",
|
||||
text: "Sure, I'll check.",
|
||||
sessionKey: "agent:agent-1:main",
|
||||
runId: "run-1",
|
||||
source: "history",
|
||||
timestampMs: Date.parse("2026-03-30T20:00:05.000Z"),
|
||||
sequenceKey: 2,
|
||||
confirmed: true,
|
||||
fingerprint: "assistant-1",
|
||||
},
|
||||
{
|
||||
entryId: "user-1",
|
||||
role: "user",
|
||||
kind: "user",
|
||||
text: "Can you check the latest news on OpenClaw?",
|
||||
sessionKey: "agent:agent-1:main",
|
||||
runId: null,
|
||||
source: "history",
|
||||
timestampMs: Date.parse("2026-03-30T20:00:00.000Z"),
|
||||
sequenceKey: 1,
|
||||
confirmed: true,
|
||||
fingerprint: "user-1",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(card).toEqual(
|
||||
expect.objectContaining({
|
||||
id: "history:agent:agent-1:main:1",
|
||||
title: "Can you check the latest news on OpenClaw?",
|
||||
assignedAgentId: "agent-1",
|
||||
externalThreadId: "agent:agent-1:main",
|
||||
source: "openclaw_event",
|
||||
isInferred: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not recover conversational transcript entries as tasks", () => {
|
||||
const card = deriveRecoveredAgentRequestCard(
|
||||
makeAgent({
|
||||
lastActivityAt: Date.parse("2026-03-30T20:00:00.000Z"),
|
||||
transcriptEntries: [
|
||||
{
|
||||
entryId: "user-1",
|
||||
role: "user",
|
||||
kind: "user",
|
||||
text: "are you there",
|
||||
sessionKey: "agent:agent-1:main",
|
||||
runId: null,
|
||||
source: "history",
|
||||
timestampMs: Date.parse("2026-03-30T20:00:00.000Z"),
|
||||
sequenceKey: 1,
|
||||
confirmed: true,
|
||||
fingerprint: "user-1",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(card).toBeNull();
|
||||
});
|
||||
|
||||
it("updates linked run cards to done or blocked", () => {
|
||||
const baseCard = {
|
||||
id: "task-1",
|
||||
title: "Review patch",
|
||||
description: "",
|
||||
status: "in_progress" as const,
|
||||
source: "claw3d_manual" as const,
|
||||
sourceEventId: null,
|
||||
assignedAgentId: "agent-1",
|
||||
createdAt: "2026-03-29T10:00:00.000Z",
|
||||
updatedAt: "2026-03-29T10:00:00.000Z",
|
||||
playbookJobId: null,
|
||||
runId: "run-1",
|
||||
channel: null,
|
||||
externalThreadId: null,
|
||||
lastActivityAt: null,
|
||||
notes: [],
|
||||
isArchived: false,
|
||||
isInferred: false,
|
||||
};
|
||||
const okRun: RunRecord = {
|
||||
runId: "run-1",
|
||||
agentId: "agent-1",
|
||||
agentName: "Agent One",
|
||||
startedAt: Date.parse("2026-03-29T10:00:00.000Z"),
|
||||
endedAt: Date.parse("2026-03-29T10:03:00.000Z"),
|
||||
outcome: "ok",
|
||||
trigger: "user",
|
||||
};
|
||||
const errorRun: RunRecord = {
|
||||
...okRun,
|
||||
outcome: "error",
|
||||
};
|
||||
|
||||
expect(syncCardWithLinkedRun(baseCard, [okRun]).status).toBe("review");
|
||||
expect(syncCardWithLinkedRun(baseCard, [errorRun]).status).toBe("blocked");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { createElement } from "react";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { TaskBoardView } from "@/features/office/tasks/TaskBoardView";
|
||||
import type { TaskBoardCard } from "@/features/office/tasks/types";
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
import type { CronJobSummary } from "@/lib/cron/types";
|
||||
|
||||
const createCard = (overrides: Partial<TaskBoardCard> = {}): TaskBoardCard => ({
|
||||
id: "task-1",
|
||||
title: "New task",
|
||||
description: "",
|
||||
status: "todo",
|
||||
source: "claw3d_manual",
|
||||
sourceEventId: null,
|
||||
assignedAgentId: null,
|
||||
createdAt: "2026-03-29T10:00:00.000Z",
|
||||
updatedAt: "2026-03-29T10:00:00.000Z",
|
||||
playbookJobId: null,
|
||||
runId: null,
|
||||
channel: null,
|
||||
externalThreadId: null,
|
||||
lastActivityAt: null,
|
||||
notes: [],
|
||||
isArchived: false,
|
||||
isInferred: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createAgent = (): AgentState => ({
|
||||
agentId: "agent-1",
|
||||
name: "Agent One",
|
||||
sessionKey: "agent:agent-1:main",
|
||||
status: "idle",
|
||||
sessionCreated: true,
|
||||
awaitingUserInput: false,
|
||||
hasUnseenActivity: false,
|
||||
outputLines: [],
|
||||
lastResult: null,
|
||||
lastDiff: null,
|
||||
runId: null,
|
||||
runStartedAt: null,
|
||||
streamText: null,
|
||||
thinkingTrace: null,
|
||||
latestOverride: null,
|
||||
latestOverrideKind: null,
|
||||
lastAssistantMessageAt: null,
|
||||
lastActivityAt: null,
|
||||
latestPreview: null,
|
||||
lastUserMessage: null,
|
||||
draft: "",
|
||||
sessionSettingsSynced: true,
|
||||
historyLoadedAt: null,
|
||||
historyFetchLimit: null,
|
||||
historyFetchedCount: null,
|
||||
historyMaybeTruncated: false,
|
||||
toolCallingEnabled: true,
|
||||
showThinkingTraces: true,
|
||||
model: "openai/gpt-5",
|
||||
thinkingLevel: "medium",
|
||||
avatarSeed: "seed-1",
|
||||
avatarUrl: null,
|
||||
});
|
||||
|
||||
const createCronJob = (): CronJobSummary => ({
|
||||
id: "job-1",
|
||||
name: "Morning review",
|
||||
agentId: "agent-1",
|
||||
enabled: true,
|
||||
updatedAtMs: Date.now(),
|
||||
schedule: { kind: "every", everyMs: 60_000 },
|
||||
sessionTarget: "isolated",
|
||||
wakeMode: "now",
|
||||
payload: { kind: "agentTurn", message: "Review new tasks." },
|
||||
state: {},
|
||||
});
|
||||
|
||||
describe("TaskBoardView", () => {
|
||||
it("routes task edits through callbacks", () => {
|
||||
const onCreateCard = vi.fn();
|
||||
const onMoveCard = vi.fn();
|
||||
const onSelectCard = vi.fn();
|
||||
const onUpdateCard = vi.fn();
|
||||
const onDeleteCard = vi.fn();
|
||||
const onRefreshCronJobs = vi.fn();
|
||||
const selectedCard = createCard();
|
||||
|
||||
render(
|
||||
createElement(TaskBoardView, {
|
||||
title: "Kanban",
|
||||
subtitle: "Track tasks.",
|
||||
agents: [createAgent()],
|
||||
cardsByStatus: {
|
||||
todo: [selectedCard],
|
||||
in_progress: [],
|
||||
blocked: [],
|
||||
review: [],
|
||||
done: [],
|
||||
},
|
||||
selectedCard,
|
||||
activeRuns: [{ runId: "run-1", agentId: "agent-1", label: "Agent One" }],
|
||||
cronJobs: [createCronJob()],
|
||||
cronLoading: false,
|
||||
cronError: null,
|
||||
onCreateCard,
|
||||
onMoveCard,
|
||||
onSelectCard,
|
||||
onUpdateCard,
|
||||
onDeleteCard,
|
||||
onRefreshCronJobs,
|
||||
})
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getAllByRole("button", { name: /new task/i })[0]!);
|
||||
fireEvent.click(screen.getByRole("button", { name: /refresh/i }));
|
||||
fireEvent.click(screen.getAllByRole("button", { name: /new task/i })[1]!);
|
||||
fireEvent.change(screen.getByLabelText("Title"), {
|
||||
target: { value: "Create marketing website" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("Status"), {
|
||||
target: { value: "in_progress" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("Assigned agent"), {
|
||||
target: { value: "agent-1" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /delete task/i }));
|
||||
|
||||
expect(onCreateCard).toHaveBeenCalledTimes(1);
|
||||
expect(onRefreshCronJobs).toHaveBeenCalledTimes(1);
|
||||
expect(onSelectCard).toHaveBeenCalledWith("task-1");
|
||||
expect(onUpdateCard).toHaveBeenCalledWith("task-1", { title: "Create marketing website" });
|
||||
expect(onMoveCard).toHaveBeenCalledWith("task-1", "in_progress");
|
||||
expect(onUpdateCard).toHaveBeenCalledWith("task-1", { assignedAgentId: "agent-1" });
|
||||
expect(onDeleteCard).toHaveBeenCalledWith("task-1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
|
||||
import { GatewayResponseError } from "@/lib/gateway/errors";
|
||||
import {
|
||||
createGatewayTask,
|
||||
deleteGatewayTask,
|
||||
isUnsupportedTaskGatewayError,
|
||||
listGatewayTasks,
|
||||
updateGatewayTask,
|
||||
} from "@/lib/tasks/gateway";
|
||||
|
||||
describe("task gateway client", () => {
|
||||
it("lists tasks via tasks.list", async () => {
|
||||
const client = {
|
||||
call: vi.fn(async () => ({ tasks: [] })),
|
||||
} as unknown as GatewayClient;
|
||||
|
||||
await listGatewayTasks(client);
|
||||
|
||||
expect(client.call).toHaveBeenCalledWith("tasks.list", { includeArchived: true });
|
||||
});
|
||||
|
||||
it("creates tasks via tasks.create", async () => {
|
||||
const client = {
|
||||
call: vi.fn(async () => ({ id: "task-1", title: "Ship board", status: "todo" })),
|
||||
} as unknown as GatewayClient;
|
||||
|
||||
await createGatewayTask(client, {
|
||||
title: "Ship board",
|
||||
description: "Release the board.",
|
||||
status: "todo",
|
||||
source: "claw3d_manual",
|
||||
});
|
||||
|
||||
expect(client.call).toHaveBeenCalledWith(
|
||||
"tasks.create",
|
||||
expect.objectContaining({
|
||||
title: "Ship board",
|
||||
description: "Release the board.",
|
||||
status: "todo",
|
||||
source: "claw3d_manual",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("updates and deletes tasks via gateway methods", async () => {
|
||||
const client = {
|
||||
call: vi.fn(async () => ({ ok: true })),
|
||||
} as unknown as GatewayClient;
|
||||
|
||||
await updateGatewayTask(client, "task-1", { status: "review" });
|
||||
await deleteGatewayTask(client, "task-1");
|
||||
|
||||
expect(client.call).toHaveBeenCalledWith(
|
||||
"tasks.update",
|
||||
expect.objectContaining({ id: "task-1", status: "review" })
|
||||
);
|
||||
expect(client.call).toHaveBeenCalledWith("tasks.delete", { id: "task-1" });
|
||||
});
|
||||
|
||||
it("detects unsupported task gateway methods", () => {
|
||||
expect(
|
||||
isUnsupportedTaskGatewayError(
|
||||
new GatewayResponseError({
|
||||
code: "METHOD_NOT_FOUND",
|
||||
message: "Unknown method tasks.list",
|
||||
})
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { DELETE, GET, PUT } from "@/app/api/task-store/route";
|
||||
|
||||
const makeTempDir = (name: string) => fs.mkdtempSync(path.join(os.tmpdir(), `${name}-`));
|
||||
|
||||
const makeRequest = (method: string, body?: unknown) =>
|
||||
new Request("http://localhost/api/task-store", {
|
||||
method,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
describe("task store route", () => {
|
||||
const priorStateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
let tempDir: string | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
process.env.OPENCLAW_STATE_DIR = priorStateDir;
|
||||
if (tempDir) {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
tempDir = null;
|
||||
}
|
||||
});
|
||||
|
||||
it("GET returns an empty task list by default", async () => {
|
||||
tempDir = makeTempDir("task-store-route-get");
|
||||
process.env.OPENCLAW_STATE_DIR = tempDir;
|
||||
|
||||
const response = await GET();
|
||||
const body = (await response.json()) as { tasks?: unknown[] };
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(body.tasks).toEqual([]);
|
||||
});
|
||||
|
||||
it("PUT upserts a task and DELETE archives it", async () => {
|
||||
tempDir = makeTempDir("task-store-route-put");
|
||||
process.env.OPENCLAW_STATE_DIR = tempDir;
|
||||
|
||||
const putResponse = await PUT(
|
||||
makeRequest("PUT", {
|
||||
task: {
|
||||
id: "task-1",
|
||||
title: "Research mtulsa.com",
|
||||
status: "todo",
|
||||
source: "claw3d_manual",
|
||||
},
|
||||
})
|
||||
);
|
||||
const putBody = (await putResponse.json()) as {
|
||||
task?: { id?: string; isArchived?: boolean; history?: Array<{ type?: string }> };
|
||||
};
|
||||
|
||||
expect(putResponse.status).toBe(200);
|
||||
expect(putBody.task?.id).toBe("task-1");
|
||||
expect(putBody.task?.history?.[0]?.type).toBe("created");
|
||||
|
||||
const deleteResponse = await DELETE(
|
||||
makeRequest("DELETE", { id: "task-1" })
|
||||
);
|
||||
const deleteBody = (await deleteResponse.json()) as {
|
||||
task?: { isArchived?: boolean; history?: Array<{ type?: string }> };
|
||||
};
|
||||
|
||||
expect(deleteResponse.status).toBe(200);
|
||||
expect(deleteBody.task?.isArchived).toBe(true);
|
||||
expect(deleteBody.task?.history?.some((entry) => entry.type === "archived")).toBe(true);
|
||||
});
|
||||
|
||||
it("PUT returns 400 for missing task payload", async () => {
|
||||
tempDir = makeTempDir("task-store-route-put-no-task");
|
||||
process.env.OPENCLAW_STATE_DIR = tempDir;
|
||||
|
||||
const response = await PUT(makeRequest("PUT", { notTask: true }));
|
||||
expect(response.status).toBe(400);
|
||||
const body = (await response.json()) as { error?: string };
|
||||
expect(body.error).toContain("Task payload is required");
|
||||
});
|
||||
|
||||
it("PUT returns 400 for empty id or title", async () => {
|
||||
tempDir = makeTempDir("task-store-route-put-empty");
|
||||
process.env.OPENCLAW_STATE_DIR = tempDir;
|
||||
|
||||
const response = await PUT(
|
||||
makeRequest("PUT", { task: { id: "", title: "Something" } })
|
||||
);
|
||||
expect(response.status).toBe(400);
|
||||
|
||||
const response2 = await PUT(
|
||||
makeRequest("PUT", { task: { id: "x", title: "" } })
|
||||
);
|
||||
expect(response2.status).toBe(400);
|
||||
});
|
||||
|
||||
it("PUT returns 400 for invalid status enum", async () => {
|
||||
tempDir = makeTempDir("task-store-route-put-bad-status");
|
||||
process.env.OPENCLAW_STATE_DIR = tempDir;
|
||||
|
||||
const response = await PUT(
|
||||
makeRequest("PUT", {
|
||||
task: { id: "t-1", title: "Test", status: "banana" },
|
||||
})
|
||||
);
|
||||
expect(response.status).toBe(400);
|
||||
const body = (await response.json()) as { error?: string };
|
||||
expect(body.error).toContain("Invalid status");
|
||||
});
|
||||
|
||||
it("PUT returns 400 for invalid source enum", async () => {
|
||||
tempDir = makeTempDir("task-store-route-put-bad-source");
|
||||
process.env.OPENCLAW_STATE_DIR = tempDir;
|
||||
|
||||
const response = await PUT(
|
||||
makeRequest("PUT", {
|
||||
task: { id: "t-1", title: "Test", source: "alien" },
|
||||
})
|
||||
);
|
||||
expect(response.status).toBe(400);
|
||||
const body = (await response.json()) as { error?: string };
|
||||
expect(body.error).toContain("Invalid source");
|
||||
});
|
||||
|
||||
it("PUT returns 400 for invalid JSON body", async () => {
|
||||
tempDir = makeTempDir("task-store-route-put-bad-json");
|
||||
process.env.OPENCLAW_STATE_DIR = tempDir;
|
||||
|
||||
const response = await PUT(
|
||||
new Request("http://localhost/api/task-store", {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: "not valid json{{{",
|
||||
})
|
||||
);
|
||||
expect(response.status).toBe(400);
|
||||
const body = (await response.json()) as { error?: string };
|
||||
expect(body.error).toContain("Invalid JSON");
|
||||
});
|
||||
|
||||
it("DELETE returns 404 for non-existent task", async () => {
|
||||
tempDir = makeTempDir("task-store-route-delete-404");
|
||||
process.env.OPENCLAW_STATE_DIR = tempDir;
|
||||
|
||||
const response = await DELETE(
|
||||
makeRequest("DELETE", { id: "does-not-exist" })
|
||||
);
|
||||
expect(response.status).toBe(404);
|
||||
const body = (await response.json()) as { error?: string };
|
||||
expect(body.error).toContain("not found");
|
||||
});
|
||||
|
||||
it("DELETE returns 400 for missing id", async () => {
|
||||
tempDir = makeTempDir("task-store-route-delete-no-id");
|
||||
process.env.OPENCLAW_STATE_DIR = tempDir;
|
||||
|
||||
const response = await DELETE(
|
||||
makeRequest("DELETE", { id: "" })
|
||||
);
|
||||
expect(response.status).toBe(400);
|
||||
const body = (await response.json()) as { error?: string };
|
||||
expect(body.error).toContain("id is required");
|
||||
});
|
||||
|
||||
it("DELETE returns 400 for invalid JSON body", async () => {
|
||||
tempDir = makeTempDir("task-store-route-delete-bad-json");
|
||||
process.env.OPENCLAW_STATE_DIR = tempDir;
|
||||
|
||||
const response = await DELETE(
|
||||
new Request("http://localhost/api/task-store", {
|
||||
method: "DELETE",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: "broken{",
|
||||
})
|
||||
);
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it("all responses include cache-control: no-store", async () => {
|
||||
tempDir = makeTempDir("task-store-route-cache");
|
||||
process.env.OPENCLAW_STATE_DIR = tempDir;
|
||||
|
||||
const getResp = await GET();
|
||||
expect(getResp.headers.get("cache-control")).toBe("no-store");
|
||||
|
||||
const putResp = await PUT(
|
||||
makeRequest("PUT", { task: { id: "t-1", title: "T" } })
|
||||
);
|
||||
expect(putResp.headers.get("cache-control")).toBe("no-store");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user