diff --git a/CHANGELOG.md b/CHANGELOG.md index 542fa4b1..c9f992f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,15 @@ All notable changes to GoClaw are documented here. For full documentation, see [ the UUID, while the agent-facing `team_tasks` tool and RPCs take the full UUID, so a human had no way to name a task to the lead from the dashboard. +- **Dashboard can cancel and retry stuck team tasks** — new WS RPCs + `teams.tasks.cancel` (any task not yet completed/cancelled; optional reason is + posted as a comment) and `teams.tasks.retry` (stale / failed / cancelled / + in_review, and blocked tasks that nothing blocks any more; a human comment is + required and is both posted on the task and appended to the assignment prompt). + Task detail dialog gets Retry and Cancel buttons. Previously these transitions + were reachable only through the lead agent's `team_tasks` tool, so a task that + ended up blocked or stale could not be recovered from the UI (#506). + - **Behavior UX sidecar delivery overrides** — Adds sidecar-generated Quick Acknowledgement and Intermediate Replies with provider/model, timeout, token, and char caps. Effective config resolves Channel > Agent > Workspace, with diff --git a/docs/04-gateway-protocol.md b/docs/04-gateway-protocol.md index 91af3125..7a703a04 100644 --- a/docs/04-gateway-protocol.md +++ b/docs/04-gateway-protocol.md @@ -288,6 +288,8 @@ flowchart TD | `teams.tasks.delete` | Delete a task | | `teams.tasks.claim` | Claim a task (mark as in-progress) | | `teams.tasks.assign` | Assign task to member | +| `teams.tasks.cancel` | Cancel an unfinished task from the dashboard | +| `teams.tasks.retry` | Re-dispatch a stuck task to its assignee with a required human comment | | `teams.tasks.approve` | Approve completed task | | `teams.tasks.reject` | Reject task submission | | `teams.tasks.comment` | Add comment to task | diff --git a/docs/19-websocket-rpc.md b/docs/19-websocket-rpc.md index 91a52a7e..9b09783a 100644 --- a/docs/19-websocket-rpc.md +++ b/docs/19-websocket-rpc.md @@ -431,6 +431,8 @@ sequenceDiagram | `teams.tasks.comments` | List comments | | `teams.tasks.events` | List task events | | `teams.tasks.assign` | Assign to member | +| `teams.tasks.cancel` | Cancel an unfinished task (human); optional `reason` is posted as a comment | +| `teams.tasks.retry` | Send a stale/failed/cancelled/blocked task back to its assignee; `comment` required, optional `agentId` reassigns | | `teams.tasks.delete` | Delete task | | `teams.tasks.delete-bulk` | Bulk delete tasks | diff --git a/internal/gateway/methods/teams_tasks.go b/internal/gateway/methods/teams_tasks.go index 4048b685..e077a074 100644 --- a/internal/gateway/methods/teams_tasks.go +++ b/internal/gateway/methods/teams_tasks.go @@ -57,6 +57,8 @@ func (m *TeamsMethods) RegisterTasks(router *gateway.MethodRouter) { router.Register(protocol.MethodTeamsTaskDelete, m.handleTaskDelete) router.Register(protocol.MethodTeamsTaskDeleteBulk, m.handleTaskDeleteBulk) router.Register(protocol.MethodTeamsTaskAssign, m.handleTaskAssign) + router.Register(protocol.MethodTeamsTaskCancel, m.handleTaskCancel) + router.Register(protocol.MethodTeamsTaskRetry, m.handleTaskRetry) } // --- Task Get (with comments + events + attachments) --- diff --git a/internal/gateway/methods/teams_tasks_human.go b/internal/gateway/methods/teams_tasks_human.go new file mode 100644 index 00000000..fd60b634 --- /dev/null +++ b/internal/gateway/methods/teams_tasks_human.go @@ -0,0 +1,293 @@ +package methods + +import ( + "context" + "errors" + "log/slog" + "strings" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/gateway" + "github.com/nextlevelbuilder/goclaw/internal/i18n" + "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/pkg/protocol" +) + +// Human-in-the-loop task actions from the dashboard. +// +// Until now a human could only delete a task once it was terminal and +// approve/reject one sitting in in_review. A task that ended up blocked, +// stale, failed or cancelled could be neither cancelled nor restarted from the +// UI: CancelTask/ResetTaskStatus existed in the store but were reachable only +// through the lead agent's team_tasks tool, which requires the full task UUID +// the dashboard never showed. These two handlers expose the same transitions +// to the human directly. + +// --- Task Cancel --- + +type taskCancelParams struct { + TeamID string `json:"teamId"` + TaskID string `json:"taskId"` + Reason string `json:"reason"` // optional; stored as result and posted as a comment +} + +func (m *TeamsMethods) handleTaskCancel(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) { + var params taskCancelParams + locale, ok := m.parseTaskParams(ctx, client, req, ¶ms) + if !ok { + return + } + + teamID, err := uuid.Parse(params.TeamID) + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "teamId"))) + return + } + taskID, err := uuid.Parse(params.TaskID) + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "taskId"))) + return + } + + reason := strings.TrimSpace(params.Reason) + if len(reason) > maxCommentLength { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "reason too long")) + return + } + + task, ok := m.loadTeamTask(ctx, client, req, locale, "teams.tasks.cancel", taskID, teamID) + if !ok { + return + } + switch task.Status { + case store.TeamTaskStatusCompleted, store.TeamTaskStatusCancelled: + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "task is already "+task.Status)) + return + } + + storedReason := reason + if storedReason == "" { + storedReason = "Cancelled by human" + } + // CancelTask also unblocks dependents (blocked → pending). They are not + // dispatched here: the dashboard has no agent turn to hang a post-turn + // dispatch on, so the lead's next turn (or teams.tasks.retry) picks them up. + if err := m.teamStore.CancelTask(ctx, taskID, teamID, storedReason); err != nil { + slog.Warn("teams.tasks.cancel failed", "task_id", taskID, "error", err) + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, ""))) + return + } + + // A human-supplied reason is worth keeping in the thread, not only in the + // result column: the lead reads comments when it reviews the board. + if reason != "" { + m.addHumanComment(ctx, client, taskID, reason) + } + + client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{"ok": true, "status": store.TeamTaskStatusCancelled})) + + if m.msgBus != nil { + m.msgBus.Broadcast(taskBusEvent(protocol.EventTeamTaskCancelled, protocol.TeamTaskEventPayload{ + TeamID: teamID.String(), + TaskID: taskID.String(), + TaskNumber: task.TaskNumber, + Subject: task.Subject, + Status: store.TeamTaskStatusCancelled, + Reason: storedReason, + UserID: client.UserID(), + Channel: taskOriginChannel(task), + ChatID: task.ChatID, + Timestamp: taskNowUTC(), + ActorType: "human", + ActorID: client.UserID(), + })) + } +} + +// --- Task Retry --- + +type taskRetryParams struct { + TeamID string `json:"teamId"` + TaskID string `json:"taskId"` + Comment string `json:"comment"` // required: the human's answer or instructions for the assignee + AgentID string `json:"agentId"` // optional: reassign to another member (agent key or UUID) +} + +// retryableTaskStatuses mirrors the ResetTaskStatus predicate in the store. +// blocked is allowed only when nothing blocks the task any more — a task that +// still waits on other tasks must be unblocked by finishing or cancelling +// those, not by restarting it. +var retryableTaskStatuses = map[string]bool{ + store.TeamTaskStatusStale: true, + store.TeamTaskStatusFailed: true, + store.TeamTaskStatusCancelled: true, + store.TeamTaskStatusInReview: true, + store.TeamTaskStatusBlocked: true, +} + +func (m *TeamsMethods) handleTaskRetry(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) { + var params taskRetryParams + locale, ok := m.parseTaskParams(ctx, client, req, ¶ms) + if !ok { + return + } + + teamID, err := uuid.Parse(params.TeamID) + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "teamId"))) + return + } + taskID, err := uuid.Parse(params.TaskID) + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "taskId"))) + return + } + + // The comment is the whole point of a human retry: it is the answer the + // assignee was missing. Without it the agent would just hit the same wall. + comment := strings.TrimSpace(params.Comment) + if comment == "" { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "comment"))) + return + } + if len(comment) > maxCommentLength { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "comment too long")) + return + } + + task, ok := m.loadTeamTask(ctx, client, req, locale, "teams.tasks.retry", taskID, teamID) + if !ok { + return + } + if !retryableTaskStatuses[task.Status] { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, + "task cannot be retried from status "+task.Status+" (retry works on stale, failed, cancelled, in_review and unblocked blocked tasks)")) + return + } + if task.Status == store.TeamTaskStatusBlocked && len(task.BlockedBy) > 0 { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, + "task is still blocked by other tasks — complete or cancel them first")) + return + } + + // Assignee: explicit reassignment wins, otherwise keep the current owner. + var agentID uuid.UUID + if params.AgentID != "" { + agentID, err = resolveAgentUUIDCached(ctx, m.agentRouter, m.agentStore, params.AgentID) + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "agentId"))) + return + } + } else if task.OwnerAgentID != nil { + agentID = *task.OwnerAgentID + } else { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "task has no assignee — pass agentId")) + return + } + + // Same guard as teams.tasks.assign / dispatchTaskToAgent: a task owned by + // the lead would be dispatched into the lead's own session and loop. + team, err := m.teamStore.GetTeam(ctx, teamID) + if err != nil || team == nil { + slog.Warn("teams.tasks.retry get team failed", "team_id", teamID, "error", err) + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, ""))) + return + } + if agentID == team.LeadAgentID { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, + "cannot retry a task assigned to the team lead — pass a team member as agentId")) + return + } + + // Comment first, so it is already on the task when the assignee reads it. + if err := m.teamStore.AddTaskComment(ctx, &store.TeamTaskCommentData{ + TaskID: taskID, + UserID: client.UserID(), + Content: comment, + }); err != nil { + slog.Warn("teams.tasks.retry comment failed", "task_id", taskID, "error", err) + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, ""))) + return + } + + // pending first (AssignTask only transitions from pending), then assign. + if err := m.teamStore.ResetTaskStatus(ctx, taskID, teamID); err != nil { + slog.Warn("teams.tasks.retry reset failed", "task_id", taskID, "error", err) + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, ""))) + return + } + if err := m.teamStore.AssignTask(ctx, taskID, agentID, teamID); err != nil { + slog.Warn("teams.tasks.retry assign failed", "task_id", taskID, "agent_id", agentID, "error", err) + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, ""))) + return + } + + client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{"ok": true, "status": store.TeamTaskStatusInProgress})) + + if m.msgBus == nil { + return + } + m.msgBus.Broadcast(taskBusEvent(protocol.EventTeamTaskDispatched, protocol.TeamTaskEventPayload{ + TeamID: teamID.String(), + TaskID: taskID.String(), + TaskNumber: task.TaskNumber, + Subject: task.Subject, + Status: store.TeamTaskStatusInProgress, + Reason: comment, + UserID: client.UserID(), + Channel: taskOriginChannel(task), + ChatID: task.ChatID, + Timestamp: taskNowUTC(), + ActorType: "human", + ActorID: client.UserID(), + })) + + // The assignee gets the task prompt with the human's comment appended, so + // the answer arrives in the same message as the assignment instead of + // relying on the agent to go and read the comment thread. + dispatched := *task + dispatched.Description = strings.TrimSpace(task.Description + "\n\n[Retry requested by human]\n" + comment) + m.dispatchTaskToAgent(ctx, &dispatched, taskID, teamID, agentID, client.UserID()) +} + +// --- helpers --- + +// loadTeamTask fetches a task and checks it belongs to the team (IDOR guard). +// Sends the error response itself and returns ok=false when the caller must stop. +func (m *TeamsMethods) loadTeamTask(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame, locale, op string, taskID, teamID uuid.UUID) (*store.TeamTaskData, bool) { + task, err := m.teamStore.GetTask(ctx, taskID) + if err != nil { + if errors.Is(err, store.ErrTaskNotFound) { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "task", ""))) + } else { + slog.Warn(op+" get failed", "task_id", taskID, "error", err) + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, ""))) + } + return nil, false + } + if task.TeamID != teamID { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "task", ""))) + return nil, false + } + return task, true +} + +func (m *TeamsMethods) addHumanComment(ctx context.Context, client *gateway.Client, taskID uuid.UUID, content string) { + if err := m.teamStore.AddTaskComment(ctx, &store.TeamTaskCommentData{ + TaskID: taskID, + UserID: client.UserID(), + Content: content, + }); err != nil { + slog.Warn("audit.comment_failed", "task_id", taskID, "error", err) + } +} + +// taskOriginChannel is the channel the task was created from; "dashboard" for +// tasks that never had one, matching dispatchTaskToAgent. +func taskOriginChannel(task *store.TeamTaskData) string { + if task.Channel == "" { + return "dashboard" + } + return task.Channel +} diff --git a/internal/gateway/methods/teams_tasks_human_test.go b/internal/gateway/methods/teams_tasks_human_test.go new file mode 100644 index 00000000..91513dc0 --- /dev/null +++ b/internal/gateway/methods/teams_tasks_human_test.go @@ -0,0 +1,313 @@ +package methods + +import ( + "context" + "encoding/json" + "errors" + "strings" + "sync" + "testing" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/gateway" + "github.com/nextlevelbuilder/goclaw/internal/permissions" + "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/pkg/protocol" +) + +// Coverage for teams.tasks.cancel / teams.tasks.retry — the human-in-the-loop +// transitions the dashboard could not trigger before. The store is a stub that +// records calls; msgBus stays nil so no dispatch is attempted (the handlers +// guard on it, same as teams.tasks.assign). + +// ---- stub team store ---- + +type humanTaskStore struct { + store.TeamStore // nil: any method not overridden below panics loudly + mu sync.Mutex + task *store.TeamTaskData + team *store.TeamData + getErr error + + cancelled []string // reasons + comments []string + resets int + assigned []uuid.UUID +} + +func (s *humanTaskStore) GetTask(_ context.Context, id uuid.UUID) (*store.TeamTaskData, error) { + if s.getErr != nil { + return nil, s.getErr + } + if s.task == nil || s.task.ID != id { + return nil, store.ErrTaskNotFound + } + cp := *s.task + return &cp, nil +} + +func (s *humanTaskStore) GetTeam(_ context.Context, id uuid.UUID) (*store.TeamData, error) { + if s.team == nil || s.team.ID != id { + return nil, errors.New("team not found") + } + return s.team, nil +} + +func (s *humanTaskStore) CancelTask(_ context.Context, _, _ uuid.UUID, reason string) error { + s.mu.Lock() + defer s.mu.Unlock() + s.cancelled = append(s.cancelled, reason) + s.task.Status = store.TeamTaskStatusCancelled + return nil +} + +func (s *humanTaskStore) AddTaskComment(_ context.Context, c *store.TeamTaskCommentData) error { + s.mu.Lock() + defer s.mu.Unlock() + s.comments = append(s.comments, c.Content) + return nil +} + +func (s *humanTaskStore) ResetTaskStatus(_ context.Context, _, _ uuid.UUID) error { + s.mu.Lock() + defer s.mu.Unlock() + s.resets++ + s.task.Status = store.TeamTaskStatusPending + return nil +} + +func (s *humanTaskStore) AssignTask(_ context.Context, _, agentID, _ uuid.UUID) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.task.Status != store.TeamTaskStatusPending { + return errors.New("assign requires pending") + } + s.assigned = append(s.assigned, agentID) + s.task.Status = store.TeamTaskStatusInProgress + s.task.OwnerAgentID = &agentID + return nil +} + +// ---- harness ---- + +type humanTaskFixture struct { + m *TeamsMethods + st *humanTaskStore + teamID uuid.UUID + taskID uuid.UUID + leadID uuid.UUID + member uuid.UUID +} + +func newHumanTaskFixture(t *testing.T, status string) *humanTaskFixture { + t.Helper() + teamID, taskID := uuid.New(), uuid.New() + leadID, memberID := uuid.New(), uuid.New() + owner := memberID + st := &humanTaskStore{ + task: &store.TeamTaskData{ + BaseModel: store.BaseModel{ID: taskID}, + TeamID: teamID, TaskNumber: 7, Subject: "Ship it", + Status: status, OwnerAgentID: &owner, Channel: "telegram", ChatID: "42", + }, + team: &store.TeamData{BaseModel: store.BaseModel{ID: teamID}, LeadAgentID: leadID}, + } + return &humanTaskFixture{ + m: &TeamsMethods{teamStore: st}, + st: st, + teamID: teamID, taskID: taskID, leadID: leadID, member: memberID, + } +} + +// call runs a handler and decodes the single response frame it sends. +func (f *humanTaskFixture) call(t *testing.T, handler func(context.Context, *gateway.Client, *protocol.RequestFrame), params map[string]any) (ok bool, errCode string, errMsg string) { + t.Helper() + client, frames := gateway.NewCapturingTestClient(permissions.RoleAdmin, uuid.Nil, "human-1", 4) + raw, _ := json.Marshal(params) + handler(context.Background(), client, &protocol.RequestFrame{ID: "1", Params: raw}) + select { + case b := <-frames: + var resp struct { + OK bool `json:"ok"` + Error *struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal(b, &resp); err != nil { + t.Fatalf("bad response frame: %v: %s", err, b) + } + if resp.Error != nil { + return resp.OK, resp.Error.Code, resp.Error.Message + } + return resp.OK, "", "" + default: + t.Fatal("handler sent no response") + return false, "", "" + } +} + +// ---- cancel ---- + +func TestTaskCancel_BlockedTaskCancelledWithReasonComment(t *testing.T) { + f := newHumanTaskFixture(t, store.TeamTaskStatusBlocked) + ok, code, msg := f.call(t, f.m.handleTaskCancel, map[string]any{ + "teamId": f.teamID.String(), "taskId": f.taskID.String(), "reason": "wrong approach, dropping", + }) + if !ok { + t.Fatalf("expected ok, got %s: %s", code, msg) + } + if len(f.st.cancelled) != 1 || f.st.cancelled[0] != "wrong approach, dropping" { + t.Fatalf("CancelTask calls = %v", f.st.cancelled) + } + if len(f.st.comments) != 1 || f.st.comments[0] != "wrong approach, dropping" { + t.Fatalf("reason must be posted as a comment, got %v", f.st.comments) + } +} + +func TestTaskCancel_NoReasonUsesDefaultAndSkipsComment(t *testing.T) { + f := newHumanTaskFixture(t, store.TeamTaskStatusStale) + if ok, code, msg := f.call(t, f.m.handleTaskCancel, map[string]any{ + "teamId": f.teamID.String(), "taskId": f.taskID.String(), + }); !ok { + t.Fatalf("expected ok, got %s: %s", code, msg) + } + if got := f.st.cancelled; len(got) != 1 || got[0] != "Cancelled by human" { + t.Fatalf("default reason expected, got %v", got) + } + if len(f.st.comments) != 0 { + t.Fatalf("no comment expected without a reason, got %v", f.st.comments) + } +} + +func TestTaskCancel_TerminalStatusRejected(t *testing.T) { + for _, status := range []string{store.TeamTaskStatusCompleted, store.TeamTaskStatusCancelled} { + f := newHumanTaskFixture(t, status) + ok, code, _ := f.call(t, f.m.handleTaskCancel, map[string]any{ + "teamId": f.teamID.String(), "taskId": f.taskID.String(), + }) + if ok || code != protocol.ErrInvalidRequest { + t.Fatalf("%s: expected INVALID_REQUEST, got ok=%v code=%s", status, ok, code) + } + if len(f.st.cancelled) != 0 { + t.Fatalf("%s: store must not be touched", status) + } + } +} + +func TestTaskCancel_WrongTeamIsNotFound(t *testing.T) { + f := newHumanTaskFixture(t, store.TeamTaskStatusInProgress) + ok, code, _ := f.call(t, f.m.handleTaskCancel, map[string]any{ + "teamId": uuid.NewString(), "taskId": f.taskID.String(), + }) + if ok || code != protocol.ErrNotFound { + t.Fatalf("expected NOT_FOUND for foreign team, got ok=%v code=%s", ok, code) + } + if len(f.st.cancelled) != 0 { + t.Fatal("IDOR: task of another team was cancelled") + } +} + +// ---- retry ---- + +func TestTaskRetry_RequiresComment(t *testing.T) { + f := newHumanTaskFixture(t, store.TeamTaskStatusFailed) + for _, comment := range []string{"", " \n"} { + ok, code, _ := f.call(t, f.m.handleTaskRetry, map[string]any{ + "teamId": f.teamID.String(), "taskId": f.taskID.String(), "comment": comment, + }) + if ok || code != protocol.ErrInvalidRequest { + t.Fatalf("comment %q: expected INVALID_REQUEST, got ok=%v code=%s", comment, ok, code) + } + } + if f.st.resets != 0 || len(f.st.assigned) != 0 || len(f.st.comments) != 0 { + t.Fatal("store must not be touched when the comment is missing") + } +} + +func TestTaskRetry_FailedTaskGoesBackToOwnerWithComment(t *testing.T) { + f := newHumanTaskFixture(t, store.TeamTaskStatusFailed) + ok, code, msg := f.call(t, f.m.handleTaskRetry, map[string]any{ + "teamId": f.teamID.String(), "taskId": f.taskID.String(), "comment": "use the mirrored image, not docker hub", + }) + if !ok { + t.Fatalf("expected ok, got %s: %s", code, msg) + } + if len(f.st.comments) != 1 || !strings.Contains(f.st.comments[0], "mirrored image") { + t.Fatalf("comment not stored: %v", f.st.comments) + } + if f.st.resets != 1 { + t.Fatalf("ResetTaskStatus calls = %d", f.st.resets) + } + if len(f.st.assigned) != 1 || f.st.assigned[0] != f.member { + t.Fatalf("task must go back to its owner, assigned = %v", f.st.assigned) + } + if f.st.task.Status != store.TeamTaskStatusInProgress { + t.Fatalf("status = %s", f.st.task.Status) + } +} + +func TestTaskRetry_BlockedWithoutBlockersIsAllowed(t *testing.T) { + f := newHumanTaskFixture(t, store.TeamTaskStatusBlocked) + if ok, code, msg := f.call(t, f.m.handleTaskRetry, map[string]any{ + "teamId": f.teamID.String(), "taskId": f.taskID.String(), "comment": "answer: option B", + }); !ok { + t.Fatalf("expected ok, got %s: %s", code, msg) + } + if f.st.resets != 1 || len(f.st.assigned) != 1 { + t.Fatalf("resets=%d assigned=%v", f.st.resets, f.st.assigned) + } +} + +func TestTaskRetry_BlockedByOtherTasksIsRejected(t *testing.T) { + f := newHumanTaskFixture(t, store.TeamTaskStatusBlocked) + f.st.task.BlockedBy = []uuid.UUID{uuid.New()} + ok, code, msg := f.call(t, f.m.handleTaskRetry, map[string]any{ + "teamId": f.teamID.String(), "taskId": f.taskID.String(), "comment": "go", + }) + if ok || code != protocol.ErrInvalidRequest || !strings.Contains(msg, "blocked by other tasks") { + t.Fatalf("expected blocked-by rejection, got ok=%v code=%s msg=%s", ok, code, msg) + } + if f.st.resets != 0 || len(f.st.comments) != 0 { + t.Fatal("store must not be touched") + } +} + +func TestTaskRetry_NonRetryableStatusRejected(t *testing.T) { + for _, status := range []string{store.TeamTaskStatusInProgress, store.TeamTaskStatusCompleted, store.TeamTaskStatusPending} { + f := newHumanTaskFixture(t, status) + ok, code, _ := f.call(t, f.m.handleTaskRetry, map[string]any{ + "teamId": f.teamID.String(), "taskId": f.taskID.String(), "comment": "go", + }) + if ok || code != protocol.ErrInvalidRequest { + t.Fatalf("%s: expected INVALID_REQUEST, got ok=%v code=%s", status, ok, code) + } + } +} + +func TestTaskRetry_LeadOwnedTaskRejected(t *testing.T) { + f := newHumanTaskFixture(t, store.TeamTaskStatusFailed) + lead := f.leadID + f.st.task.OwnerAgentID = &lead + ok, code, msg := f.call(t, f.m.handleTaskRetry, map[string]any{ + "teamId": f.teamID.String(), "taskId": f.taskID.String(), "comment": "go", + }) + if ok || code != protocol.ErrInvalidRequest || !strings.Contains(msg, "team lead") { + t.Fatalf("expected lead guard, got ok=%v code=%s msg=%s", ok, code, msg) + } + if f.st.resets != 0 || len(f.st.comments) != 0 { + t.Fatal("store must not be touched") + } +} + +func TestTaskRetry_NoOwnerNeedsAgentID(t *testing.T) { + f := newHumanTaskFixture(t, store.TeamTaskStatusCancelled) + f.st.task.OwnerAgentID = nil + ok, code, msg := f.call(t, f.m.handleTaskRetry, map[string]any{ + "teamId": f.teamID.String(), "taskId": f.taskID.String(), "comment": "go", + }) + if ok || code != protocol.ErrInvalidRequest || !strings.Contains(msg, "agentId") { + t.Fatalf("expected agentId requirement, got ok=%v code=%s msg=%s", ok, code, msg) + } +} diff --git a/internal/permissions/policy.go b/internal/permissions/policy.go index fef208e2..b9f99a1a 100644 --- a/internal/permissions/policy.go +++ b/internal/permissions/policy.go @@ -351,6 +351,8 @@ func isWriteMethod(method string) bool { protocol.MethodTeamsTaskComment, protocol.MethodTeamsTaskCreate, protocol.MethodTeamsTaskAssign, + protocol.MethodTeamsTaskCancel, + protocol.MethodTeamsTaskRetry, protocol.MethodTeamsWorkspaceDelete, protocol.MethodHooksTest, protocol.MethodPairingRequest, diff --git a/internal/permissions/policy_test.go b/internal/permissions/policy_test.go index 48844d62..60193831 100644 --- a/internal/permissions/policy_test.go +++ b/internal/permissions/policy_test.go @@ -128,6 +128,8 @@ func TestCanAccess_WriteMethods(t *testing.T) { protocol.MethodSessionsDelete, protocol.MethodSessionsCompact, protocol.MethodCronCreate, + protocol.MethodTeamsTaskCancel, + protocol.MethodTeamsTaskRetry, } for _, method := range writeMethods { t.Run(method, func(t *testing.T) { diff --git a/internal/store/pg/teams_tasks_progress.go b/internal/store/pg/teams_tasks_progress.go index d6c2c9ef..d891c1f9 100644 --- a/internal/store/pg/teams_tasks_progress.go +++ b/internal/store/pg/teams_tasks_progress.go @@ -235,10 +235,10 @@ func (s *PGTeamStore) ResetTaskStatus(ctx context.Context, taskID, teamID uuid.U res, err := s.db.ExecContext(ctx, `UPDATE team_tasks SET status = $1, locked_at = NULL, lock_expires_at = NULL, result = NULL, progress_percent = NULL, progress_step = NULL, updated_at = $2 - WHERE id = $3 AND team_id = $4 AND status IN ($5, $6, $7, $8) AND tenant_id = $9`, + WHERE id = $3 AND team_id = $4 AND status IN ($5, $6, $7, $8, $9) AND tenant_id = $10`, store.TeamTaskStatusPending, now, taskID, teamID, store.TeamTaskStatusStale, store.TeamTaskStatusFailed, - store.TeamTaskStatusCancelled, store.TeamTaskStatusInReview, tid, + store.TeamTaskStatusCancelled, store.TeamTaskStatusInReview, store.TeamTaskStatusBlocked, tid, ) if err != nil { return err diff --git a/internal/store/sqlitestore/teams_tasks_activity.go b/internal/store/sqlitestore/teams_tasks_activity.go index 90ecf76e..102e9022 100644 --- a/internal/store/sqlitestore/teams_tasks_activity.go +++ b/internal/store/sqlitestore/teams_tasks_activity.go @@ -532,11 +532,11 @@ func (s *SQLiteTeamStore) ResetTaskStatus(ctx context.Context, taskID, teamID uu res, err := s.db.ExecContext(ctx, `UPDATE team_tasks SET status = ?, locked_at = NULL, lock_expires_at = NULL, result = NULL, progress_percent = NULL, progress_step = NULL, updated_at = ? - WHERE id = ? AND team_id = ? AND status IN (?, ?, ?, ?) AND tenant_id = ?`, + WHERE id = ? AND team_id = ? AND status IN (?, ?, ?, ?, ?) AND tenant_id = ?`, store.TeamTaskStatusPending, now, taskID, teamID, store.TeamTaskStatusStale, store.TeamTaskStatusFailed, - store.TeamTaskStatusCancelled, store.TeamTaskStatusInReview, tid, + store.TeamTaskStatusCancelled, store.TeamTaskStatusInReview, store.TeamTaskStatusBlocked, tid, ) if err != nil { return err diff --git a/pkg/protocol/methods.go b/pkg/protocol/methods.go index 81c05c80..4c2d52cf 100644 --- a/pkg/protocol/methods.go +++ b/pkg/protocol/methods.go @@ -145,6 +145,8 @@ const ( MethodTeamsTaskDelete = "teams.tasks.delete" MethodTeamsTaskDeleteBulk = "teams.tasks.delete-bulk" MethodTeamsTaskAssign = "teams.tasks.assign" + MethodTeamsTaskCancel = "teams.tasks.cancel" + MethodTeamsTaskRetry = "teams.tasks.retry" MethodTeamsTaskActiveBySession = "teams.tasks.active-by-session" MethodTeamsMembersAdd = "teams.members.add" MethodTeamsMembersRemove = "teams.members.remove" diff --git a/ui/web/src/api/protocol.ts b/ui/web/src/api/protocol.ts index fb32db45..7132e7c6 100644 --- a/ui/web/src/api/protocol.ts +++ b/ui/web/src/api/protocol.ts @@ -149,6 +149,8 @@ export const Methods = { TEAMS_TASK_DELETE: "teams.tasks.delete", TEAMS_TASK_DELETE_BULK: "teams.tasks.delete-bulk", TEAMS_TASK_ASSIGN: "teams.tasks.assign", + TEAMS_TASK_CANCEL: "teams.tasks.cancel", + TEAMS_TASK_RETRY: "teams.tasks.retry", TEAMS_TASK_ACTIVE_BY_SESSION: "teams.tasks.active-by-session", TEAMS_MEMBERS_ADD: "teams.members.add", TEAMS_MEMBERS_REMOVE: "teams.members.remove", diff --git a/ui/web/src/i18n/locales/en/teams.json b/ui/web/src/i18n/locales/en/teams.json index 74ef3a2d..f7bf9cdb 100644 --- a/ui/web/src/i18n/locales/en/teams.json +++ b/ui/web/src/i18n/locales/en/teams.json @@ -129,7 +129,17 @@ "deleteBulkConfirm": "This will permanently delete {{count}} task(s). Type \"delete\" to confirm.", "badges": { "awaitingReply": "awaiting reply" - } + }, + "cancel": "Cancel task", + "cancelTitle": "Cancel this task?", + "cancelDescription": "The task is marked cancelled and tasks it was blocking become pending. The reason, if given, is posted as a comment.", + "cancelReasonPlaceholder": "Reason (optional)", + "retry": "Retry", + "retryTitle": "Retry this task", + "retryDescription": "The task goes back to its assignee as a new run. Your comment is posted on the task and included in the assignment — this is where the answer or missing instruction goes.", + "retryCommentPlaceholder": "What should the assignee do differently, or what was the answer they were waiting for?", + "retryCommentRequired": "A comment is required to retry.", + "retryAssignee": "Assignee" }, "delegations": { "noTitle": "No delegations", @@ -352,7 +362,11 @@ "failedRejectTask": "Failed to reject task", "failedBulkDeleteTasks": "Failed to delete tasks", "workspaceFileDeleted": "File deleted", - "failedDeleteWorkspaceFile": "Failed to delete file" + "failedDeleteWorkspaceFile": "Failed to delete file", + "taskCancelled": "Task cancelled", + "failedCancelTask": "Failed to cancel task", + "taskRetried": "Task sent back to its assignee", + "failedRetryTask": "Failed to retry task" }, "board": { "kanban": "Board", diff --git a/ui/web/src/i18n/locales/ko/teams.json b/ui/web/src/i18n/locales/ko/teams.json index bc13f11f..cb749183 100644 --- a/ui/web/src/i18n/locales/ko/teams.json +++ b/ui/web/src/i18n/locales/ko/teams.json @@ -129,7 +129,17 @@ "deleteBulkConfirm": "{{count}}개 태스크가 영구적으로 삭제됩니다. 확인하려면 \"delete\"를 입력하세요.", "badges": { "awaitingReply": "응답 대기 중" - } + }, + "cancel": "작업 취소", + "cancelTitle": "이 작업을 취소할까요?", + "cancelDescription": "작업은 취소됨으로 표시되고, 이 작업이 차단하던 작업은 대기 상태가 됩니다. 사유를 입력하면 댓글로 기록됩니다.", + "cancelReasonPlaceholder": "사유(선택)", + "retry": "재시도", + "retryTitle": "작업 재시도", + "retryDescription": "작업이 담당자에게 새 실행으로 다시 전달됩니다. 댓글은 작업에 기록되고 배정 내용에 포함됩니다. 답변이나 누락된 지시를 여기에 적으세요.", + "retryCommentPlaceholder": "담당자가 무엇을 다르게 해야 하나요, 또는 어떤 답변을 기다리고 있었나요?", + "retryCommentRequired": "재시도하려면 댓글이 필요합니다.", + "retryAssignee": "담당자" }, "delegations": { "noTitle": "위임 없음", @@ -344,7 +354,11 @@ "failedRejectTask": "태스크 거부 실패", "failedBulkDeleteTasks": "태스크 삭제 실패", "workspaceFileDeleted": "파일 삭제됨", - "failedDeleteWorkspaceFile": "파일 삭제 실패" + "failedDeleteWorkspaceFile": "파일 삭제 실패", + "taskCancelled": "작업이 취소되었습니다", + "failedCancelTask": "작업 취소 실패", + "taskRetried": "작업이 담당자에게 다시 전달되었습니다", + "failedRetryTask": "작업 재시도 실패" }, "board": { "kanban": "보드", diff --git a/ui/web/src/i18n/locales/ru/teams.json b/ui/web/src/i18n/locales/ru/teams.json index 7e7f4af3..8c388de4 100644 --- a/ui/web/src/i18n/locales/ru/teams.json +++ b/ui/web/src/i18n/locales/ru/teams.json @@ -129,7 +129,17 @@ "deleteBulkConfirm": "Это безвозвратно удалит {{count}} задач(и). Введите «delete» для подтверждения.", "badges": { "awaitingReply": "ожидает ответа" - } + }, + "cancel": "Отменить задачу", + "cancelTitle": "Отменить эту задачу?", + "cancelDescription": "Задача получит статус «отменена», а задачи, которые она блокировала, станут ожидающими. Причина, если указана, будет добавлена комментарием.", + "cancelReasonPlaceholder": "Причина (необязательно)", + "retry": "Перезапустить", + "retryTitle": "Перезапустить задачу", + "retryDescription": "Задача вернётся исполнителю новым прогоном. Ваш комментарий будет добавлен к задаче и включён в постановку — сюда пишется ответ или недостающая инструкция.", + "retryCommentPlaceholder": "Что исполнителю сделать иначе, или какой ответ он ждал?", + "retryCommentRequired": "Для перезапуска нужен комментарий.", + "retryAssignee": "Исполнитель" }, "delegations": { "noTitle": "Нет делегирований", @@ -352,7 +362,11 @@ "failedRejectTask": "Не удалось отклонить задачу", "failedBulkDeleteTasks": "Не удалось удалить задачи", "workspaceFileDeleted": "Файл удалён", - "failedDeleteWorkspaceFile": "Не удалось удалить файл" + "failedDeleteWorkspaceFile": "Не удалось удалить файл", + "taskCancelled": "Задача отменена", + "failedCancelTask": "Не удалось отменить задачу", + "taskRetried": "Задача возвращена исполнителю", + "failedRetryTask": "Не удалось перезапустить задачу" }, "board": { "kanban": "Доска", diff --git a/ui/web/src/i18n/locales/vi/teams.json b/ui/web/src/i18n/locales/vi/teams.json index 30d0734d..7bbae409 100644 --- a/ui/web/src/i18n/locales/vi/teams.json +++ b/ui/web/src/i18n/locales/vi/teams.json @@ -129,7 +129,17 @@ "deleteBulkConfirm": "Thao tác này sẽ xoá vĩnh viễn {{count}} task. Nhập \"delete\" để xác nhận.", "badges": { "awaitingReply": "chờ trả lời" - } + }, + "cancel": "Hủy tác vụ", + "cancelTitle": "Hủy tác vụ này?", + "cancelDescription": "Tác vụ sẽ được đánh dấu đã hủy, các tác vụ bị nó chặn sẽ chuyển sang chờ xử lý. Lý do (nếu có) sẽ được đăng dưới dạng bình luận.", + "cancelReasonPlaceholder": "Lý do (không bắt buộc)", + "retry": "Thử lại", + "retryTitle": "Thử lại tác vụ này", + "retryDescription": "Tác vụ sẽ được giao lại cho người thực hiện trong một lượt chạy mới. Bình luận của bạn được đăng lên tác vụ và đưa vào nội dung giao việc — hãy ghi câu trả lời hoặc hướng dẫn còn thiếu tại đây.", + "retryCommentPlaceholder": "Người thực hiện cần làm gì khác đi, hoặc họ đang chờ câu trả lời nào?", + "retryCommentRequired": "Cần có bình luận để thử lại.", + "retryAssignee": "Người thực hiện" }, "delegations": { "noTitle": "Không có ủy quyền", @@ -352,7 +362,11 @@ "failedRejectTask": "Không thể từ chối task", "failedBulkDeleteTasks": "Không thể xóa các task", "workspaceFileDeleted": "Đã xóa file", - "failedDeleteWorkspaceFile": "Không thể xóa file" + "failedDeleteWorkspaceFile": "Không thể xóa file", + "taskCancelled": "Đã hủy tác vụ", + "failedCancelTask": "Không thể hủy tác vụ", + "taskRetried": "Đã giao lại tác vụ cho người thực hiện", + "failedRetryTask": "Không thể thử lại tác vụ" }, "board": { "kanban": "Bảng", diff --git a/ui/web/src/i18n/locales/zh/teams.json b/ui/web/src/i18n/locales/zh/teams.json index 4053e309..fd06b9b4 100644 --- a/ui/web/src/i18n/locales/zh/teams.json +++ b/ui/web/src/i18n/locales/zh/teams.json @@ -129,7 +129,17 @@ "deleteBulkConfirm": "此操作将永久删除 {{count}} 个任务。输入「delete」确认。", "badges": { "awaitingReply": "等待回复" - } + }, + "cancel": "取消任务", + "cancelTitle": "取消此任务?", + "cancelDescription": "任务将标记为已取消,被它阻塞的任务将变为待处理。如填写原因,将作为评论发布。", + "cancelReasonPlaceholder": "原因(可选)", + "retry": "重试", + "retryTitle": "重试此任务", + "retryDescription": "任务将作为新一轮运行返回给执行者。您的评论会发布到任务并包含在派发内容中——请在此写下答复或缺失的说明。", + "retryCommentPlaceholder": "执行者应当如何调整,或者他们在等待什么答复?", + "retryCommentRequired": "重试需要填写评论。", + "retryAssignee": "执行者" }, "delegations": { "noTitle": "暂无委托", @@ -352,7 +362,11 @@ "failedRejectTask": "拒绝任务失败", "failedBulkDeleteTasks": "批量删除任务失败", "workspaceFileDeleted": "文件已删除", - "failedDeleteWorkspaceFile": "删除文件失败" + "failedDeleteWorkspaceFile": "删除文件失败", + "taskCancelled": "任务已取消", + "failedCancelTask": "取消任务失败", + "taskRetried": "任务已返回给执行者", + "failedRetryTask": "重试任务失败" }, "board": { "kanban": "看板", diff --git a/ui/web/src/pages/teams/board/board-container.tsx b/ui/web/src/pages/teams/board/board-container.tsx index 3f56586a..84f4608b 100644 --- a/ui/web/src/pages/teams/board/board-container.tsx +++ b/ui/web/src/pages/teams/board/board-container.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, useMemo, useRef, memo, lazy, Suspense import { useTranslation } from "react-i18next"; import { useBoardStore } from "../stores/use-board-store"; import { toast } from "@/stores/use-toast-store"; -import { buildTaskLookup, buildMemberLookup, buildEmojiLookup } from "./board-utils"; +import { buildTaskLookup, buildMemberLookup, buildEmojiLookup, buildAssigneeOptions } from "./board-utils"; import { BoardToolbar } from "./board-toolbar"; import { KanbanBoard } from "./kanban-board"; import { TaskList } from "../task-sections"; @@ -30,12 +30,14 @@ interface BoardContainerProps { deleteTask?: (teamId: string, taskId: string) => Promise; deleteTasksBulk?: (teamId: string, taskIds: string[]) => Promise; addTaskComment?: (teamId: string, taskId: string, content: string) => Promise; + cancelTask?: (teamId: string, taskId: string, reason?: string) => Promise; + retryTask?: (teamId: string, taskId: string, comment: string, agentId?: string) => Promise; onWorkspace?: () => void; } export const BoardContainer = memo(function BoardContainer({ teamId, members, scopes, isTeamV2, - getTeamTasks, getTaskDetail, getTaskLight, deleteTask, deleteTasksBulk, addTaskComment, onWorkspace, + getTeamTasks, getTaskDetail, getTaskLight, deleteTask, deleteTasksBulk, addTaskComment, cancelTask, retryTask, onWorkspace, }: BoardContainerProps) { const { t } = useTranslation("teams"); const viewMode = useBoardStore((s) => s.viewMode); @@ -58,6 +60,7 @@ export const BoardContainer = memo(function BoardContainer({ // Lookups for name resolution const taskLookup = useMemo(() => buildTaskLookup(tasks), [tasks]); const memberLookup = useMemo(() => buildMemberLookup(members), [members]); + const assigneeOptions = useMemo(() => buildAssigneeOptions(members), [members]); const emojiLookup = useMemo(() => buildEmojiLookup(members), [members]); useEffect(() => { load(); }, [load]); @@ -138,6 +141,8 @@ export const BoardContainer = memo(function BoardContainer({ deleteTask={deleteTask} deleteTasksBulk={deleteTasksBulk} addTaskComment={addTaskComment} + cancelTask={cancelTask} + retryTask={retryTask} /> )} @@ -162,6 +167,9 @@ export const BoardContainer = memo(function BoardContainer({ onClose={handleCloseDetail} getTaskDetail={getTaskDetail} deleteTask={deleteTask} + cancelTask={cancelTask} + retryTask={retryTask} + assignees={assigneeOptions} taskLookup={taskLookup} memberLookup={memberLookup} emojiLookup={emojiLookup} diff --git a/ui/web/src/pages/teams/board/board-utils.ts b/ui/web/src/pages/teams/board/board-utils.ts index cdac4303..98281e57 100644 --- a/ui/web/src/pages/teams/board/board-utils.ts +++ b/ui/web/src/pages/teams/board/board-utils.ts @@ -65,6 +65,15 @@ export function buildMemberLookup( return map; } +/** Members a task can be handed to on retry: everyone but the lead */ +export function buildAssigneeOptions( + members: TeamMemberData[], +): { id: string; name: string }[] { + return members + .filter((m) => m.role !== "lead") + .map((m) => ({ id: m.agent_id, name: m.display_name || m.agent_key || m.agent_id.slice(0, 8) })); +} + /** Build agent_id -> emoji lookup from members */ export function buildEmojiLookup( members: TeamMemberData[], diff --git a/ui/web/src/pages/teams/hooks/use-teams.ts b/ui/web/src/pages/teams/hooks/use-teams.ts index 719c1e9f..c1110918 100644 --- a/ui/web/src/pages/teams/hooks/use-teams.ts +++ b/ui/web/src/pages/teams/hooks/use-teams.ts @@ -230,6 +230,34 @@ export function useTeams() { [ws], ); + const cancelTask = useCallback( + async (teamId: string, taskId: string, reason?: string) => { + try { + await ws.call(Methods.TEAMS_TASK_CANCEL, { teamId, taskId, reason }); + toast.success(i18next.t("teams:toast.taskCancelled")); + } catch (err) { + toast.error(i18next.t("teams:toast.failedCancelTask"), userFriendlyError(err)); + throw err; + } + }, + [ws], + ); + + // Retry always carries a human comment: it is the answer the assignee was + // missing, and the backend rejects an empty one. + const retryTask = useCallback( + async (teamId: string, taskId: string, comment: string, agentId?: string) => { + try { + await ws.call(Methods.TEAMS_TASK_RETRY, { teamId, taskId, comment, agentId }); + toast.success(i18next.t("teams:toast.taskRetried")); + } catch (err) { + toast.error(i18next.t("teams:toast.failedRetryTask"), userFriendlyError(err)); + throw err; + } + }, + [ws], + ); + const addMember = useCallback( async (teamId: string, agent: string, role?: string) => { try { @@ -272,7 +300,7 @@ export function useTeams() { return { teams, loading, load, createTeam, deleteTeam, getTeam, getTeamTasks, getTeamScopes, getTaskDetail, getTaskLight, approveTask, rejectTask, addTaskComment, getTaskComments, getTaskEvents, - createTask, deleteTask, deleteTasksBulk, assignTask, + createTask, deleteTask, deleteTasksBulk, assignTask, cancelTask, retryTask, addMember, removeMember, updateTeam, }; } diff --git a/ui/web/src/pages/teams/task-sections/task-detail-dialog.tsx b/ui/web/src/pages/teams/task-sections/task-detail-dialog.tsx index 3e3e33f0..c68f77ed 100644 --- a/ui/web/src/pages/teams/task-sections/task-detail-dialog.tsx +++ b/ui/web/src/pages/teams/task-sections/task-detail-dialog.tsx @@ -5,9 +5,11 @@ import { import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; +import { Textarea } from "@/components/ui/textarea"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { ConfirmDialog } from "@/components/shared/confirm-dialog"; import { - Trash2, ArrowUp, ArrowDown, ArrowRight, AlertTriangle, Copy, Check, + Trash2, ArrowUp, ArrowDown, ArrowRight, AlertTriangle, Copy, Check, Ban, RotateCcw, } from "lucide-react"; import { useTranslation } from "react-i18next"; import { formatDate } from "@/lib/format"; @@ -15,7 +17,7 @@ import { useWsEvent } from "@/hooks/use-ws-event"; import { Events } from "@/api/protocol"; import type { TeamTaskData, TeamTaskComment, TeamTaskEvent, TeamTaskAttachment } from "@/types/team"; import type { TeamTaskEventPayload } from "@/types/team-events"; -import { taskStatusBadgeVariant, isTerminalStatus } from "./task-utils"; +import { taskStatusBadgeVariant, isTerminalStatus, canCancelTask, canRetryTask } from "./task-utils"; import { TaskDetailContent } from "./task-detail-content"; import { TaskDetailAttachments } from "./task-detail-attachments"; import { TaskDetailComments } from "./task-detail-comments"; @@ -88,6 +90,88 @@ function MetaItem({ label, children }: { label: string; children: React.ReactNod ); } +/* ── Action dialog (cancel with optional reason / retry with required comment) ── */ + +export interface TaskAssigneeOption { id: string; name: string } + +interface TaskActionDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + title: string; + description: string; + placeholder: string; + confirmLabel: string; + required?: boolean; + requiredHint?: string; + variant?: "default" | "destructive"; + loading?: boolean; + /** When set, an assignee select is shown above the textarea (retry). */ + assignees?: TaskAssigneeOption[]; + assigneeLabel?: string; + defaultAssigneeId?: string; + onConfirm: (text: string, assigneeId?: string) => void; +} + +function TaskActionDialog({ + open, onOpenChange, title, description, placeholder, confirmLabel, + required, requiredHint, variant = "default", loading, + assignees, assigneeLabel, defaultAssigneeId, onConfirm, +}: TaskActionDialogProps) { + const { t } = useTranslation("common"); + const [text, setText] = useState(""); + const [assigneeId, setAssigneeId] = useState(""); + useEffect(() => { + if (open) { setText(""); setAssigneeId(defaultAssigneeId ?? ""); } + }, [open, defaultAssigneeId]); + const withAssignee = !!assignees && assignees.length > 0; + const missing = (!!required && text.trim() === "") || (withAssignee && assigneeId === ""); + + return ( + { if (!loading) onOpenChange(v); }}> + + + {title} + +

{description}

+ {withAssignee && ( +
+ {assigneeLabel &&

{assigneeLabel}

} + +
+ )} +