feat(teams): blocker escalation, audit events, review workflow + docs

- Add comment_type column (note/blocker) with migration 000028
- Blocker comments auto-fail task, cancel member session via
  EventTeamTaskFailed broadcast, and escalate to leader via
  InboundMessage (system:escalation bypasses debounce)
- Enrich audit events: fix tenant scope bug (context.Background →
  WithTenantID), add 4 missing types (commented/progress/updated/stale),
  populate data field (reason, comment_text, progress_percent)
- Add blocker escalation config toggle in team settings UI
- Add audit logs modal with paginated team events (Clock icon)
- Update features modal: all features now available (no coming soon)
- Update code comments: reviewer role not yet active, all flows via leader
- Update docs: remove v1/v2 versioning, add blocker escalation and
  review workflow sections, document enriched audit events
This commit is contained in:
viettranx committed 2026-03-23 13:51:28 +07:00
1 parent 8d3eb5bb6f
commit ae6615d962
26 files changed
+678 -116

No files matched your search

+30 -1
View File
@@ -2,6 +2,7 @@ package cmd
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"os"
@@ -546,11 +547,31 @@ func runGateway() {
if err != nil {
return
}
if err := teamEventStore.RecordTaskEvent(context.Background(), &store.TeamTaskEventData{
// Propagate tenant from bus event to ensure correct tenant isolation.
auditCtx := store.WithTenantID(context.Background(), evt.TenantID)
// Populate data field with event-specific context for audit trail.
var data json.RawMessage
switch evt.Name {
case protocol.EventTeamTaskFailed, protocol.EventTeamTaskRejected, protocol.EventTeamTaskCancelled:
if payload.Reason != "" {
data, _ = json.Marshal(map[string]string{"reason": payload.Reason})
}
case protocol.EventTeamTaskCommented:
if payload.CommentText != "" {
data, _ = json.Marshal(map[string]string{"comment_text": payload.CommentText})
}
case protocol.EventTeamTaskProgress:
data, _ = json.Marshal(map[string]any{"progress_percent": payload.ProgressPercent, "progress_step": payload.ProgressStep})
}
if err := teamEventStore.RecordTaskEvent(auditCtx, &store.TeamTaskEventData{
TaskID: taskID,
EventType: eventType,
ActorType: payload.ActorType,
ActorID: payload.ActorID,
Data: data,
}); err != nil {
slog.Warn("team_task_audit.record_failed", "task_id", payload.TaskID, "event", eventType, "error", err)
}
@@ -1060,6 +1081,14 @@ func teamTaskEventType(eventName string) string {
return "approved"
case protocol.EventTeamTaskRejected:
return "rejected"
case protocol.EventTeamTaskCommented:
return "commented"
case protocol.EventTeamTaskProgress:
return "progress"
case protocol.EventTeamTaskUpdated:
return "updated"
case protocol.EventTeamTaskStale:
return "stale"
default:
return ""
}
+6
View File
@@ -108,6 +108,12 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents
continue
}
// Blocker escalation messages bypass debounce — deliver immediately to leader.
if msg.SenderID == "system:escalation" {
go processNormalMessage(ctx, msg, agents, cfg, sched, channelMgr, teamStore, quotaChecker, sessStore, agentStore, contactCollector, postTurn, msgBus)
continue
}
// --- Normal messages: route through debouncer ---
debouncer.Push(msg)
}
+2 -2
View File
@@ -118,8 +118,8 @@ Context keys ensure each tool call receives the correct per-call values without
| Tool | Description |
|------|-------------|
| `team_tasks` | Task board: list, create, claim, complete, search |
| `team_message` | Mailbox: send, broadcast, read unread messages |
| `team_tasks` | Task board: create, list, get, claim, complete, cancel, assign, review, approve, reject, comment (with type: note/blocker), progress, attach, ask_user, search, update |
| `team_message` | Mailbox: send direct message to teammate, broadcast to all, read unread messages |
### Media Generation
+57 -20
View File
@@ -161,30 +161,42 @@ flowchart TD
| `retry` | Re-dispatch stale or failed tasks back to pending | Admin |
| `update` | Update task metadata (priority, description, etc.) | Lead |
### Team Versioning
Many task actions require **team version >= 2**. Teams created with v1 only support basic actions.
**V2-Required Actions:**
- `approve` — Approve completed task
- `reject` — Reject and cancel task
- `review` — Submit for review
- `comment` — Add comments
- `progress` — Update progress
- `attach` — Attach files
- `update` — Update metadata
- `ask_user` — Set reminders
- `clear_ask_user` — Cancel reminders
- `retry` — Retry failed tasks
**V1 Teams** support only: `create`, `claim`, `complete`, `cancel`, `assign`, `list`, `get`, `search`
If a v1 team tries a v2 action, error: `"action 'X' requires team version 2 — upgrade in team settings"`
### Atomic Claiming
Two agents grabbing the same task is prevented at the database level. The claim operation uses a conditional update: `SET status = 'in_progress', owner = agent WHERE status = 'pending' AND owner IS NULL`. One row updated means claimed; zero rows means someone else got it first. No distributed mutex needed.
### Blocker Escalation
Members can flag themselves as blocked on a task by adding a blocker comment:
```
team_tasks(action="comment", task_id="...", text="Cannot find API documentation", type="blocker")
```
When a blocker comment is posted:
1. The comment is saved with `comment_type='blocker'`
2. The task is **auto-failed** (status: in_progress → failed)
3. An `EventTeamTaskFailed` is broadcast:
- Member's session is cancelled via scheduler
- Chat channel receives "❌ Task failed" notification
- Web UI dashboard updates in real-time
4. The **lead agent receives an escalation message** from `system:escalation` with:
- Blocked member name
- Task number and subject
- Blocker reason
- Instructions to retry with `team_tasks(action="retry", task_id="...")`
Blocker escalation is **enabled by default** but can be disabled per-team via settings:
```json
{
"blocker_escalation": {
"enabled": false
}
}
```
When disabled, blocker comments are saved but do not trigger auto-fail or escalation.
### Task Dependencies & Blocking
Tasks can declare `blocked_by` — a list of prerequisite task IDs. When a task has blocking dependencies:
@@ -289,6 +301,30 @@ Workspace files can be attached to tasks:
- Auto-links files created during task execution
- Metadata captures which agent/user attached the file
### Review Workflow
Tasks can require human approval before final completion. When creating a task, pass `require_approval: true`:
```
team_tasks(action="create", ..., require_approval=true)
```
**Flow:**
1. **Create with approval flag**: Task created with status `pending`, `require_approval` set
2. **Member submits for review**: When done, member calls `team_tasks(action="review", task_id="...")`
- Task transitions to `in_review` status
- Emits `EventTeamTaskReviewed` event
3. **Human approves**: Via dashboard, human clicks "Approve" → `teams.tasks.approve` RPC
- Task transitions to `completed`
- Emits `EventTeamTaskApproved` event
4. **Human rejects**: Via dashboard, human clicks "Reject" with reason → `teams.tasks.reject` RPC
- Task transitions to `cancelled` with reason
- Emits `EventTeamTaskRejected` event
- Lead receives notification to retry or investigate
Without `require_approval`, tasks move directly to `completed` after member calls `complete` (no in-review stage).
---
## 5. Team Mailbox
@@ -546,6 +582,7 @@ Teams support fine-grained access control through team settings.
| `followup_max_reminders` | Integer | Max ask_user reminders before escalation |
| `escalation_mode` | String | How to escalate stale tasks: "notify_lead", "fail_task" |
| `escalation_actions` | String list | Actions to take on escalation |
| `blocker_escalation` | Object | Blocker comment escalation settings: `{enabled: true}` (default enabled) |
System channels (`teammate`, `system`) always pass access checks. Empty settings mean open access.
+100 -12
View File
@@ -270,7 +270,7 @@ Emitted when a human rejects a task via the dashboard (status becomes cancelled
```
#### `team.task.commented`
Emitted when a human adds a comment to a task (no status change).
Emitted when a human or agent adds a comment to a task. For agent blocker comments, also triggers task failure and leader escalation (see below).
```json
{
@@ -279,10 +279,19 @@ Emitted when a human adds a comment to a task (no status change).
"user_id": "user123",
"channel": "dashboard",
"chat_id": "-100123456",
"timestamp": "2026-03-05T10:02:45Z"
"timestamp": "2026-03-05T10:02:45Z",
"actor_type": "human",
"actor_id": "user123"
}
```
**Agent blocker comments** (type='blocker') trigger special handling:
- Comment is saved with `comment_type='blocker'`
- Task is auto-failed (transitions to failed status)
- `EventTeamTaskFailed` is emitted (see below)
- Lead agent receives escalation message via `system:escalation`
- Member's session is cancelled
#### `team.task.deleted`
Emitted when a terminal-status task is hard-deleted via the dashboard.
@@ -301,19 +310,98 @@ Emitted when a terminal-status task is hard-deleted via the dashboard.
```
#### `team.task.failed`
Reserved for future use. Emitted when a task auto-execution fails (not currently triggered).
Emitted when a task is auto-failed due to blocker escalation or system errors.
```json
{
"team_id": "019c9503-...",
"task_id": "019ca84f-...",
"task_number": 5,
"subject": "Create Instagram image",
"status": "failed",
"reason": "Blocked: Cannot find API documentation",
"owner_agent_key": "tieu-la",
"owner_display_name": "Tieu La",
"user_id": "user123",
"channel": "telegram",
"chat_id": "-100123456",
"timestamp": "2026-03-05T10:02:15Z",
"actor_type": "system",
"actor_id": "blocker-escalation"
}
```
#### `team.task.reviewed`
Reserved for future use. Emitted when a task enters review stage (not currently triggered).
Emitted when a member submits a task for review via `team_tasks(action="review", task_id="...")`.
```json
{
"team_id": "019c9503-...",
"task_id": "019ca84f-...",
"status": "in_review",
"owner_agent_key": "tieu-la",
"owner_display_name": "Tieu La",
"user_id": "user123",
"channel": "dashboard",
"chat_id": "-100123456",
"timestamp": "2026-03-05T10:02:30Z",
"actor_type": "agent",
"actor_id": "tieu-la"
}
```
#### `team.task.progress`
Reserved for future use. Emitted periodically for long-running tasks (not currently triggered).
Emitted when a member updates task progress via `team_tasks(action="progress", task_id="...", progress_percent=..., progress_step="...")`.
```json
{
"team_id": "019c9503-...",
"task_id": "019ca84f-...",
"progress_percent": 50,
"progress_step": "Waiting for API response",
"user_id": "user123",
"channel": "system",
"chat_id": "-100123456",
"timestamp": "2026-03-05T10:02:00Z",
"actor_type": "agent",
"actor_id": "tieu-la"
}
```
#### `team.task.updated`
Reserved for future use. Emitted when task metadata is updated (not currently triggered).
Emitted when task metadata (priority, description, etc.) is updated via `team_tasks(action="update", task_id="...", ...)`.
```json
{
"team_id": "019c9503-...",
"task_id": "019ca84f-...",
"changes": ["priority", "description"],
"user_id": "user123",
"channel": "dashboard",
"chat_id": "-100123456",
"timestamp": "2026-03-05T10:01:30Z",
"actor_type": "human",
"actor_id": "user123"
}
```
#### `team.task.stale`
Reserved for future use. Emitted when a task hasn't been updated within a timeout threshold (not currently triggered).
Emitted when a task hasn't been updated within a timeout threshold and is marked as stale by the system.
```json
{
"team_id": "019c9503-...",
"task_id": "019ca84f-...",
"status": "stale",
"reason": "No activity for 7 days",
"user_id": "user123",
"channel": "system",
"chat_id": "-100123456",
"timestamp": "2026-03-05T10:00:00Z",
"actor_type": "system",
"actor_id": "stale-detector"
}
```
---
@@ -558,11 +646,11 @@ All event name constants are defined in `pkg/protocol/events.go`:
| `EventTeamTaskRejected` | `team.task.rejected` | Active |
| `EventTeamTaskCommented` | `team.task.commented` | Active |
| `EventTeamTaskDeleted` | `team.task.deleted` | Active |
| `EventTeamTaskFailed` | `team.task.failed` | Reserved (future) |
| `EventTeamTaskReviewed` | `team.task.reviewed` | Reserved (future) |
| `EventTeamTaskProgress` | `team.task.progress` | Reserved (future) |
| `EventTeamTaskUpdated` | `team.task.updated` | Reserved (future) |
| `EventTeamTaskStale` | `team.task.stale` | Reserved (future) |
| `EventTeamTaskFailed` | `team.task.failed` | Active |
| `EventTeamTaskReviewed` | `team.task.reviewed` | Active |
| `EventTeamTaskProgress` | `team.task.progress` | Active |
| `EventTeamTaskUpdated` | `team.task.updated` | Active |
| `EventTeamTaskStale` | `team.task.stale` | Active |
### Team CRUD Events
| Constant | Event Name |
+4
View File
@@ -130,6 +130,10 @@ func buildTeamMD(team *store.TeamData, members []store.TeamMemberData, selfID uu
sb.WriteString("\n## Communication\n\n")
sb.WriteString("Use `team_tasks(action=\"comment\")` to report issues or ask questions on your current task.\n")
}
sb.WriteString("\n## Blocker Escalation\n\n")
sb.WriteString("If blocked (missing info, unclear requirements, need credentials):\n")
sb.WriteString("```\nteam_tasks(action=\"comment\", type=\"blocker\", text=\"what you need\")\n```\n")
sb.WriteString("This auto-fails the task and notifies the leader, who can retry with updated instructions.\n")
}
return sb.String()
+11 -7
View File
@@ -20,12 +20,16 @@ func (s *PGTeamStore) AddTaskComment(ctx context.Context, comment *store.TeamTas
comment.ID = store.GenNewID()
}
comment.CreatedAt = time.Now()
commentType := comment.CommentType
if commentType == "" {
commentType = "note"
}
_, err := s.db.ExecContext(ctx,
`INSERT INTO team_task_comments (id, task_id, agent_id, user_id, content, created_at, tenant_id)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
`INSERT INTO team_task_comments (id, task_id, agent_id, user_id, content, comment_type, created_at, tenant_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
comment.ID, comment.TaskID, comment.AgentID,
sql.NullString{String: comment.UserID, Valid: comment.UserID != ""},
comment.Content, comment.CreatedAt, tenantIDForInsert(ctx),
comment.Content, commentType, comment.CreatedAt, tenantIDForInsert(ctx),
)
if err != nil {
return err
@@ -39,7 +43,7 @@ func (s *PGTeamStore) AddTaskComment(ctx context.Context, comment *store.TeamTas
func (s *PGTeamStore) ListTaskComments(ctx context.Context, taskID uuid.UUID) ([]store.TeamTaskCommentData, error) {
tid := tenantIDForInsert(ctx)
rows, err := s.db.QueryContext(ctx,
`SELECT c.id, c.task_id, c.agent_id, c.user_id, c.content, c.created_at,
`SELECT c.id, c.task_id, c.agent_id, c.user_id, c.content, c.comment_type, c.created_at,
COALESCE(a.agent_key, '') AS agent_key
FROM team_task_comments c
LEFT JOIN agents a ON a.id = c.agent_id
@@ -55,7 +59,7 @@ func (s *PGTeamStore) ListTaskComments(ctx context.Context, taskID uuid.UUID) ([
var c store.TeamTaskCommentData
var agentID *uuid.UUID
var userID sql.NullString
if err := rows.Scan(&c.ID, &c.TaskID, &agentID, &userID, &c.Content, &c.CreatedAt, &c.AgentKey); err != nil {
if err := rows.Scan(&c.ID, &c.TaskID, &agentID, &userID, &c.Content, &c.CommentType, &c.CreatedAt, &c.AgentKey); err != nil {
return nil, err
}
c.AgentID = agentID
@@ -72,7 +76,7 @@ func (s *PGTeamStore) ListTaskComments(ctx context.Context, taskID uuid.UUID) ([
func (s *PGTeamStore) ListRecentTaskComments(ctx context.Context, taskID uuid.UUID, limit int) ([]store.TeamTaskCommentData, error) {
tid := tenantIDForInsert(ctx)
rows, err := s.db.QueryContext(ctx,
`SELECT c.id, c.task_id, c.agent_id, c.user_id, c.content, c.created_at,
`SELECT c.id, c.task_id, c.agent_id, c.user_id, c.content, c.comment_type, c.created_at,
COALESCE(a.agent_key, '') AS agent_key
FROM team_task_comments c
LEFT JOIN agents a ON a.id = c.agent_id
@@ -89,7 +93,7 @@ func (s *PGTeamStore) ListRecentTaskComments(ctx context.Context, taskID uuid.UU
var c store.TeamTaskCommentData
var agentID *uuid.UUID
var userID sql.NullString
if err := rows.Scan(&c.ID, &c.TaskID, &agentID, &userID, &c.Content, &c.CreatedAt, &c.AgentKey); err != nil {
if err := rows.Scan(&c.ID, &c.TaskID, &agentID, &userID, &c.Content, &c.CommentType, &c.CreatedAt, &c.AgentKey); err != nil {
return nil, err
}
c.AgentID = agentID
+7 -6
View File
@@ -137,12 +137,13 @@ type TeamTaskData struct {
// TeamTaskCommentData represents a comment on a team task.
type TeamTaskCommentData struct {
ID uuid.UUID `json:"id"`
TaskID uuid.UUID `json:"task_id"`
AgentID *uuid.UUID `json:"agent_id,omitempty"`
UserID string `json:"user_id,omitempty"`
Content string `json:"content"`
CreatedAt time.Time `json:"created_at"`
ID uuid.UUID `json:"id"`
TaskID uuid.UUID `json:"task_id"`
AgentID *uuid.UUID `json:"agent_id,omitempty"`
UserID string `json:"user_id,omitempty"`
Content string `json:"content"`
CommentType string `json:"comment_type,omitempty"` // "note" (default) or "blocker"
CreatedAt time.Time `json:"created_at"`
// Joined
AgentKey string `json:"agent_key,omitempty"`
@@ -0,0 +1,34 @@
package tools
import "encoding/json"
// BlockerEscalationConfig controls whether blocker comments trigger
// auto-fail + leader escalation.
type BlockerEscalationConfig struct {
Enabled bool `json:"enabled"` // default: true
}
// DefaultBlockerEscalationConfig returns the default (enabled) config.
func DefaultBlockerEscalationConfig() BlockerEscalationConfig {
return BlockerEscalationConfig{Enabled: true}
}
// ParseBlockerEscalationConfig extracts blocker escalation config from team settings.
func ParseBlockerEscalationConfig(settings json.RawMessage) BlockerEscalationConfig {
cfg := DefaultBlockerEscalationConfig()
if len(settings) == 0 {
return cfg
}
var s struct {
BlockerEscalation *struct {
Enabled *bool `json:"enabled"`
} `json:"blocker_escalation"`
}
if json.Unmarshal(settings, &s) != nil || s.BlockerEscalation == nil {
return cfg
}
if s.BlockerEscalation.Enabled != nil {
cfg.Enabled = *s.BlockerEscalation.Enabled
}
return cfg
}
+94
View File
@@ -0,0 +1,94 @@
package tools
import (
"context"
"fmt"
"log/slog"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
// handleBlockerComment auto-fails the task, cancels the member session via
// EventTeamTaskFailed broadcast, and escalates to the leader agent.
func (t *TeamTasksTool) handleBlockerComment(
ctx context.Context,
team *store.TeamData,
task *store.TeamTaskData,
taskID, agentID uuid.UUID,
text string,
) *Result {
// Only escalate if task is still in progress.
if task.Status != store.TeamTaskStatusInProgress {
return NewResult(fmt.Sprintf(
"Blocker comment saved on task #%d \"%s\", but task is already %s — no escalation needed.",
task.TaskNumber, task.Subject, task.Status))
}
// Auto-fail the task. FailTask guards with AND status='in_progress' so
// concurrent calls are safe — only one succeeds.
reason := "Blocked: " + text
if len([]rune(reason)) > 500 {
reason = string([]rune(reason)[:500])
}
if err := t.manager.teamStore.FailTask(ctx, taskID, team.ID, reason); err != nil {
slog.Warn("blocker: FailTask error", "task_id", taskID, "error", err)
// Task may have completed concurrently — not a hard error.
return NewResult("Blocker comment saved. Task may have already completed — check task status.")
}
// Broadcast EventTeamTaskFailed — triggers:
// 1. Cancel subscriber → sched.CancelSession() → member stops
// 2. Notify subscriber → "❌ Task failed" → chat channel (direct outbound)
// 3. WS broadcast → web UI dashboard real-time update
memberKey := t.manager.agentKeyFromID(ctx, agentID)
t.manager.broadcastTeamEvent(ctx, protocol.EventTeamTaskFailed, protocol.TeamTaskEventPayload{
TeamID: team.ID.String(),
TaskID: taskID.String(),
TaskNumber: task.TaskNumber,
Subject: task.Subject,
Status: store.TeamTaskStatusFailed,
Reason: reason,
OwnerAgentKey: memberKey,
UserID: store.UserIDFromContext(ctx),
Channel: task.Channel,
ChatID: task.ChatID,
Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
ActorType: "agent",
ActorID: memberKey,
})
// Escalate to leader if enabled in team settings.
escalationCfg := ParseBlockerEscalationConfig(team.Settings)
if escalationCfg.Enabled && t.manager.msgBus != nil {
leadAg, err := t.manager.cachedGetAgentByID(ctx, team.LeadAgentID)
if err == nil {
escalationMsg := fmt.Sprintf(
"[Escalation] Member %q is blocked on task #%d \"%s\"\n\n"+
"Blocker: %s\n\n"+
"Use team_tasks(action=\"retry\", task_id=\"%s\") to reopen with updated instructions.",
memberKey, task.TaskNumber, task.Subject, text, taskID)
if !t.manager.msgBus.TryPublishInbound(bus.InboundMessage{
Channel: task.Channel,
SenderID: "system:escalation",
ChatID: task.ChatID,
Content: escalationMsg,
UserID: store.UserIDFromContext(ctx),
TenantID: store.TenantIDFromContext(ctx),
AgentID: leadAg.AgentKey,
}) {
slog.Warn("blocker: inbound buffer full, escalation dropped",
"task_id", taskID, "leader", leadAg.AgentKey)
}
}
}
return NewResult(fmt.Sprintf(
"Task #%d \"%s\" auto-failed due to blocker. Leader has been notified and can retry with updated instructions.",
task.TaskNumber, task.Subject))
}
+6 -20
View File
@@ -43,11 +43,9 @@ func (t *TeamTasksTool) executeClaim(ctx context.Context, args map[string]any) *
}
func (t *TeamTasksTool) executeComplete(ctx context.Context, args map[string]any) *Result {
// TODO: Enable when reviewer workflow is implemented — teammate agents should
// not complete tasks directly when a reviewer is required.
// if ToolChannelFromCtx(ctx) == ChannelTeammate {
// return ErrorResult("teammate agents cannot complete team tasks directly — results are auto-completed when delegation finishes")
// }
// Note: reviewer role exists but is not yet active in UI.
// All approval flows through leader. When reviewer role is enabled,
// restrict teammate agents from completing tasks directly.
team, agentID, err := t.manager.resolveTeam(ctx)
if err != nil {
@@ -106,11 +104,7 @@ func (t *TeamTasksTool) executeComplete(ctx context.Context, args map[string]any
}
func (t *TeamTasksTool) executeCancel(ctx context.Context, args map[string]any) *Result {
// TODO: Enable when reviewer workflow is implemented — teammate agents should
// not cancel tasks directly when a reviewer is required.
// if ToolChannelFromCtx(ctx) == ChannelTeammate {
// return ErrorResult("teammate agents cannot cancel team tasks directly")
// }
// Note: reviewer role not yet active. Cancellation goes through leader only.
team, agentID, err := t.manager.resolveTeam(ctx)
if err != nil {
@@ -199,11 +193,7 @@ func (t *TeamTasksTool) executeReview(ctx context.Context, args map[string]any)
}
func (t *TeamTasksTool) executeApprove(ctx context.Context, args map[string]any) *Result {
// TODO: Enable when reviewer workflow is implemented — teammate agents should
// not approve tasks directly when a reviewer is required.
// if ToolChannelFromCtx(ctx) == ChannelTeammate {
// return ErrorResult("teammate agents cannot approve team tasks")
// }
// Note: reviewer role not yet active. All approvals flow through leader or dashboard.
team, agentID, err := t.manager.resolveTeam(ctx)
if err != nil {
@@ -270,11 +260,7 @@ func (t *TeamTasksTool) executeApprove(ctx context.Context, args map[string]any)
}
func (t *TeamTasksTool) executeReject(ctx context.Context, args map[string]any) *Result {
// TODO: Enable when reviewer workflow is implemented — teammate agents should
// not reject tasks directly when a reviewer is required.
// if ToolChannelFromCtx(ctx) == ChannelTeammate {
// return ErrorResult("teammate agents cannot reject team tasks")
// }
// Note: reviewer role not yet active. Rejections flow through leader or dashboard.
team, agentID, err := t.manager.resolveTeam(ctx)
if err != nil {
+15 -3
View File
@@ -309,10 +309,16 @@ func (t *TeamTasksTool) executeComment(ctx context.Context, args map[string]any)
return ErrorResult("task does not belong to your team")
}
commentType, _ := args["type"].(string)
if commentType != "blocker" {
commentType = "note"
}
if err := t.manager.teamStore.AddTaskComment(ctx, &store.TeamTaskCommentData{
TaskID: taskID,
AgentID: &agentID,
Content: text,
TaskID: taskID,
AgentID: &agentID,
Content: text,
CommentType: commentType,
}); err != nil {
return ErrorResult("failed to add comment: " + err.Error())
}
@@ -331,6 +337,11 @@ func (t *TeamTasksTool) executeComment(ctx context.Context, args map[string]any)
ActorID: t.manager.agentKeyFromID(ctx, agentID),
})
// Blocker escalation: auto-fail task + notify leader.
if commentType == "blocker" {
return t.handleBlockerComment(ctx, team, task, taskID, agentID, text)
}
isLead := agentID == team.LeadAgentID
msg := fmt.Sprintf("Comment added to task #%d \"%s\" (id: %s).", task.TaskNumber, task.Subject, taskID)
switch {
@@ -346,6 +357,7 @@ func (t *TeamTasksTool) executeComment(ctx context.Context, args map[string]any)
return NewResult(msg)
}
func (t *TeamTasksTool) executeProgress(ctx context.Context, args map[string]any) *Result {
team, agentID, err := t.manager.resolveTeam(ctx)
if err != nil {
+4
View File
@@ -52,6 +52,10 @@ func (t *TeamTasksTool) Parameters() map[string]any {
"type": "string",
"description": "Text content: comment text, cancel/reject reason, progress update, or ask_user reminder question (must be a question asking the user for input/decision)",
},
"type": map[string]any{
"type": "string",
"description": "Comment type for action=comment: 'note' (default, share findings) or 'blocker' (you are BLOCKED and need leader input — auto-fails task and notifies leader)",
},
"status": map[string]any{
"type": "string",
"description": "Filter for list: '' (all, default), 'active', 'completed', 'in_review'",
+2 -1
View File
@@ -83,7 +83,8 @@ func (m *TeamToolManager) dispatchTaskToAgent(ctx context.Context, task *store.T
// Hint: guide member on available team_tasks actions.
content += "\n\n[Instructions]\n" +
"- Use team_tasks(action=\"progress\", percent=N, text=\"...\") to report progress\n" +
"- Use team_tasks(action=\"comment\", text=\"...\") to share findings or ask questions — comments are included in the task report sent to the leader\n" +
"- Use team_tasks(action=\"comment\", text=\"...\") to share findings\n" +
"- Use team_tasks(action=\"comment\", type=\"blocker\", text=\"...\") when BLOCKED and need leader input — auto-fails task and notifies leader\n" +
"- When done: team_tasks(action=\"complete\", result=\"summary of your work\")\n" +
"- Write output files to team workspace so lead can review"
+1 -1
View File
@@ -2,4 +2,4 @@ package upgrade
// RequiredSchemaVersion is the schema migration version this binary requires.
// Bump this whenever adding a new SQL migration file.
const RequiredSchemaVersion uint = 27
const RequiredSchemaVersion uint = 28
+1
View File
@@ -0,0 +1 @@
ALTER TABLE team_task_comments DROP COLUMN IF EXISTS comment_type;
+3
View File
@@ -0,0 +1,3 @@
-- Add comment_type to distinguish note vs blocker comments.
-- Blocker comments trigger task auto-fail + leader escalation.
ALTER TABLE team_task_comments ADD COLUMN IF NOT EXISTS comment_type VARCHAR(20) NOT NULL DEFAULT 'note';
+1
View File
@@ -151,6 +151,7 @@ export const Methods = {
TEAMS_WORKSPACE_LIST: "teams.workspace.list",
TEAMS_WORKSPACE_READ: "teams.workspace.read",
TEAMS_WORKSPACE_DELETE: "teams.workspace.delete",
TEAMS_EVENTS_LIST: "teams.events.list",
// Heartbeat
HEARTBEAT_GET: "heartbeat.get",
+26 -2
View File
@@ -160,6 +160,27 @@
"empty": "File is empty or binary"
}
},
"auditLogs": {
"title": "Audit Logs",
"empty": "No events yet",
"loadMore": "Load more",
"event": {
"created": "Created",
"claimed": "Claimed",
"assigned": "Assigned",
"dispatched": "Dispatched",
"completed": "Completed",
"failed": "Failed",
"cancelled": "Cancelled",
"reviewed": "Reviewed",
"approved": "Approved",
"rejected": "Rejected",
"commented": "Commented",
"progress": "Progress",
"updated": "Updated",
"stale": "Stale"
}
},
"settings": {
"title": "Team Settings",
"name": "Name",
@@ -212,6 +233,9 @@
"escalationPolicy": "Escalation Policy",
"escalationMode": "Escalation Mode",
"escalationModeHint": "Controls how privileged workspace actions (pin, tag, delete others' files, set_template) are handled when requested by agents.",
"blockerEscalation": "Blocker Escalation",
"blockerEscalationEnabled": "Enable blocker escalation",
"blockerEscalationHint": "When members flag a blocker, the task auto-fails and the leader is notified to retry with updated instructions.",
"escalationModeAuto": "Auto (default)",
"escalationModeAutoDesc": "LLM lead evaluates the request — either rejects or creates a review task. Never executes directly.",
"escalationModeReview": "Review",
@@ -259,8 +283,8 @@
"commentsAuditDesc": "Task comments and full event history",
"autoRecovery": "Auto-Recovery",
"autoRecoveryDesc": "Stuck tasks auto-reset after timeout",
"escalationPolicy": "Escalation Policy",
"escalationPolicyDesc": "Control what agents can do autonomously",
"escalationPolicy": "Blocker Escalation",
"escalationPolicyDesc": "Members can flag blockers — auto-fails task and notifies leader to retry",
"comingSoon": "Coming soon",
"downgradeNote": "Downgrade is safe — V2 data is preserved but hidden.",
"betaNote": "Some features are currently in Beta.",
+26 -2
View File
@@ -160,6 +160,27 @@
"empty": "File trống hoặc là file nhị phân"
}
},
"auditLogs": {
"title": "Nhật ký hoạt động",
"empty": "Chưa có sự kiện",
"loadMore": "Tải thêm",
"event": {
"created": "Tạo mới",
"claimed": "Nhận",
"assigned": "Phân công",
"dispatched": "Gửi đi",
"completed": "Hoàn thành",
"failed": "Thất bại",
"cancelled": "Hủy",
"reviewed": "Đánh giá",
"approved": "Duyệt",
"rejected": "Từ chối",
"commented": "Bình luận",
"progress": "Tiến độ",
"updated": "Cập nhật",
"stale": "Quá hạn"
}
},
"settings": {
"title": "Cài đặt nhóm",
"name": "Tên",
@@ -212,6 +233,9 @@
"escalationPolicy": "Chính sách Leo thang",
"escalationMode": "Chế độ Leo thang",
"escalationModeHint": "Kiểm soát cách xử lý action workspace nhạy cảm (pin, tag, xóa file người khác, set_template) khi agent yêu cầu.",
"blockerEscalation": "Escalation khi bị chặn",
"blockerEscalationEnabled": "Bật escalation khi bị chặn",
"blockerEscalationHint": "Khi member báo blocker, task tự fail và leader được thông báo để retry với hướng dẫn mới.",
"escalationModeAuto": "Tự động (mặc định)",
"escalationModeAutoDesc": "LLM lead đánh giá yêu cầu — từ chối hoặc tạo task review. Không bao giờ thực thi trực tiếp.",
"escalationModeReview": "Review",
@@ -259,8 +283,8 @@
"commentsAuditDesc": "Bình luận nhiệm vụ và lịch sử sự kiện đầy đủ",
"autoRecovery": "Tự phục hồi",
"autoRecoveryDesc": "Nhiệm vụ bị treo tự đặt lại sau thời gian chờ",
"escalationPolicy": "Chính sách leo thang",
"escalationPolicyDesc": "Kiểm soát những gì agent có thể tự thực hiện",
"escalationPolicy": "Escalation khi bị chặn",
"escalationPolicyDesc": "Member có thể báo blocker — tự fail task và thông báo leader để retry",
"comingSoon": "Sắp ra mắt",
"downgradeNote": "Hạ cấp an toàn — dữ liệu V2 được giữ lại nhưng ẩn đi.",
"betaNote": "Một số tính năng đang ở giai đoạn Beta.",
+26 -2
View File
@@ -160,6 +160,27 @@
"empty": "文件为空或为二进制文件"
}
},
"auditLogs": {
"title": "审计日志",
"empty": "暂无事件",
"loadMore": "加载更多",
"event": {
"created": "创建",
"claimed": "领取",
"assigned": "分配",
"dispatched": "派发",
"completed": "完成",
"failed": "失败",
"cancelled": "取消",
"reviewed": "审核",
"approved": "批准",
"rejected": "拒绝",
"commented": "评论",
"progress": "进度",
"updated": "更新",
"stale": "超时"
}
},
"settings": {
"title": "Team设置",
"name": "名称",
@@ -212,6 +233,9 @@
"escalationPolicy": "升级策略",
"escalationMode": "升级模式",
"escalationModeHint": "控制Agent请求执行敏感工作空间操作(置顶、标记、删除他人文件、设置模板)时的处理方式。",
"blockerEscalation": "阻塞升级",
"blockerEscalationEnabled": "启用阻塞升级",
"blockerEscalationHint": "当成员标记阻塞时,任务自动失败并通知领导重试。",
"escalationModeAuto": "自动(默认)",
"escalationModeAutoDesc": "LLM负责人评估请求——拒绝或创建审核任务。从不直接执行。",
"escalationModeReview": "审核",
@@ -259,8 +283,8 @@
"commentsAuditDesc": "任务评论和完整事件历史",
"autoRecovery": "自动恢复",
"autoRecoveryDesc": "卡住的任务在超时后自动重置",
"escalationPolicy": "升级策略",
"escalationPolicyDesc": "控制代理可以自主执行的操作",
"escalationPolicy": "阻塞升级",
"escalationPolicyDesc": "成员可以标记阻塞 — 自动失败任务并通知领导重试",
"comingSoon": "即将推出",
"downgradeNote": "降级是安全的 — V2数据会保留但隐藏。",
"betaNote": "部分功能目前处于Beta阶段。",
@@ -1,10 +1,11 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { ArrowLeft, Settings, Trash2, Users } from "lucide-react";
import { ArrowLeft, Clock, Settings, Trash2, Users } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { TeamData, TeamMemberData } from "@/types/team";
import { TeamFeaturesModal } from "../team-features-modal";
import { TeamAuditLogsModal } from "../team-audit-logs-modal";
interface BoardHeaderProps {
team: TeamData;
@@ -18,6 +19,7 @@ interface BoardHeaderProps {
export function BoardHeader({ team, members, onBack, onDelete, onSettings, onMembers }: BoardHeaderProps) {
const { t } = useTranslation("teams");
const [featuresOpen, setFeaturesOpen] = useState(false);
const [auditLogsOpen, setAuditLogsOpen] = useState(false);
const leadMember = members.find((m) => m.role === "lead");
const leadName = leadMember?.display_name || leadMember?.agent_key
|| team.lead_display_name || team.lead_agent_key;
@@ -59,6 +61,10 @@ export function BoardHeader({ team, members, onBack, onDelete, onSettings, onMem
<Users className="h-4 w-4" />
<span className="hidden sm:inline">{t("members.title")}</span>
</Button>
<Button variant="ghost" size="sm" onClick={() => setAuditLogsOpen(true)} className="shrink-0 gap-1.5">
<Clock className="h-4 w-4" />
<span className="hidden sm:inline">{t("auditLogs.title")}</span>
</Button>
<Button variant="ghost" size="sm" onClick={onSettings} className="shrink-0 gap-1.5">
<Settings className="h-4 w-4" />
<span className="hidden sm:inline">{t("detail.tabs.settings")}</span>
@@ -74,6 +80,7 @@ export function BoardHeader({ team, members, onBack, onDelete, onSettings, onMem
</Button>
<TeamFeaturesModal open={featuresOpen} onOpenChange={setFeaturesOpen} />
<TeamAuditLogsModal open={auditLogsOpen} onOpenChange={setAuditLogsOpen} teamId={team.id} />
</div>
);
}
@@ -0,0 +1,180 @@
import { useState, useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useWs } from "@/hooks/use-ws";
import { Methods } from "@/api/protocol";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { formatRelativeTime } from "@/lib/format";
import type { TeamTaskEvent } from "@/types/team";
const LIMIT = 50;
const EVENT_BADGE_CLASSES: Record<string, string> = {
created: "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300",
updated: "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300",
claimed: "bg-indigo-100 text-indigo-700 dark:bg-indigo-900 dark:text-indigo-300",
assigned: "bg-indigo-100 text-indigo-700 dark:bg-indigo-900 dark:text-indigo-300",
dispatched: "bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300",
completed: "bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300",
approved: "bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300",
failed: "bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300",
rejected: "bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300",
cancelled: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400",
commented: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300",
progress: "bg-cyan-100 text-cyan-700 dark:bg-cyan-900 dark:text-cyan-300",
reviewed: "bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300",
stale: "bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300",
};
interface EventDataDetails {
reason?: string;
comment_text?: string;
progress_percent?: number;
progress_step?: string;
[key: string]: unknown;
}
function EventDataRow({ data }: { data: Record<string, unknown> }) {
const [expanded, setExpanded] = useState(false);
const d = data as EventDataDetails;
const summary = d.reason || d.comment_text
|| (d.progress_percent != null ? `${d.progress_percent}%${d.progress_step ? ` — ${d.progress_step}` : ""}` : null);
if (!summary && Object.keys(data).length === 0) return null;
return (
<div className="mt-0.5">
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="text-[10px] text-muted-foreground underline-offset-2 hover:underline"
>
{expanded ? "hide" : "details"}
</button>
{expanded && (
<pre className="mt-1 max-h-20 overflow-auto rounded bg-muted px-2 py-1 text-[10px] text-muted-foreground">
{JSON.stringify(data, null, 2)}
</pre>
)}
{!expanded && summary && (
<span className="ml-1 text-[10px] text-muted-foreground">{summary}</span>
)}
</div>
);
}
interface TeamAuditLogsModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
teamId: string;
}
export function TeamAuditLogsModal({ open, onOpenChange, teamId }: TeamAuditLogsModalProps) {
const { t } = useTranslation("teams");
const ws = useWs();
const [events, setEvents] = useState<TeamTaskEvent[]>([]);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchEvents = useCallback(async (offset: number, append: boolean) => {
setLoading(true);
setError(null);
try {
const res = await ws.call<{ events: TeamTaskEvent[]; count: number }>(
Methods.TEAMS_EVENTS_LIST,
{ team_id: teamId, limit: LIMIT, offset },
);
const incoming = res.events ?? [];
setEvents((prev) => append ? [...prev, ...incoming] : incoming);
setHasMore(incoming.length === LIMIT);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load events");
} finally {
setLoading(false);
}
}, [ws, teamId]);
// Load on open
useEffect(() => {
if (open) {
setEvents([]);
fetchEvents(0, false);
}
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
const handleLoadMore = useCallback(() => {
fetchEvents(events.length, true);
}, [events.length, fetchEvents]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[95vw] sm:max-w-2xl max-h-[90dvh] flex flex-col p-0">
<DialogHeader className="px-4 pt-4 pb-2">
<DialogTitle>{t("auditLogs.title")}</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto overscroll-contain px-4 pb-4">
{error && (
<p className="py-8 text-center text-sm text-destructive">{error}</p>
)}
{events.length === 0 && !loading && !error && (
<p className="py-8 text-center text-sm text-muted-foreground">{t("auditLogs.empty")}</p>
)}
<div className="space-y-0">
{events.map((e, i) => (
<div key={e.id} className="relative flex gap-3 pb-3 last:pb-0">
{/* Dot + vertical line */}
<div className="flex flex-col items-center pt-1.5">
<div className="h-2 w-2 shrink-0 rounded-full bg-primary" />
{i < events.length - 1 && <div className="mt-0.5 flex-1 w-px bg-border" />}
</div>
{/* Content */}
<div className="flex-1 min-w-0 pb-1">
<div className="flex flex-wrap items-center gap-1.5">
<Badge
variant="outline"
className={`text-[10px] border-0 font-medium ${EVENT_BADGE_CLASSES[e.event_type] ?? "bg-muted text-muted-foreground"}`}
>
{t(`auditLogs.event.${e.event_type}`, { defaultValue: e.event_type })}
</Badge>
<span className="text-xs text-muted-foreground truncate max-w-[120px]">
{e.actor_type === "human" ? "Human" : (e.actor_id?.slice(0, 8) ?? "—")}
</span>
<span className="text-[11px] text-muted-foreground ml-auto shrink-0">
{formatRelativeTime(e.created_at)}
</span>
</div>
{e.task_id && (
<div className="text-[10px] text-muted-foreground mt-0.5">
task: {e.task_id.slice(0, 8)}
</div>
)}
{e.data && Object.keys(e.data).length > 0 && (
<EventDataRow data={e.data} />
)}
</div>
</div>
))}
</div>
{loading && (
<p className="py-4 text-center text-xs text-muted-foreground">Loading...</p>
)}
{hasMore && !loading && (
<div className="pt-2 text-center">
<Button variant="outline" size="sm" onClick={handleLoadMore}>
{t("auditLogs.loadMore")}
</Button>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
+9 -21
View File
@@ -8,7 +8,7 @@ import {
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Check, Minus,
Check,
ListTodo, FolderOpen, Lock, Bell,
ClipboardCheck, BarChart3, MessageSquare, RotateCcw, ShieldCheck,
} from "lucide-react";
@@ -26,9 +26,9 @@ const FEATURES = [
{ key: "followupReminders", icon: Bell },
{ key: "progressTracking", icon: BarChart3 },
{ key: "autoRecovery", icon: RotateCcw },
{ key: "commentsAudit", icon: MessageSquare, comingSoon: true },
{ key: "escalationPolicy", icon: ShieldCheck, comingSoon: true },
{ key: "reviewWorkflow", icon: ClipboardCheck, comingSoon: true },
{ key: "commentsAudit", icon: MessageSquare },
{ key: "escalationPolicy", icon: ShieldCheck },
{ key: "reviewWorkflow", icon: ClipboardCheck },
] as const;
export function TeamFeaturesModal({ open, onOpenChange }: TeamFeaturesModalProps) {
@@ -48,45 +48,33 @@ export function TeamFeaturesModal({ open, onOpenChange }: TeamFeaturesModalProps
</DialogHeader>
<div className="overflow-y-auto min-h-0 space-y-4 -mx-4 px-4 sm:-mx-6 sm:px-6">
{/* Feature list */}
<div className="space-y-1">
{FEATURES.map((f) => {
const Icon = f.icon;
const dimmed = "comingSoon" in f && f.comingSoon;
return (
<div
key={f.key}
className={`group grid grid-cols-[1fr_40px] items-center gap-2 rounded-lg px-3 py-2.5 hover:bg-muted/30 ${dimmed ? "opacity-40" : ""}`}
className="group grid grid-cols-[1fr_40px] items-center gap-2 rounded-lg px-3 py-2.5 hover:bg-muted/30"
>
<div className="flex items-start gap-3 min-w-0">
<Icon className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<div className="min-w-0">
<p className="text-sm font-medium flex items-center gap-2">
{t(`settings.versionModal.${f.key}`)}
{dimmed && (
<Badge variant="outline" className="text-[9px] px-1.5 py-0 font-normal">
{t("settings.versionModal.comingSoon")}
</Badge>
)}
<p className="text-sm font-medium">
{String(t(`settings.versionModal.${f.key}`))}
</p>
<p className="text-xs text-muted-foreground leading-relaxed">
{t(`settings.versionModal.${f.key}Desc`)}
{String(t(`settings.versionModal.${f.key}Desc`))}
</p>
</div>
</div>
<div className="flex justify-center">
{dimmed ? (
<Minus className="h-4 w-4 text-muted-foreground/30" />
) : (
<Check className="h-4 w-4 text-green-600 dark:text-green-400" />
)}
<Check className="h-4 w-4 text-green-600 dark:text-green-400" />
</div>
</div>
);
})}
</div>
{/* Notes */}
<div className="space-y-1 rounded-lg border bg-muted/20 px-4 py-3 text-xs text-muted-foreground">
<p>{t("settings.versionModal.betaNote")}</p>
</div>
+22 -15
View File
@@ -83,6 +83,8 @@ export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps)
const [memberRequestsAutoDispatch, setMemberRequestsAutoDispatch] = useState(initMemberRequests.auto_dispatch ?? false);
const [escalationMode, setEscalationMode] = useState<EscalationMode | "">(initial.escalation_mode ?? "");
const [escalationActions, setEscalationActions] = useState<EscalationAction[]>(initial.escalation_actions ?? []);
const initBlockerEscalation = initial.blocker_escalation ?? {};
const [blockerEscalationEnabled, setBlockerEscalationEnabled] = useState(initBlockerEscalation.enabled ?? true);
const [followupInterval, setFollowupInterval] = useState<number>(initial.followup_interval_minutes ?? 30);
const [followupMaxReminders, setFollowupMaxReminders] = useState<number>(initial.followup_max_reminders ?? 0);
const [workspaceScope, setWorkspaceScope] = useState<string>(initial.workspace_scope ?? "isolated");
@@ -105,9 +107,9 @@ export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps)
setNotifyDispatched(sn.dispatched ?? true);
setNotifyProgress(sn.progress ?? true);
setNotifyFailed(sn.failed ?? true);
setNotifyCompleted(sn.completed ?? false);
setNotifyCommented(sn.commented ?? false);
setNotifyNewTask(sn.new_task ?? false);
setNotifyCompleted(sn.completed ?? true);
setNotifyCommented(sn.commented ?? true);
setNotifyNewTask(sn.new_task ?? true);
setNotifySlowTool(sn.slow_tool ?? true);
setNotifyMode(sn.mode ?? "direct");
const smr = s.member_requests ?? {};
@@ -115,6 +117,8 @@ export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps)
setMemberRequestsAutoDispatch(smr.auto_dispatch ?? false);
setEscalationMode(s.escalation_mode ?? "");
setEscalationActions(s.escalation_actions ?? []);
const sbe = s.blocker_escalation ?? {};
setBlockerEscalationEnabled(sbe.enabled ?? true);
setFollowupInterval(s.followup_interval_minutes ?? 30);
setFollowupMaxReminders(s.followup_max_reminders ?? 0);
setWorkspaceScope(s.workspace_scope ?? "isolated");
@@ -149,6 +153,7 @@ export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps)
settings.escalation_mode = escalationMode;
if (escalationActions.length > 0) settings.escalation_actions = escalationActions;
}
settings.blocker_escalation = { enabled: blockerEscalationEnabled };
if (followupInterval !== 30) settings.followup_interval_minutes = followupInterval;
if (followupMaxReminders !== 0) settings.followup_max_reminders = followupMaxReminders;
settings.workspace_scope = workspaceScope || "isolated";
@@ -158,7 +163,7 @@ export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps)
} finally {
setSaving(false);
}
}, [teamId, allowUserIds, denyUserIds, allowChannels, denyChannels, notifyDispatched, notifyProgress, notifyFailed, notifyCompleted, notifyCommented, notifyNewTask, notifySlowTool, notifyMode, memberRequestsEnabled, memberRequestsAutoDispatch, escalationMode, escalationActions, followupInterval, followupMaxReminders, workspaceScope, updateTeamSettings, onSaved]);
}, [teamId, allowUserIds, denyUserIds, allowChannels, denyChannels, notifyDispatched, notifyProgress, notifyFailed, notifyCompleted, notifyCommented, notifyNewTask, notifySlowTool, notifyMode, memberRequestsEnabled, memberRequestsAutoDispatch, escalationMode, escalationActions, blockerEscalationEnabled, followupInterval, followupMaxReminders, workspaceScope, updateTeamSettings, onSaved]);
const userOptions = knownUsers.map((u) => ({ value: u, label: u }));
const channelOptions = CHANNEL_TYPES.map((c) => ({ value: c.value, label: c.label }));
@@ -328,22 +333,24 @@ export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps)
</div>
</div>
{/* Escalation Policy (coming soon) */}
<div className="space-y-4 opacity-40 pointer-events-none select-none">
<h3 className="text-sm font-medium flex items-center gap-2">
{t("settings.escalationPolicy")}
<span className="rounded border px-1.5 py-0.5 text-[9px] font-normal text-muted-foreground">{t("settings.versionModal.comingSoon")}</span>
</h3>
{/* Blocker Escalation */}
<div className="space-y-4">
<h3 className="text-sm font-medium">{t("settings.blockerEscalation")}</h3>
<div className="rounded-lg border bg-gradient-to-r from-orange-500/5 to-red-500/5 p-4">
<div className="flex items-start gap-4">
<div className="rounded-lg bg-orange-500/10 p-2.5 text-orange-600 dark:text-orange-400">
<ShieldAlert className="h-5 w-5" />
</div>
<div className="flex-1 space-y-1">
<span className="text-sm font-semibold">{t("settings.escalationMode")}</span>
<p className="text-xs text-muted-foreground leading-relaxed">
{t("settings.escalationModeHint")}
</p>
<div className="flex-1 space-y-3">
<div className="flex items-center justify-between">
<div className="space-y-1">
<span className="text-sm font-semibold">{t("settings.blockerEscalationEnabled")}</span>
<p className="text-xs text-muted-foreground leading-relaxed">
{t("settings.blockerEscalationHint")}
</p>
</div>
<Switch checked={blockerEscalationEnabled} onCheckedChange={setBlockerEscalationEnabled} />
</div>
</div>
</div>
</div>
+3
View File
@@ -33,6 +33,9 @@ export interface TeamAccessSettings {
enabled?: boolean;
auto_dispatch?: boolean;
};
blocker_escalation?: {
enabled?: boolean;
};
}
export interface TeamData {