diff --git a/assets/skills/task-manager/SKILL.md b/assets/skills/task-manager/SKILL.md new file mode 100644 index 0000000..60748db --- /dev/null +++ b/assets/skills/task-manager/SKILL.md @@ -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. diff --git a/assets/skills/task-manager/tasks.example.json b/assets/skills/task-manager/tasks.example.json new file mode 100644 index 0000000..e46b774 --- /dev/null +++ b/assets/skills/task-manager/tasks.example.json @@ -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" + } + ] + } + ] +} diff --git a/playwright.kanban.config.ts b/playwright.kanban.config.ts new file mode 100644 index 0000000..f82c02c --- /dev/null +++ b/playwright.kanban.config.ts @@ -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: "", + }, + }, +}); diff --git a/src/app/api/task-store/route.ts b/src/app/api/task-store/route.ts new file mode 100644 index 0000000..2aae975 --- /dev/null +++ b/src/app/api/task-store/route.ts @@ -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 => + 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); + } +} diff --git a/src/features/agents/components/AgentAvatar.tsx b/src/features/agents/components/AgentAvatar.tsx index 2232123..25e79de 100644 --- a/src/features/agents/components/AgentAvatar.tsx +++ b/src/features/agents/components/AgentAvatar.tsx @@ -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 (
void; - onOpenSettings: () => void; onRename?: (name: string) => Promise; onNewSession?: () => Promise | 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({
- +
@@ -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({
- +
@@ -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({ { 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 = ({ -
@@ -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} diff --git a/src/features/agents/screens/AgentsPageScreen.tsx b/src/features/agents/screens/AgentsPageScreen.tsx index 159e451..36e2cd0 100644 --- a/src/features/agents/screens/AgentsPageScreen.tsx +++ b/src/features/agents/screens/AgentsPageScreen.tsx @@ -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) } diff --git a/src/features/office/components/HQSidebar.tsx b/src/features/office/components/HQSidebar.tsx index 27f1df9..1968bbb 100644 --- a/src/features/office/components/HQSidebar.tsx +++ b/src/features/office/components/HQSidebar.tsx @@ -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 = { 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 (
{open ? ( -
+
{analyticsOnly ? "ANALYTICS" : "HEADQUARTERS"} @@ -151,7 +162,7 @@ export function HQSidebar({
{PRIMARY_TABS.map((tab) => { const isActive = tab === activeTab; diff --git a/src/features/office/components/panels/KanbanDisabledPanel.tsx b/src/features/office/components/panels/KanbanDisabledPanel.tsx new file mode 100644 index 0000000..91cbd63 --- /dev/null +++ b/src/features/office/components/panels/KanbanDisabledPanel.tsx @@ -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 ( +
+
+
+ Kanban +
+ +
+ Task Manager +
+

Kanban Skill Not Installed

+

+ Install the TASK-MANAGER skill to let your + agents capture work as tasks and open the Kanban desk. +

+ + {installing ? ( +
+
+ + Installing + + + {Math.max(0, Math.min(100, Math.round(progressPercent)))}% + +
+
+
+
+

+ {progressMessage?.trim() || "Installing the task-manager skill."} +

+

+ Once it's installed, Claw3D will refresh the task-manager state. +

+
+ ) : null} + + {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} + +
+ + +
+
+
+ ); +} diff --git a/src/features/office/components/panels/TaskBoardPanel.tsx b/src/features/office/components/panels/TaskBoardPanel.tsx new file mode 100644 index 0000000..26319c1 --- /dev/null +++ b/src/features/office/components/panels/TaskBoardPanel.tsx @@ -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; + selectedCard: TaskBoardCard | null; + activeRuns: Array<{ runId: string; agentId: string; label: string }>; + cronJobs: CronJobSummary[]; + cronLoading: boolean; + cronError: string | null; + taskCaptureDebug?: ComponentProps["taskCaptureDebug"]; + onCreateCard: () => void; + onMoveCard: (cardId: string, status: TaskBoardStatus) => void; + onSelectCard: (cardId: string | null) => void; + onUpdateCard: (cardId: string, patch: Partial) => void; + onDeleteCard: (cardId: string) => void; + onRefreshCronJobs: () => void; +}) { + return ( + + ); +} diff --git a/src/features/office/hooks/useOfficeSkillsMarketplace.ts b/src/features/office/hooks/useOfficeSkillsMarketplace.ts index b14dfe2..746fe33 100644 --- a/src/features/office/hooks/useOfficeSkillsMarketplace.ts +++ b/src/features/office/hooks/useOfficeSkillsMarketplace.ts @@ -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, }; diff --git a/src/features/office/screens/KanbanImmersiveScreen.tsx b/src/features/office/screens/KanbanImmersiveScreen.tsx new file mode 100644 index 0000000..502860f --- /dev/null +++ b/src/features/office/screens/KanbanImmersiveScreen.tsx @@ -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; + selectedCard: TaskBoardCard | null; + activeRuns: Array<{ runId: string; agentId: string; label: string }>; + cronJobs: CronJobSummary[]; + cronLoading: boolean; + cronError: string | null; + taskCaptureDebug?: ComponentProps["taskCaptureDebug"]; + onCreateCard: () => void; + onMoveCard: (cardId: string, status: TaskBoardStatus) => void; + onSelectCard: (cardId: string | null) => void; + onUpdateCard: (cardId: string, patch: Partial) => void; + onDeleteCard: (cardId: string) => void; + onRefreshCronJobs: () => void; + onClose: () => void; +}) { + const dialogRef = useRef(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 ( +
{ + if (e.target === e.currentTarget) onClose(); + }} + > +
+ + +
+
+ +
+
+
+
+ ); +} diff --git a/src/features/office/screens/OfficeScreen.tsx b/src/features/office/screens/OfficeScreen.tsx index 68667e0..8f3e68c 100644 --- a/src/features/office/screens/OfficeScreen.tsx +++ b/src/features/office/screens/OfficeScreen.tsx @@ -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>(async () => {}); const [officeTriggerState, setOfficeTriggerState] = useState(() => createOfficeAnimationTriggerState(), ); @@ -963,6 +968,18 @@ export function OfficeScreen({ const [gatewayModels, setGatewayModels] = useState([]); 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>({}); 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( + () => + 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 ? ( + { + 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} {showEmptyFleetBanner ? ( @@ -4311,6 +4456,33 @@ export function OfficeScreen({ }} /> } + kanbanPanel={ + { + 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={ {}} - onOpenSettings={() => { - router.push("/office"); - }} onNewSession={() => chatController.handleNewSession(focusedChatAgent.agentId) } diff --git a/src/features/office/tasks/TaskBoardView.tsx b/src/features/office/tasks/TaskBoardView.tsx new file mode 100644 index 0000000..aa97401 --- /dev/null +++ b/src/features/office/tasks/TaskBoardView.tsx @@ -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 = { + 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) => { + 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; + 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) => void; + onDeleteCard: (cardId: string) => void; + onRefreshCronJobs: () => void; +}) { + return ( +
+
+
+
+
+ {title} +
+
{subtitle}
+
+
+ + +
+
+ {cronError ? ( +
+ {cronError} +
+ ) : null} + {taskCaptureDebug ? ( +
+ +
+ Capture debug + Status: {taskCaptureDebug.lastStatus} + Visible cards: {taskCaptureDebug.visibleCardCount} + Tracked cards: {taskCaptureDebug.totalCardCount} + Detected: {taskCaptureDebug.detectedCount} +
+
+
+
+ Last request: {taskCaptureDebug.lastTitle ?? "None yet."} +
+
+ Last task id: {taskCaptureDebug.lastTaskId ?? "-"} +
+
+ Session/thread: {taskCaptureDebug.lastSessionKey ?? "-"} +
+
+ Last update: {formatRelativeTime(taskCaptureDebug.lastUpdatedAt)} +
+
+ Shared store:{" "} + {taskCaptureDebug.sharedTasksSupported + ? taskCaptureDebug.sharedTasksLoading + ? "Syncing." + : "Available." + : "Unavailable."} +
+
+ Note: {taskCaptureDebug.lastMessage ?? "Waiting for inbound request detection."} +
+ {taskCaptureDebug.sharedTasksError ? ( +
+ Store error: {taskCaptureDebug.sharedTasksError} +
+ ) : null} +
+
+ ) : null} +
+ +
+
+
+ {STATUS_ORDER.map((status) => { + const cards = cardsByStatus[status]; + return ( +
{ + 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]" + > +
+
+
+ {STATUS_LABELS[status]} +
+
+ {cards.length} +
+
+
+
+ {cards.length === 0 ? ( +
+ Drop a card here. +
+ ) : ( + cards.map((card) => ( + + )) + )} +
+
+ ); + })} +
+
+ + {selectedCard ? ( +