From 7892dfad7caeb99cdd3b3d6e1b5ddaad0154a156 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Wed, 4 Feb 2026 18:13:17 +0530 Subject: [PATCH] feat(agents): Add task assignment and comments functionality --- .../8045fbfb157f_add_task_assigned_agent.py | 36 + .../b9d22e2a4d50_add_task_comments_index.py | 29 + backend/app/api/tasks.py | 71 +- backend/app/models/tasks.py | 1 + backend/app/schemas/tasks.py | 14 + docs/openclaw_gateway_ws.md | 1549 +++++++++++++++++ frontend/src/app/boards/[boardId]/page.tsx | 161 +- .../src/components/molecules/TaskCard.tsx | 22 +- .../src/components/organisms/TaskBoard.tsx | 5 + 9 files changed, 1882 insertions(+), 6 deletions(-) create mode 100644 backend/alembic/versions/8045fbfb157f_add_task_assigned_agent.py create mode 100644 backend/alembic/versions/b9d22e2a4d50_add_task_comments_index.py create mode 100644 docs/openclaw_gateway_ws.md diff --git a/backend/alembic/versions/8045fbfb157f_add_task_assigned_agent.py b/backend/alembic/versions/8045fbfb157f_add_task_assigned_agent.py new file mode 100644 index 00000000..31bf9588 --- /dev/null +++ b/backend/alembic/versions/8045fbfb157f_add_task_assigned_agent.py @@ -0,0 +1,36 @@ +"""add task assigned agent + +Revision ID: 8045fbfb157f +Revises: 6df47d330227 +Create Date: 2026-02-04 17:28:57.465934 + +""" +from __future__ import annotations + +from alembic import op + + +# revision identifiers, used by Alembic. +revision = '8045fbfb157f' +down_revision = '6df47d330227' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute( + "ALTER TABLE tasks ADD COLUMN IF NOT EXISTS assigned_agent_id UUID" + ) + op.execute( + "ALTER TABLE tasks ADD CONSTRAINT IF NOT EXISTS tasks_assigned_agent_id_fkey " + "FOREIGN KEY (assigned_agent_id) REFERENCES agents(id)" + ) + + +def downgrade() -> None: + op.execute( + "ALTER TABLE tasks DROP CONSTRAINT IF EXISTS tasks_assigned_agent_id_fkey" + ) + op.execute( + "ALTER TABLE tasks DROP COLUMN IF EXISTS assigned_agent_id" + ) diff --git a/backend/alembic/versions/b9d22e2a4d50_add_task_comments_index.py b/backend/alembic/versions/b9d22e2a4d50_add_task_comments_index.py new file mode 100644 index 00000000..1d5aad3d --- /dev/null +++ b/backend/alembic/versions/b9d22e2a4d50_add_task_comments_index.py @@ -0,0 +1,29 @@ +"""add task comments index + +Revision ID: b9d22e2a4d50 +Revises: 8045fbfb157f +Create Date: 2026-02-04 17:32:06.204331 + +""" +from __future__ import annotations + +from alembic import op + + +# revision identifiers, used by Alembic. +revision = 'b9d22e2a4d50' +down_revision = '8045fbfb157f' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute( + "CREATE INDEX IF NOT EXISTS ix_activity_events_task_comment " + "ON activity_events (task_id, created_at) " + "WHERE event_type = 'task.comment'" + ) + + +def downgrade() -> None: + op.execute("DROP INDEX IF EXISTS ix_activity_events_task_comment") diff --git a/backend/app/api/tasks.py b/backend/app/api/tasks.py index cf570602..b2bfd994 100644 --- a/backend/app/api/tasks.py +++ b/backend/app/api/tasks.py @@ -3,7 +3,8 @@ from __future__ import annotations from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, status -from sqlmodel import Session, select +from sqlalchemy import asc +from sqlmodel import Session, col, select from app.api.deps import ( ActorContext, @@ -14,9 +15,17 @@ from app.api.deps import ( ) from app.core.auth import AuthContext from app.db.session import get_session +from app.models.agents import Agent +from app.models.activity_events import ActivityEvent from app.models.boards import Board from app.models.tasks import Task -from app.schemas.tasks import TaskCreate, TaskRead, TaskUpdate +from app.schemas.tasks import ( + TaskCommentCreate, + TaskCommentRead, + TaskCreate, + TaskRead, + TaskUpdate, +) from app.services.activity_log import record_activity router = APIRouter(prefix="/boards/{board_id}/tasks", tags=["tasks"]) @@ -66,9 +75,25 @@ def update_task( previous_status = task.status updates = payload.model_dump(exclude_unset=True) if actor.actor_type == "agent": + if actor.agent and actor.agent.board_id and task.board_id: + if actor.agent.board_id != task.board_id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) allowed_fields = {"status"} if not set(updates).issubset(allowed_fields): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + if "status" in updates: + if updates["status"] == "inbox": + task.assigned_agent_id = None + else: + task.assigned_agent_id = actor.agent.id if actor.agent else None + elif "status" in updates and updates["status"] == "inbox": + task.assigned_agent_id = None + if "assigned_agent_id" in updates and updates["assigned_agent_id"]: + agent = session.get(Agent, updates["assigned_agent_id"]) + if agent is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + if agent.board_id and task.board_id and agent.board_id != task.board_id: + raise HTTPException(status_code=status.HTTP_409_CONFLICT) for key, value in updates.items(): setattr(task, key, value) task.updated_at = datetime.utcnow() @@ -103,3 +128,45 @@ def delete_task( session.delete(task) session.commit() return {"ok": True} + + +@router.get("/{task_id}/comments", response_model=list[TaskCommentRead]) +def list_task_comments( + task: Task = Depends(get_task_or_404), + session: Session = Depends(get_session), + actor: ActorContext = Depends(require_admin_or_agent), +) -> list[ActivityEvent]: + if actor.actor_type == "agent" and actor.agent: + if actor.agent.board_id and task.board_id and actor.agent.board_id != task.board_id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + statement = ( + select(ActivityEvent) + .where(col(ActivityEvent.task_id) == task.id) + .where(col(ActivityEvent.event_type) == "task.comment") + .order_by(asc(col(ActivityEvent.created_at))) + ) + return list(session.exec(statement)) + + +@router.post("/{task_id}/comments", response_model=TaskCommentRead) +def create_task_comment( + payload: TaskCommentCreate, + task: Task = Depends(get_task_or_404), + session: Session = Depends(get_session), + actor: ActorContext = Depends(require_admin_or_agent), +) -> ActivityEvent: + if actor.actor_type == "agent" and actor.agent: + if actor.agent.board_id and task.board_id and actor.agent.board_id != task.board_id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + if not payload.message.strip(): + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY) + event = ActivityEvent( + event_type="task.comment", + message=payload.message.strip(), + task_id=task.id, + agent_id=actor.agent.id if actor.actor_type == "agent" and actor.agent else None, + ) + session.add(event) + session.commit() + session.refresh(event) + return event diff --git a/backend/app/models/tasks.py b/backend/app/models/tasks.py index 9ff18818..31f9a579 100644 --- a/backend/app/models/tasks.py +++ b/backend/app/models/tasks.py @@ -21,6 +21,7 @@ class Task(TenantScoped, table=True): due_at: datetime | None = None created_by_user_id: UUID | None = Field(default=None, foreign_key="users.id", index=True) + assigned_agent_id: UUID | None = Field(default=None, foreign_key="agents.id", index=True) created_at: datetime = Field(default_factory=datetime.utcnow) updated_at: datetime = Field(default_factory=datetime.utcnow) diff --git a/backend/app/schemas/tasks.py b/backend/app/schemas/tasks.py index b2c0495f..817ee879 100644 --- a/backend/app/schemas/tasks.py +++ b/backend/app/schemas/tasks.py @@ -12,6 +12,7 @@ class TaskBase(SQLModel): status: str = "inbox" priority: str = "medium" due_at: datetime | None = None + assigned_agent_id: UUID | None = None class TaskCreate(TaskBase): @@ -24,6 +25,7 @@ class TaskUpdate(SQLModel): status: str | None = None priority: str | None = None due_at: datetime | None = None + assigned_agent_id: UUID | None = None class TaskRead(TaskBase): @@ -32,3 +34,15 @@ class TaskRead(TaskBase): created_by_user_id: UUID | None created_at: datetime updated_at: datetime + + +class TaskCommentCreate(SQLModel): + message: str + + +class TaskCommentRead(SQLModel): + id: UUID + message: str | None + agent_id: UUID | None + task_id: UUID | None + created_at: datetime diff --git a/docs/openclaw_gateway_ws.md b/docs/openclaw_gateway_ws.md new file mode 100644 index 00000000..90cfcbec --- /dev/null +++ b/docs/openclaw_gateway_ws.md @@ -0,0 +1,1549 @@ +# OpenClaw Gateway WebSocket Protocol + +This document describes how to interact with the OpenClaw Gateway over WebSocket. It is intended for humans and LLMs and includes frame formats, auth/scopes, events, and all known methods with params and response payloads. + +Protocol version: `3` +Default URL: `ws://127.0.0.1:18789` + +All timestamps are milliseconds since Unix epoch unless noted. + +## Connection Lifecycle + +1. Open a WebSocket connection to the gateway. +2. Server immediately sends an `event` frame named `connect.challenge` with a nonce. +3. Client must send a `req` frame with `method: "connect"` and `params: ConnectParams` as the first request. +4. Server responds with a `res` frame whose `payload` is a `HelloOk` object. + +If `connect` is not the first request, the server returns an error. + +### Connect Challenge + +Event payload: + +```ts +type ConnectChallenge = { nonce: string; ts: number }; +``` + +If you provide `device.nonce` in `connect`, it must match this `nonce`. + +## Frame Formats + +```ts +type RequestFrame = { + type: "req"; + id: string; // client-generated + method: string; + params?: unknown; +}; + +type ResponseFrame = { + type: "res"; + id: string; // matches RequestFrame.id + ok: boolean; + payload?: unknown; + error?: ErrorShape; +}; + +type EventFrame = { + type: "event"; + event: string; + payload?: unknown; + seq?: number; // optional event sequence counter + stateVersion?: { presence: number; health: number }; +}; +``` + +### Error Shape + +```ts +type ErrorShape = { + code: string; + message: string; + details?: unknown; + retryable?: boolean; + retryAfterMs?: number; +}; +``` + +Known error codes include: +`NOT_LINKED`, `NOT_PAIRED`, `AGENT_TIMEOUT`, `INVALID_REQUEST`, `UNAVAILABLE`. + +## Connect Params and HelloOk + +```ts +type ConnectParams = { + minProtocol: number; + maxProtocol: number; + client: { + id: "webchat-ui" | "openclaw-control-ui" | "webchat" | "cli" | "gateway-client" | "openclaw-macos" | "openclaw-ios" | "openclaw-android" | "node-host" | "test" | "fingerprint" | "openclaw-probe"; + displayName?: string; + version: string; + platform: string; + deviceFamily?: string; + modelIdentifier?: string; + mode: "webchat" | "cli" | "ui" | "backend" | "node" | "probe" | "test"; + instanceId?: string; + }; + caps?: string[]; + commands?: string[]; + permissions?: Record; + pathEnv?: string; + role?: string; // default "operator" + scopes?: string[]; + device?: { + id: string; + publicKey: string; + signature: string; + signedAt: number; + nonce?: string; + }; + auth?: { + token?: string; + password?: string; + }; + locale?: string; + userAgent?: string; +}; +``` + +Notes: +- `minProtocol`/`maxProtocol` must include `3` (the server's expected protocol). +- If you send `device.nonce`, it must match the `connect.challenge` nonce. + +```ts +type HelloOk = { + type: "hello-ok"; + protocol: number; + server: { + version: string; + commit?: string; + host?: string; + connId: string; + }; + features: { + methods: string[]; // advertised methods + events: string[]; // advertised events + }; + snapshot: Snapshot; + canvasHostUrl?: string; + auth?: { + deviceToken: string; + role: string; + scopes: string[]; + issuedAtMs?: number; + }; + policy: { + maxPayload: number; + maxBufferedBytes: number; + tickIntervalMs: number; + }; +}; +``` + +## Auth, Roles, and Scopes + +Gateway methods are authorized by role + scopes set during `connect`. + +Roles: +`operator` (default) and `node`. + +Scopes: +`operator.read`, `operator.write`, `operator.admin`, `operator.pairing`, `operator.approvals`. + +Notes: +- `node` role can only call `node.invoke.result`, `node.event`, `skills.bins`. +- `operator.admin` is required for config, wizard, update, and several maintenance methods. +- If a method is not explicitly read/write/pairing/approvals, it generally requires `operator.admin`. + +## Idempotency + +The following methods require `idempotencyKey` in params and dedupe repeated requests: +`send`, `poll`, `agent`, `chat.send`, `node.invoke`. + +For `send`, `poll`, `agent`, and `chat.send` the `idempotencyKey` is used as the `runId` in responses/events. + +## Common Types + +```ts +type Snapshot = { + presence: PresenceEntry[]; + health: HealthSummary; + stateVersion: { presence: number; health: number }; + uptimeMs: number; + configPath?: string; + stateDir?: string; + sessionDefaults?: { + defaultAgentId: string; + mainKey: string; + mainSessionKey: string; + scope?: string; + }; +}; + +type PresenceEntry = { + host?: string; + ip?: string; + version?: string; + platform?: string; + deviceFamily?: string; + modelIdentifier?: string; + mode?: string; + lastInputSeconds?: number; + reason?: string; + tags?: string[]; + text?: string; + ts: number; + deviceId?: string; + roles?: string[]; + scopes?: string[]; + instanceId?: string; +}; +``` + +Health summary (used by `health` method and `health` event): + +```ts +type HealthSummary = { + ok: true; + ts: number; + durationMs: number; + channels: Record; + channelOrder: string[]; + channelLabels: Record; + heartbeatSeconds: number; + defaultAgentId: string; + agents: AgentHealthSummary[]; + sessions: { + path: string; + count: number; + recent: Array<{ key: string; updatedAt: number | null; age: number | null }>; + }; +}; +``` + +Status summary (used by `status` method): + +```ts +type StatusSummary = { + linkChannel?: { id: string; label: string; linked: boolean; authAgeMs: number | null }; + heartbeat: { defaultAgentId: string; agents: HeartbeatStatus[] }; + channelSummary: string[]; + queuedSystemEvents: string[]; + sessions: { + paths: string[]; + count: number; + defaults: { model: string | null; contextTokens: number | null }; + recent: SessionStatus[]; + byAgent: Array<{ agentId: string; path: string; count: number; recent: SessionStatus[] }>; + }; +}; +``` + +Usage summaries: + +```ts +type UsageSummary = { + updatedAt: number; + providers: Array<{ + provider: string; + displayName: string; + windows: Array<{ label: string; usedPercent: number; resetAt?: number }>; + plan?: string; + error?: string; + }>; +}; + +type CostUsageSummary = { + updatedAt: number; + days: number; + daily: Array<{ date: string; input: number; output: number; cacheRead: number; cacheWrite: number; totalTokens: number; totalCost: number; missingCostEntries: number }>; + totals: { input: number; output: number; cacheRead: number; cacheWrite: number; totalTokens: number; totalCost: number; missingCostEntries: number }; +}; +``` + +Heartbeat event payload (used by `heartbeat` event and `last-heartbeat` method): + +```ts +type HeartbeatEventPayload = { + ts: number; + status: "sent" | "ok-empty" | "ok-token" | "skipped" | "failed"; + to?: string; + preview?: string; + durationMs?: number; + hasMedia?: boolean; + reason?: string; + channel?: string; + silent?: boolean; + indicatorType?: "ok" | "alert" | "error"; +}; +``` + +Chat and agent events: + +```ts +type AgentEvent = { + runId: string; + seq: number; + stream: string; + ts: number; + data: Record; +}; + +type ChatEvent = { + runId: string; + sessionKey: string; + seq: number; + state: "delta" | "final" | "aborted" | "error"; + message?: unknown; + errorMessage?: string; + usage?: unknown; + stopReason?: string; +}; +``` + +Cron types: + +```ts +type CronSchedule = + | { kind: "at"; at: string } + | { kind: "every"; everyMs: number; anchorMs?: number } + | { kind: "cron"; expr: string; tz?: string }; + +type CronPayload = + | { kind: "systemEvent"; text: string } + | { kind: "agentTurn"; message: string; model?: string; thinking?: string; timeoutSeconds?: number }; + +type CronDelivery = { mode: "none" | "announce"; channel?: "last" | string; to?: string; bestEffort?: boolean }; + +type CronJob = { + id: string; + agentId?: string; + name: string; + description?: string; + enabled: boolean; + deleteAfterRun?: boolean; + createdAtMs: number; + updatedAtMs: number; + schedule: CronSchedule; + sessionTarget: "main" | "isolated"; + wakeMode: "next-heartbeat" | "now"; + payload: CronPayload; + delivery?: CronDelivery; + state: { + nextRunAtMs?: number; + runningAtMs?: number; + lastRunAtMs?: number; + lastStatus?: "ok" | "error" | "skipped"; + lastError?: string; + lastDurationMs?: number; + }; +}; + +type CronEvent = { + jobId: string; + action: "added" | "updated" | "removed" | "started" | "finished"; + runAtMs?: number; + durationMs?: number; + status?: "ok" | "error" | "skipped"; + error?: string; + summary?: string; + nextRunAtMs?: number; +}; +``` + +Node and device pairing types: + +```ts +type NodePairingPendingRequest = { + requestId: string; + nodeId: string; + displayName?: string; + platform?: string; + version?: string; + coreVersion?: string; + uiVersion?: string; + deviceFamily?: string; + modelIdentifier?: string; + caps?: string[]; + commands?: string[]; + permissions?: Record; + remoteIp?: string; + silent?: boolean; + isRepair?: boolean; + ts: number; +}; + +type NodePairingPairedNode = { + nodeId: string; + token: string; + displayName?: string; + platform?: string; + version?: string; + coreVersion?: string; + uiVersion?: string; + deviceFamily?: string; + modelIdentifier?: string; + caps?: string[]; + commands?: string[]; + bins?: string[]; + permissions?: Record; + remoteIp?: string; + createdAtMs: number; + approvedAtMs: number; + lastConnectedAtMs?: number; +}; + +type DevicePairingPendingRequest = { + requestId: string; + deviceId: string; + publicKey: string; + displayName?: string; + platform?: string; + clientId?: string; + clientMode?: string; + role?: string; + roles?: string[]; + scopes?: string[]; + remoteIp?: string; + silent?: boolean; + isRepair?: boolean; + ts: number; +}; + +type PairedDevice = { + deviceId: string; + publicKey: string; + displayName?: string; + platform?: string; + clientId?: string; + clientMode?: string; + role?: string; + roles?: string[]; + scopes?: string[]; + remoteIp?: string; + tokens?: Record; + createdAtMs: number; + approvedAtMs: number; +}; +``` + +Sessions list/preview results: + +```ts +type SessionsListResult = { + ts: number; + path: string; + count: number; + defaults: { modelProvider: string | null; model: string | null; contextTokens: number | null }; + sessions: Array<{ + key: string; + kind: "direct" | "group" | "global" | "unknown"; + label?: string; + displayName?: string; + derivedTitle?: string; + lastMessagePreview?: string; + channel?: string; + subject?: string; + groupChannel?: string; + space?: string; + chatType?: string; + origin?: unknown; + updatedAt: number | null; + sessionId?: string; + systemSent?: boolean; + abortedLastRun?: boolean; + thinkingLevel?: string; + verboseLevel?: string; + reasoningLevel?: string; + elevatedLevel?: string; + sendPolicy?: "allow" | "deny"; + inputTokens?: number; + outputTokens?: number; + totalTokens?: number; + responseUsage?: "on" | "off" | "tokens" | "full"; + modelProvider?: string; + model?: string; + contextTokens?: number; + deliveryContext?: unknown; + lastChannel?: string; + lastTo?: string; + lastAccountId?: string; + }>; +}; + +type SessionsPreviewResult = { + ts: number; + previews: Array<{ + key: string; + status: "ok" | "empty" | "missing" | "error"; + items: Array<{ role: "user" | "assistant" | "tool" | "system" | "other"; text: string }>; + }>; +}; +``` + +## Events + +The gateway may emit these events. Payloads use the types above. + +- `connect.challenge`: `ConnectChallenge` +- `agent`: `AgentEvent` +- `chat`: `ChatEvent` +- `presence`: `{ presence: PresenceEntry[] }` +- `tick`: `{ ts: number }` +- `talk.mode`: `{ enabled: boolean; phase: string | null; ts: number }` +- `shutdown`: `{ reason: string; restartExpectedMs?: number }` +- `health`: `HealthSummary` +- `heartbeat`: `HeartbeatEventPayload` +- `cron`: `CronEvent` +- `node.pair.requested`: `NodePairingPendingRequest` +- `node.pair.resolved`: `{ requestId: string; nodeId: string; decision: "approved" | "rejected"; ts: number }` +- `node.invoke.request`: `{ id: string; nodeId: string; command: string; paramsJSON?: string; timeoutMs?: number; idempotencyKey?: string }` +- `device.pair.requested`: `DevicePairingPendingRequest` +- `device.pair.resolved`: `{ requestId: string; deviceId: string; decision: string; ts: number }` +- `voicewake.changed`: `{ triggers: string[] }` +- `exec.approval.requested`: `{ id: string; request: { command: string; cwd?: string | null; host?: string | null; security?: string | null; ask?: string | null; agentId?: string | null; resolvedPath?: string | null; sessionKey?: string | null }; createdAtMs: number; expiresAtMs: number }` +- `exec.approval.resolved`: `{ id: string; decision: "allow-once" | "allow-always" | "deny"; resolvedBy?: string; ts: number }` + +## Method Reference + +### Health and Status + +#### `health` +Params: +```ts +{ probe?: boolean } +``` +Response: +`HealthSummary` +Notes: Uses cached health unless `probe: true`. + +#### `status` +Params: +```ts +{} +``` +Response: +`StatusSummary` + +#### `usage.status` +Params: +```ts +{} +``` +Response: +`UsageSummary` + +#### `usage.cost` +Params: +```ts +{ days?: number } +``` +Response: +`CostUsageSummary` + +#### `last-heartbeat` +Params: +```ts +{} +``` +Response: +`HeartbeatEventPayload | null` + +#### `set-heartbeats` +Params: +```ts +{ enabled: boolean } +``` +Response: +```ts +{ ok: true; enabled: boolean } +``` +Scope: `operator.admin` + +#### `system-presence` +Params: +```ts +{} +``` +Response: +`PresenceEntry[]` + +#### `system-event` +Params: +```ts +{ + text: string; + deviceId?: string; + instanceId?: string; + host?: string; + ip?: string; + mode?: string; + version?: string; + platform?: string; + deviceFamily?: string; + modelIdentifier?: string; + lastInputSeconds?: number; + reason?: string; + roles?: string[]; + scopes?: string[]; + tags?: string[]; +} +``` +Response: +```ts +{ ok: true } +``` +Scope: `operator.admin` + +### Logs + +#### `logs.tail` +Params: +```ts +{ cursor?: number; limit?: number; maxBytes?: number } +``` +Response: +```ts +{ file: string; cursor: number; size: number; lines: string[]; truncated?: boolean; reset?: boolean } +``` + +### Channels + +#### `channels.status` +Params: +```ts +{ probe?: boolean; timeoutMs?: number } +``` +Response: +```ts +{ + ts: number; + channelOrder: string[]; + channelLabels: Record; + channelDetailLabels?: Record; + channelSystemImages?: Record; + channelMeta?: Array<{ id: string; label: string; detailLabel: string; systemImage?: string }>; + channels: Record; // plugin summaries + channelAccounts: Record>; + channelDefaultAccountId: Record; +} +``` + +#### `channels.logout` +Params: +```ts +{ channel: string; accountId?: string } +``` +Response: +```ts +{ channel: string; accountId: string; cleared: boolean; [key: string]: unknown } +``` +Scope: `operator.admin` + +#### `web.login.start` (plugin-provided) +Params: +```ts +{ force?: boolean; timeoutMs?: number; verbose?: boolean; accountId?: string } +``` +Response: provider-specific, typically includes QR or login URL. + +#### `web.login.wait` (plugin-provided) +Params: +```ts +{ timeoutMs?: number; accountId?: string } +``` +Response: provider-specific, typically `{ connected: boolean; ... }`. + +### TTS + +#### `tts.status` +Params: +```ts +{} +``` +Response: +```ts +{ + enabled: boolean; + auto: boolean | string; + provider: "openai" | "elevenlabs" | "edge"; + fallbackProvider: string | null; + fallbackProviders: string[]; + prefsPath: string; + hasOpenAIKey: boolean; + hasElevenLabsKey: boolean; + edgeEnabled: boolean; +} +``` + +#### `tts.providers` +Params: +```ts +{} +``` +Response: +```ts +{ providers: Array<{ id: string; name: string; configured: boolean; models: string[]; voices?: string[] }>; active: string } +``` + +#### `tts.enable` +Params: +```ts +{} +``` +Response: +```ts +{ enabled: true } +``` + +#### `tts.disable` +Params: +```ts +{} +``` +Response: +```ts +{ enabled: false } +``` + +#### `tts.convert` +Params: +```ts +{ text: string; channel?: string } +``` +Response: +```ts +{ audioPath: string; provider: string; outputFormat: string; voiceCompatible: boolean } +``` + +#### `tts.setProvider` +Params: +```ts +{ provider: "openai" | "elevenlabs" | "edge" } +``` +Response: +```ts +{ provider: string } +``` + +### Config and Update + +#### `config.get` +Params: +```ts +{} +``` +Response: +```ts +{ path: string; exists: boolean; raw: string | null; parsed: unknown; valid: boolean; config: unknown; hash?: string; issues: Array<{ path: string; message: string }>; warnings: Array<{ path: string; message: string }>; legacyIssues: Array<{ path: string; message: string }> } +``` + +#### `config.schema` +Params: +```ts +{} +``` +Response: +```ts +{ schema: unknown; uiHints: Record; version: string; generatedAt: string } +``` + +#### `config.set` +Params: +```ts +{ raw: string; baseHash?: string } +``` +Response: +```ts +{ ok: true; path: string; config: unknown } +``` +Notes: `baseHash` is required if a config already exists. + +#### `config.patch` +Params: +```ts +{ raw: string; baseHash?: string; sessionKey?: string; note?: string; restartDelayMs?: number } +``` +Response: +```ts +{ ok: true; path: string; config: unknown; restart: unknown; sentinel: { path: string | null; payload: unknown } } +``` +Notes: `raw` must be a JSON object for merge patch. Requires `baseHash` if config exists. + +#### `config.apply` +Params: +```ts +{ raw: string; baseHash?: string; sessionKey?: string; note?: string; restartDelayMs?: number } +``` +Response: +```ts +{ ok: true; path: string; config: unknown; restart: unknown; sentinel: { path: string | null; payload: unknown } } +``` +Notes: Requires `baseHash` if config exists. + +#### `update.run` +Params: +```ts +{ sessionKey?: string; note?: string; restartDelayMs?: number; timeoutMs?: number } +``` +Response: +```ts +{ ok: true; result: { status: "ok" | "error"; mode: string; reason?: string; root?: string; before?: string | null; after?: string | null; steps: Array<{ name: string; command: string; cwd: string; durationMs: number; stdoutTail?: string | null; stderrTail?: string | null; exitCode?: number | null }>; durationMs: number }; restart: unknown; sentinel: { path: string | null; payload: unknown } } +``` +Scope: `operator.admin` + +### Exec Approvals + +#### `exec.approvals.get` +Params: +```ts +{} +``` +Response: +```ts +{ path: string; exists: boolean; hash: string; file: { version: 1; socket?: { path?: string }; defaults?: { security?: string; ask?: string; askFallback?: string; autoAllowSkills?: boolean }; agents?: Record }> } } +``` + +#### `exec.approvals.set` +Params: +```ts +{ file: ExecApprovalsFile; baseHash?: string } +``` +Response: +Same shape as `exec.approvals.get`. +Notes: `baseHash` required if file exists. + +#### `exec.approvals.node.get` +Params: +```ts +{ nodeId: string } +``` +Response: +Node-provided exec approvals snapshot for that node. + +#### `exec.approvals.node.set` +Params: +```ts +{ nodeId: string; file: ExecApprovalsFile; baseHash?: string } +``` +Response: +Node-provided exec approvals snapshot after update. + +#### `exec.approval.request` +Params: +```ts +{ id?: string; command: string; cwd?: string | null; host?: string | null; security?: string | null; ask?: string | null; agentId?: string | null; resolvedPath?: string | null; sessionKey?: string | null; timeoutMs?: number } +``` +Response: +```ts +{ id: string; decision: "allow-once" | "allow-always" | "deny"; createdAtMs: number; expiresAtMs: number } +``` +Notes: This method blocks until a decision is made or timeout occurs. + +#### `exec.approval.resolve` +Params: +```ts +{ id: string; decision: "allow-once" | "allow-always" | "deny" } +``` +Response: +```ts +{ ok: true } +``` + +### Wizard + +#### `wizard.start` +Params: +```ts +{ mode?: "local" | "remote"; workspace?: string } +``` +Response: +```ts +{ sessionId: string; done: boolean; step?: WizardStep; status?: "running" | "done" | "cancelled" | "error"; error?: string } +``` + +#### `wizard.next` +Params: +```ts +{ sessionId: string; answer?: { stepId: string; value?: unknown } } +``` +Response: +```ts +{ done: boolean; step?: WizardStep; status?: "running" | "done" | "cancelled" | "error"; error?: string } +``` + +#### `wizard.cancel` +Params: +```ts +{ sessionId: string } +``` +Response: +```ts +{ status: "running" | "done" | "cancelled" | "error"; error?: string } +``` + +#### `wizard.status` +Params: +```ts +{ sessionId: string } +``` +Response: +```ts +{ status: "running" | "done" | "cancelled" | "error"; error?: string } +``` + +WizardStep: +```ts +{ id: string; type: "note" | "select" | "text" | "confirm" | "multiselect" | "progress" | "action"; title?: string; message?: string; options?: Array<{ value: unknown; label: string; hint?: string }>; initialValue?: unknown; placeholder?: string; sensitive?: boolean; executor?: "gateway" | "client" } +``` + +### Talk + +#### `talk.mode` +Params: +```ts +{ enabled: boolean; phase?: string } +``` +Response: +```ts +{ enabled: boolean; phase: string | null; ts: number } +``` +Notes: For webchat clients, requires a connected mobile node. + +### Models + +#### `models.list` +Params: +```ts +{} +``` +Response: +```ts +{ models: Array<{ id: string; name: string; provider: string; contextWindow?: number; reasoning?: boolean }> } +``` + +### Agents + +#### `agents.list` +Params: +```ts +{} +``` +Response: +```ts +{ defaultId: string; mainKey: string; scope: "per-sender" | "global"; agents: Array<{ id: string; name?: string; identity?: { name?: string; theme?: string; emoji?: string; avatar?: string; avatarUrl?: string } }> } +``` + +#### `agents.files.list` +Params: +```ts +{ agentId: string } +``` +Response: +```ts +{ agentId: string; workspace: string; files: Array<{ name: string; path: string; missing: boolean; size?: number; updatedAtMs?: number; content?: string }> } +``` + +#### `agents.files.get` +Params: +```ts +{ agentId: string; name: string } +``` +Response: +```ts +{ agentId: string; workspace: string; file: { name: string; path: string; missing: boolean; size?: number; updatedAtMs?: number; content?: string } } +``` + +#### `agents.files.set` +Params: +```ts +{ agentId: string; name: string; content: string } +``` +Response: +```ts +{ ok: true; agentId: string; workspace: string; file: { name: string; path: string; missing: boolean; size?: number; updatedAtMs?: number; content?: string } } +``` + +#### `agent` +Params: +```ts +{ + message: string; + agentId?: string; + to?: string; + replyTo?: string; + sessionId?: string; + sessionKey?: string; + thinking?: string; + deliver?: boolean; + attachments?: Array<{ type?: string; mimeType?: string; fileName?: string; content?: unknown }>; + channel?: string; + replyChannel?: string; + accountId?: string; + replyAccountId?: string; + threadId?: string; + groupId?: string; + groupChannel?: string; + groupSpace?: string; + timeout?: number; + lane?: string; + extraSystemPrompt?: string; + idempotencyKey: string; + label?: string; + spawnedBy?: string; +} +``` +Response: +```ts +{ runId: string; status: "accepted"; acceptedAt: number } +``` +Final response (same request id, later): +```ts +{ runId: string; status: "ok" | "error"; summary: string; result?: unknown } +``` +Notes: The gateway may send multiple `res` frames for the same `id`. + +#### `agent.identity.get` +Params: +```ts +{ agentId?: string; sessionKey?: string } +``` +Response: +```ts +{ agentId: string; name?: string; avatar?: string; emoji?: string } +``` + +#### `agent.wait` +Params: +```ts +{ runId: string; timeoutMs?: number } +``` +Response: +```ts +{ runId: string; status: "ok" | "error" | "timeout"; startedAt?: number; endedAt?: number; error?: string } +``` + +### Skills + +#### `skills.status` +Params: +```ts +{ agentId?: string } +``` +Response: +```ts +{ workspaceDir: string; managedSkillsDir: string; skills: Array<{ name: string; description?: string; source?: string; bundled?: boolean; filePath?: string; baseDir?: string; skillKey?: string; emoji?: string; homepage?: string; always?: boolean; disabled?: boolean; blockedByAllowlist?: boolean; eligible?: boolean; requirements?: { bins?: string[]; anyBins?: string[]; env?: string[]; config?: string[]; os?: string[] }; missing?: { bins?: string[]; anyBins?: string[]; env?: string[]; config?: string[]; os?: string[] }; configChecks?: unknown[]; install?: unknown[] }> } +``` + +#### `skills.bins` +Params: +```ts +{} +``` +Response: +```ts +{ bins: string[] } +``` + +#### `skills.install` +Params: +```ts +{ name: string; installId: string; timeoutMs?: number } +``` +Response: +```ts +{ ok: boolean; message?: string; [key: string]: unknown } +``` +Notes: Install details are returned from the skill installer. + +#### `skills.update` +Params: +```ts +{ skillKey: string; enabled?: boolean; apiKey?: string; env?: Record } +``` +Response: +```ts +{ ok: true; skillKey: string; config: { enabled?: boolean; apiKey?: string; env?: Record } } +``` + +### Voice Wake + +#### `voicewake.get` +Params: +```ts +{} +``` +Response: +```ts +{ triggers: string[] } +``` + +#### `voicewake.set` +Params: +```ts +{ triggers: string[] } +``` +Response: +```ts +{ triggers: string[] } +``` +Also emits `voicewake.changed` event. + +### Sessions + +#### `sessions.list` +Params: +```ts +{ limit?: number; activeMinutes?: number; includeGlobal?: boolean; includeUnknown?: boolean; includeDerivedTitles?: boolean; includeLastMessage?: boolean; label?: string; spawnedBy?: string; agentId?: string; search?: string } +``` +Response: +`SessionsListResult` + +#### `sessions.preview` +Params: +```ts +{ keys: string[]; limit?: number; maxChars?: number } +``` +Response: +`SessionsPreviewResult` + +#### `sessions.resolve` +Params: +```ts +{ key?: string; sessionId?: string; label?: string; agentId?: string; spawnedBy?: string; includeGlobal?: boolean; includeUnknown?: boolean } +``` +Response: +```ts +{ ok: true; key: string } +``` + +#### `sessions.patch` +Params: +```ts +{ key: string; label?: string | null; thinkingLevel?: string | null; verboseLevel?: string | null; reasoningLevel?: string | null; responseUsage?: "off" | "tokens" | "full" | "on" | null; elevatedLevel?: string | null; execHost?: string | null; execSecurity?: string | null; execAsk?: string | null; execNode?: string | null; model?: string | null; spawnedBy?: string | null; sendPolicy?: "allow" | "deny" | null; groupActivation?: "mention" | "always" | null } +``` +Response: +```ts +{ ok: true; path: string; key: string; entry: unknown } +``` +Scope: `operator.admin` + +#### `sessions.reset` +Params: +```ts +{ key: string } +``` +Response: +```ts +{ ok: true; key: string; entry: unknown } +``` +Scope: `operator.admin` + +#### `sessions.delete` +Params: +```ts +{ key: string; deleteTranscript?: boolean } +``` +Response: +```ts +{ ok: true; key: string; deleted: boolean; archived: string[] } +``` +Scope: `operator.admin` + +#### `sessions.compact` +Params: +```ts +{ key: string; maxLines?: number } +``` +Response: +```ts +{ ok: true; key: string; compacted: boolean; reason?: string; archived?: string; kept?: number } +``` +Scope: `operator.admin` + +### Nodes + +#### `node.pair.request` +Params: +```ts +{ nodeId: string; displayName?: string; platform?: string; version?: string; coreVersion?: string; uiVersion?: string; deviceFamily?: string; modelIdentifier?: string; caps?: string[]; commands?: string[]; remoteIp?: string; silent?: boolean } +``` +Response: +```ts +{ status: "pending"; request: NodePairingPendingRequest; created: boolean } +``` +Scope: `operator.pairing` + +#### `node.pair.list` +Params: +```ts +{} +``` +Response: +```ts +{ pending: NodePairingPendingRequest[]; paired: NodePairingPairedNode[] } +``` +Scope: `operator.pairing` + +#### `node.pair.approve` +Params: +```ts +{ requestId: string } +``` +Response: +```ts +{ requestId: string; node: NodePairingPairedNode } +``` +Scope: `operator.pairing` + +#### `node.pair.reject` +Params: +```ts +{ requestId: string } +``` +Response: +```ts +{ requestId: string; nodeId: string } +``` +Scope: `operator.pairing` + +#### `node.pair.verify` +Params: +```ts +{ nodeId: string; token: string } +``` +Response: +```ts +{ ok: boolean; node?: NodePairingPairedNode } +``` +Scope: `operator.pairing` + +#### `node.rename` +Params: +```ts +{ nodeId: string; displayName: string } +``` +Response: +```ts +{ nodeId: string; displayName: string } +``` +Scope: `operator.pairing` + +#### `node.list` +Params: +```ts +{} +``` +Response: +```ts +{ ts: number; nodes: Array<{ nodeId: string; displayName?: string; platform?: string; version?: string; coreVersion?: string; uiVersion?: string; deviceFamily?: string; modelIdentifier?: string; remoteIp?: string; caps: string[]; commands: string[]; pathEnv?: string; permissions?: Record; connectedAtMs?: number; paired: boolean; connected: boolean }> } +``` + +#### `node.describe` +Params: +```ts +{ nodeId: string } +``` +Response: +```ts +{ ts: number; nodeId: string; displayName?: string; platform?: string; version?: string; coreVersion?: string; uiVersion?: string; deviceFamily?: string; modelIdentifier?: string; remoteIp?: string; caps: string[]; commands: string[]; pathEnv?: string; permissions?: Record; connectedAtMs?: number; paired: boolean; connected: boolean } +``` + +#### `node.invoke` +Params: +```ts +{ nodeId: string; command: string; params?: unknown; timeoutMs?: number; idempotencyKey: string } +``` +Response: +```ts +{ ok: true; nodeId: string; command: string; payload: unknown; payloadJSON?: string | null } +``` +Notes: Requires the node to be connected and command allowed by policy/allowlist. + +#### `node.invoke.result` +Params: +```ts +{ id: string; nodeId: string; ok: boolean; payload?: unknown; payloadJSON?: string; error?: { code?: string; message?: string } } +``` +Response: +```ts +{ ok: true } | { ok: true; ignored: true } +``` +Scope: `node` role only. + +#### `node.event` +Params: +```ts +{ event: string; payload?: unknown; payloadJSON?: string } +``` +Response: +```ts +{ ok: true } +``` +Scope: `node` role only. + +### Devices + +#### `device.pair.list` +Params: +```ts +{} +``` +Response: +```ts +{ pending: DevicePairingPendingRequest[]; paired: Array }> } +``` +Scope: `operator.pairing` + +#### `device.pair.approve` +Params: +```ts +{ requestId: string } +``` +Response: +```ts +{ requestId: string; device: PairedDevice } +``` +Scope: `operator.pairing` + +#### `device.pair.reject` +Params: +```ts +{ requestId: string } +``` +Response: +```ts +{ requestId: string; deviceId: string } +``` +Scope: `operator.pairing` + +#### `device.token.rotate` +Params: +```ts +{ deviceId: string; role: string; scopes?: string[] } +``` +Response: +```ts +{ deviceId: string; role: string; token: string; scopes: string[]; rotatedAtMs: number } +``` +Scope: `operator.pairing` + +#### `device.token.revoke` +Params: +```ts +{ deviceId: string; role: string } +``` +Response: +```ts +{ deviceId: string; role: string; revokedAtMs: number } +``` +Scope: `operator.pairing` + +### Cron and Wake + +#### `wake` +Params: +```ts +{ mode: "now" | "next-heartbeat"; text: string } +``` +Response: +```ts +{ ok: boolean } +``` + +#### `cron.list` +Params: +```ts +{ includeDisabled?: boolean } +``` +Response: +```ts +{ jobs: CronJob[] } +``` + +#### `cron.status` +Params: +```ts +{} +``` +Response: +```ts +{ enabled: boolean; storePath: string; jobs: number; nextWakeAtMs: number | null } +``` + +#### `cron.add` +Params: +```ts +{ name: string; agentId?: string | null; description?: string; enabled?: boolean; deleteAfterRun?: boolean; schedule: CronSchedule; sessionTarget: "main" | "isolated"; wakeMode: "next-heartbeat" | "now"; payload: CronPayload; delivery?: CronDelivery } +``` +Response: +`CronJob` +Scope: `operator.admin` + +#### `cron.update` +Params: +```ts +{ id?: string; jobId?: string; patch: Partial } +``` +Response: +`CronJob` +Scope: `operator.admin` + +#### `cron.remove` +Params: +```ts +{ id?: string; jobId?: string } +``` +Response: +```ts +{ ok: true; removed: boolean } +``` +Scope: `operator.admin` + +#### `cron.run` +Params: +```ts +{ id?: string; jobId?: string; mode?: "due" | "force" } +``` +Response: +```ts +{ ok: true; ran: true } | { ok: true; ran: false; reason: "not-due" } +``` +Scope: `operator.admin` + +#### `cron.runs` +Params: +```ts +{ id?: string; jobId?: string; limit?: number } +``` +Response: +```ts +{ entries: Array<{ ts: number; jobId: string; action: "finished"; status?: "ok" | "error" | "skipped"; error?: string; summary?: string; runAtMs?: number; durationMs?: number; nextRunAtMs?: number }> } +``` + +### Messaging + +#### `send` +Params: +```ts +{ to: string; message: string; mediaUrl?: string; mediaUrls?: string[]; gifPlayback?: boolean; channel?: string; accountId?: string; sessionKey?: string; idempotencyKey: string } +``` +Response: +```ts +{ runId: string; messageId: string; channel: string; chatId?: string; channelId?: string; toJid?: string; conversationId?: string } +``` +Notes: `runId` is `idempotencyKey`. + +#### `poll` (undocumented but implemented) +Params: +```ts +{ to: string; question: string; options: string[]; maxSelections?: number; durationHours?: number; channel?: string; accountId?: string; idempotencyKey: string } +``` +Response: +```ts +{ runId: string; messageId: string; channel: string; toJid?: string; channelId?: string; conversationId?: string; pollId?: string } +``` +Notes: `poll` is not advertised in `features.methods` but is implemented. + +### Chat (WebSocket-native) + +#### `chat.history` +Params: +```ts +{ sessionKey: string; limit?: number } +``` +Response: +```ts +{ sessionKey: string; sessionId?: string; messages: unknown[]; thinkingLevel?: string } +``` + +#### `chat.send` +Params: +```ts +{ sessionKey: string; message: string; thinking?: string; deliver?: boolean; attachments?: unknown[]; timeoutMs?: number; idempotencyKey: string } +``` +Response (immediate ack): +```ts +{ runId: string; status: "started" } +``` +Possible cached response: +```ts +{ runId: string; status: "in_flight" } +``` +Final state is delivered via `chat` events. + +#### `chat.abort` +Params: +```ts +{ sessionKey: string; runId?: string } +``` +Response: +```ts +{ ok: true; aborted: boolean; runIds: string[] } +``` + +#### `chat.inject` (undocumented but implemented) +Params: +```ts +{ sessionKey: string; message: string; label?: string } +``` +Response: +```ts +{ ok: true; messageId: string } +``` +Notes: `chat.inject` is not advertised in `features.methods` but is implemented. + +### Browser + +#### `browser.request` +Params: +```ts +{ method: "GET" | "POST" | "DELETE"; path: string; query?: Record; body?: unknown; timeoutMs?: number } +``` +Response: +`unknown` (proxy result body) +Notes: If a connected browser-capable node is available, requests are proxied through it. Otherwise, the local browser control service is used if enabled. + +### Misc + +#### `system-presence`, `system-event`, `last-heartbeat`, `set-heartbeats` +See Health and Status section. + +## Hidden or Not Advertised in `features.methods` + +The following methods are implemented but not in the `features.methods` list returned by `HelloOk`: +- `poll` +- `chat.inject` + +Clients should primarily rely on `features.methods` to discover capabilities, but these methods exist in the current implementation. diff --git a/frontend/src/app/boards/[boardId]/page.tsx b/frontend/src/app/boards/[boardId]/page.tsx index 39da3954..5fc3f387 100644 --- a/frontend/src/app/boards/[boardId]/page.tsx +++ b/frontend/src/app/boards/[boardId]/page.tsx @@ -40,6 +40,21 @@ type Task = { status: string; priority: string; due_at?: string | null; + assigned_agent_id?: string | null; +}; + +type Agent = { + id: string; + name: string; + board_id?: string | null; +}; + +type TaskComment = { + id: string; + message?: string | null; + agent_id?: string | null; + task_id?: string | null; + created_at: string; }; const apiBase = @@ -61,8 +76,14 @@ export default function BoardDetailPage() { const [board, setBoard] = useState(null); const [tasks, setTasks] = useState([]); + const [agents, setAgents] = useState([]); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); + const [selectedTask, setSelectedTask] = useState(null); + const [comments, setComments] = useState([]); + const [isCommentsLoading, setIsCommentsLoading] = useState(false); + const [commentsError, setCommentsError] = useState(null); + const [isCommentsOpen, setIsCommentsOpen] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false); const [title, setTitle] = useState(""); @@ -82,7 +103,7 @@ export default function BoardDetailPage() { setError(null); try { const token = await getToken(); - const [boardResponse, tasksResponse] = await Promise.all([ + const [boardResponse, tasksResponse, agentsResponse] = await Promise.all([ fetch(`${apiBase}/api/v1/boards/${boardId}`, { headers: { Authorization: token ? `Bearer ${token}` : "", @@ -93,6 +114,11 @@ export default function BoardDetailPage() { Authorization: token ? `Bearer ${token}` : "", }, }), + fetch(`${apiBase}/api/v1/agents`, { + headers: { + Authorization: token ? `Bearer ${token}` : "", + }, + }), ]); if (!boardResponse.ok) { @@ -101,11 +127,16 @@ export default function BoardDetailPage() { if (!tasksResponse.ok) { throw new Error("Unable to load tasks."); } + if (!agentsResponse.ok) { + throw new Error("Unable to load agents."); + } const boardData = (await boardResponse.json()) as Board; const taskData = (await tasksResponse.json()) as Task[]; + const agentData = (await agentsResponse.json()) as Agent[]; setBoard(boardData); setTasks(taskData); + setAgents(agentData); } catch (err) { setError(err instanceof Error ? err.message : "Something went wrong."); } finally { @@ -165,6 +196,74 @@ export default function BoardDetailPage() { } }; + const assigneeById = useMemo(() => { + const map = new Map(); + agents + .filter((agent) => !boardId || agent.board_id === boardId) + .forEach((agent) => { + map.set(agent.id, agent.name); + }); + return map; + }, [agents, boardId]); + + const displayTasks = useMemo( + () => + tasks.map((task) => ({ + ...task, + assignee: task.assigned_agent_id + ? assigneeById.get(task.assigned_agent_id) + : undefined, + })), + [tasks, assigneeById], + ); + + const loadComments = async (taskId: string) => { + if (!isSignedIn || !boardId) return; + setIsCommentsLoading(true); + setCommentsError(null); + try { + const token = await getToken(); + const response = await fetch( + `${apiBase}/api/v1/boards/${boardId}/tasks/${taskId}/comments`, + { + headers: { Authorization: token ? `Bearer ${token}` : "" }, + }, + ); + if (!response.ok) { + throw new Error("Unable to load comments."); + } + const data = (await response.json()) as TaskComment[]; + setComments(data); + } catch (err) { + setCommentsError(err instanceof Error ? err.message : "Something went wrong."); + } finally { + setIsCommentsLoading(false); + } + }; + + const openComments = (task: Task) => { + setSelectedTask(task); + setIsCommentsOpen(true); + void loadComments(task.id); + }; + + const closeComments = () => { + setIsCommentsOpen(false); + setSelectedTask(null); + setComments([]); + setCommentsError(null); + }; + + const formatCommentTimestamp = (value: string) => { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "—"; + return date.toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + }; return ( @@ -215,14 +314,72 @@ export default function BoardDetailPage() { ) : ( setIsDialogOpen(true)} isCreateDisabled={isCreating} + onTaskSelect={openComments} /> )} + { + if (!open) { + closeComments(); + } + }}> + + + {selectedTask?.title ?? "Task"} + + {selectedTask?.description || "Task details and discussion."} + + +
+
+
+ Comments +
+ {isCommentsLoading ? ( +

Loading comments…

+ ) : commentsError ? ( +
+ {commentsError} +
+ ) : comments.length === 0 ? ( +

No comments yet.

+ ) : ( +
+ {comments.map((comment) => ( +
+
+ + {comment.agent_id + ? assigneeById.get(comment.agent_id) ?? "Agent" + : "Admin"} + + {formatCommentTimestamp(comment.created_at)} +
+

+ {comment.message || "—"} +

+
+ ))} +
+ )} +
+
+ + + +
+
+ { diff --git a/frontend/src/components/molecules/TaskCard.tsx b/frontend/src/components/molecules/TaskCard.tsx index b44c96e0..4ce193a5 100644 --- a/frontend/src/components/molecules/TaskCard.tsx +++ b/frontend/src/components/molecules/TaskCard.tsx @@ -8,11 +8,29 @@ interface TaskCardProps { status: string; assignee?: string; due?: string; + onClick?: () => void; } -export function TaskCard({ title, status, assignee, due }: TaskCardProps) { +export function TaskCard({ + title, + status, + assignee, + due, + onClick, +}: TaskCardProps) { return ( - + { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onClick?.(); + } + }} + >
diff --git a/frontend/src/components/organisms/TaskBoard.tsx b/frontend/src/components/organisms/TaskBoard.tsx index f93fb23c..b2f04ac5 100644 --- a/frontend/src/components/organisms/TaskBoard.tsx +++ b/frontend/src/components/organisms/TaskBoard.tsx @@ -10,12 +10,14 @@ type Task = { title: string; status: string; due_at?: string | null; + assignee?: string; }; type TaskBoardProps = { tasks: Task[]; onCreateTask: () => void; isCreateDisabled?: boolean; + onTaskSelect?: (task: Task) => void; }; const columns = [ @@ -41,6 +43,7 @@ export function TaskBoard({ tasks, onCreateTask, isCreateDisabled = false, + onTaskSelect, }: TaskBoardProps) { const grouped = useMemo(() => { const buckets: Record = {}; @@ -85,7 +88,9 @@ export function TaskBoard({ key={task.id} title={task.title} status={column.status} + assignee={task.assignee} due={formatDueDate(task.due_at)} + onClick={() => onTaskSelect?.(task)} /> ))}