feat(teams): task hints for weak models + force v2

- Add lead hint in search/list results with member+model info and task
  creation guidelines (description quality, complexity, model matching)
- Add member instructions in dispatch message (progress, comment, complete)
- Include member comments and attachments in announce content to leader
- Cap announce content at 50k runes to prevent context blowup
- Remove duplicate comment block in announce path
- Force all teams to v2: remove IsTeamV2 gate, v2Actions map,
  isConsumerTeamV2, and v1 TEAM.md branch
- Replace version comparison modal with features showcase modal
- Remove version selector from team settings UI
This commit is contained in:
viettranx committed 2026-03-23 12:14:29 +07:00
1 parent 57f249a89d
commit 8d3eb5bb6f
17 files changed
+259 -362

No files matched your search

-8
View File
@@ -136,11 +136,6 @@ func autoSetFollowup(ctx context.Context, teamStore store.TeamStore, agentStore
if err != nil || team == nil || team.LeadAgentID != ag.ID {
return // only lead agent triggers auto-set
}
// Followup is a v2 feature.
if !isConsumerTeamV2(team) {
return
}
// Skip auto-followup when lead is waiting for teammates (not user).
if hasMember, _ := teamStore.HasActiveMemberTasks(ctx, team.ID, ag.ID); hasMember {
slog.Debug("auto-followup: skipping, active member tasks exist", "team_id", team.ID)
@@ -159,9 +154,6 @@ func autoSetFollowup(ctx context.Context, teamStore store.TeamStore, agentStore
}
}
// isConsumerTeamV2 delegates to tools.IsTeamV2 for version checking.
var isConsumerTeamV2 = tools.IsTeamV2
// parseFollowupSettings extracts followup interval and max reminders from team settings.
func parseFollowupSettings(team *store.TeamData) (time.Duration, int) {
const (
+32 -25
View File
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"log/slog"
"path/filepath"
"strings"
"sync"
"time"
@@ -330,7 +331,7 @@ func handleTeammateMessage(
teamID, _ := uuid.Parse(inMeta["team_id"])
if teamTaskID != uuid.Nil && teamStore != nil {
cachedTeam, _ = teamStore.GetTeam(ctx, teamID)
if cachedTeam != nil && isConsumerTeamV2(cachedTeam) {
if cachedTeam != nil {
// Check current task status — agent may have already updated it via tool.
currentTask, taskErr := teamStore.GetTask(ctx, teamTaskID)
alreadyTerminal := taskErr == nil && currentTask != nil &&
@@ -432,6 +433,33 @@ func handleTeammateMessage(
announceMedia = outcome.Result.Media
}
// Append member comments & attachments so leader sees them in the announce.
if taskIDStr := inMeta["team_task_id"]; taskIDStr != "" && teamStore != nil {
if taskUUID, err := uuid.Parse(taskIDStr); err == nil {
if comments, err := teamStore.ListRecentTaskComments(ctx, taskUUID, 5); err == nil && len(comments) > 0 {
var parts []string
for _, c := range comments {
author := c.AgentKey
if author == "" {
author = "system"
}
text := c.Content
if len([]rune(text)) > 500 {
text = string([]rune(text)[:500]) + "..."
}
parts = append(parts, fmt.Sprintf("- [%s]: %s", author, text))
}
announceContent += "\n\n[Member notes]\n" + strings.Join(parts, "\n")
}
if attachments, err := teamStore.ListTaskAttachments(ctx, taskUUID); err == nil && len(attachments) > 0 {
announceContent += "\n\n[Attached files in team workspace]"
for _, a := range attachments {
announceContent += "\n- " + filepath.Base(a.Path)
}
}
}
}
// Announce result (or failure) to lead agent via announce queue.
// Queue merges concurrent completions into a single batched announce.
if origChatID == "" {
@@ -483,30 +511,9 @@ func handleTeammateMessage(
parentRootSpanID, _ = uuid.Parse(sid)
}
// Enrich announce content with member comments on the task (if any).
if taskIDStr := inMeta["team_task_id"]; taskIDStr != "" && teamStore != nil {
if tid, err := uuid.Parse(taskIDStr); err == nil {
if comments, err := teamStore.ListTaskComments(ctx, tid); err == nil && len(comments) > 0 {
// Include last 5 comments, each truncated to 200 chars.
start := 0
if len(comments) > 5 {
start = len(comments) - 5
}
var commentLines []string
for _, c := range comments[start:] {
author := c.AgentKey
if author == "" && c.UserID != "" {
author = "user:" + c.UserID
}
body := c.Content
if len(body) > 200 {
body = body[:200] + "..."
}
commentLines = append(commentLines, fmt.Sprintf("- [%s]: %s", author, body))
}
announceContent += "\n\n--- Member comments ---\n" + strings.Join(commentLines, "\n")
}
}
// Cap announce content to prevent context blowup for the leader agent.
if len([]rune(announceContent)) > 50_000 {
announceContent = string([]rune(announceContent)[:50_000]) + "\n[truncated]"
}
// Enqueue result. If we become the processor, run the announce loop.
+1 -1
View File
@@ -162,7 +162,7 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc {
hasTeam = true
contextFiles = append(contextFiles, bootstrap.ContextFile{
Path: bootstrap.TeamFile,
Content: buildTeamMD(team, members, ag.ID, tools.IsTeamV2(team)),
Content: buildTeamMD(team, members, ag.ID),
})
// Detect lead role for tool policy
for _, m := range members {
+30 -42
View File
@@ -14,8 +14,7 @@ import (
// buildTeamMD generates compact TEAM.md content for an agent that is part of a team.
// Kept minimal — tool descriptions already live in tool Parameters()/Description().
// isV2 controls whether advanced sections (orchestration, followup, review) are rendered.
func buildTeamMD(team *store.TeamData, members []store.TeamMemberData, selfID uuid.UUID, isV2 bool) string {
func buildTeamMD(team *store.TeamData, members []store.TeamMemberData, selfID uuid.UUID) string {
var sb strings.Builder
sb.WriteString("# Team: " + team.Name + "\n")
if team.Description != "" {
@@ -77,48 +76,37 @@ func buildTeamMD(team *store.TeamData, members []store.TeamMemberData, selfID uu
// Workflow guidance — version-aware to match backend behavior.
sb.WriteString("\n## Workflow\n\n")
if selfRole == store.TeamRoleLead {
if isV2 {
sb.WriteString("Delegate work to team members using `team_tasks` with `assignee`.\n\n")
sb.WriteString("```\nteam_tasks(action=\"create\", subject=\"...\", description=\"...\", assignee=\"agent-key\")\n```\n\n")
sb.WriteString("The system auto-dispatches to the assigned member and auto-completes when done.\n")
sb.WriteString("Do NOT use `spawn` for team delegation — `spawn` is only for self-clone subagent work.\n\n")
sb.WriteString("Rules:\n")
sb.WriteString("- Always specify `assignee` — match member expertise from the list above\n")
sb.WriteString("- **Check task board first** — call `team_tasks(action=\"search\", query=\"<keywords>\")` to find similar tasks before creating. This uses semantic search and saves tokens vs listing all. The system blocks creation if you skip this step\n")
sb.WriteString("- **Create ALL tasks upfront** in one batch, then announce — then STOP. Do NOT create one task, wait for it to finish, then create the next\n")
sb.WriteString("- Delegation is NOT completion — do NOT say \"done\"/\"xong\"/\"finished\" after delegating. Only report completion when ALL task results have been delivered\n")
sb.WriteString("- Results arrive automatically — do NOT present partial results\n")
sb.WriteString("- **Prefer delegation** — delegate tasks to members, do NOT do the work yourself. If you need research, create a research task. If you need code, create a coding task. Only use tools like web_search yourself for quick clarifications needed to plan tasks (e.g., checking a member's capability), NOT for producing deliverables\n")
sb.WriteString("- **Do NOT block on completed tasks** — pass completed task's result in the description instead of using blocked_by\n")
sb.WriteString("Delegate work to team members using `team_tasks` with `assignee`.\n\n")
sb.WriteString("```\nteam_tasks(action=\"create\", subject=\"...\", description=\"...\", assignee=\"agent-key\")\n```\n\n")
sb.WriteString("The system auto-dispatches to the assigned member and auto-completes when done.\n")
sb.WriteString("Do NOT use `spawn` for team delegation — `spawn` is only for self-clone subagent work.\n\n")
sb.WriteString("Rules:\n")
sb.WriteString("- Always specify `assignee` — match member expertise from the list above\n")
sb.WriteString("- **Check task board first** — call `team_tasks(action=\"search\", query=\"<keywords>\")` to find similar tasks before creating. This uses semantic search and saves tokens vs listing all. The system blocks creation if you skip this step\n")
sb.WriteString("- **Create ALL tasks upfront** in one batch, then announce — then STOP. Do NOT create one task, wait for it to finish, then create the next\n")
sb.WriteString("- Delegation is NOT completion — do NOT say \"done\"/\"xong\"/\"finished\" after delegating. Only report completion when ALL task results have been delivered\n")
sb.WriteString("- Results arrive automatically — do NOT present partial results\n")
sb.WriteString("- **Prefer delegation** — delegate tasks to members, do NOT do the work yourself. If you need research, create a research task. If you need code, create a coding task. Only use tools like web_search yourself for quick clarifications needed to plan tasks (e.g., checking a member's capability), NOT for producing deliverables\n")
sb.WriteString("- **Do NOT block on completed tasks** — pass completed task's result in the description instead of using blocked_by\n")
sb.WriteString("\n## Task Planning\n\n")
sb.WriteString("**CRITICAL: Create the full task graph in ONE batch.** Do NOT create→wait→create sequentially.\n\n")
sb.WriteString("Each task = ONE deliverable. Complex requests need 3+ tasks.\n\n")
sb.WriteString("1. Identify ALL distinct deliverables (research, writing, design, code...)\n")
sb.WriteString("2. Create independent tasks FIRST → get their UUIDs from the response\n")
sb.WriteString("3. THEN create dependent tasks with `blocked_by=[UUID]` — the system auto-dispatches when blockers complete\n")
sb.WriteString(" `blocked_by` only accepts real UUIDs returned by previous create calls. Never use placeholders.\n\n")
sb.WriteString("Same member → sequential (higher priority first). Different members → parallel.\n\n")
sb.WriteString("**Anti-pattern (WRONG):** create task A → wait for A to finish → create task B → wait...\n")
sb.WriteString("**Correct pattern:** create A → create B → create C(blocked_by=[A.id, B.id]) → announce → STOP. You must create tasks first to get their UUIDs, then use those UUIDs in blocked_by of dependent tasks.\n\n")
sb.WriteString("**Example:** User: \"research X and make infographic\"\n")
sb.WriteString("→ WRONG: web_search(X) yourself → summarize → delegate only infographic\n")
sb.WriteString("→ RIGHT: task 1 \"Research X\" (assignee=researcher) → task 2 \"Create infographic about X\" (assignee=artist, blocked_by=[task1.id])\n")
sb.WriteString("\n## Task Planning\n\n")
sb.WriteString("**CRITICAL: Create the full task graph in ONE batch.** Do NOT create→wait→create sequentially.\n\n")
sb.WriteString("Each task = ONE deliverable. Complex requests need 3+ tasks.\n\n")
sb.WriteString("1. Identify ALL distinct deliverables (research, writing, design, code...)\n")
sb.WriteString("2. Create independent tasks FIRST → get their UUIDs from the response\n")
sb.WriteString("3. THEN create dependent tasks with `blocked_by=[UUID]` — the system auto-dispatches when blockers complete\n")
sb.WriteString(" `blocked_by` only accepts real UUIDs returned by previous create calls. Never use placeholders.\n\n")
sb.WriteString("Same member → sequential (higher priority first). Different members → parallel.\n\n")
sb.WriteString("**Anti-pattern (WRONG):** create task A → wait for A to finish → create task B → wait...\n")
sb.WriteString("**Correct pattern:** create A → create B → create C(blocked_by=[A.id, B.id]) → announce → STOP. You must create tasks first to get their UUIDs, then use those UUIDs in blocked_by of dependent tasks.\n\n")
sb.WriteString("**Example:** User: \"research X and make infographic\"\n")
sb.WriteString("→ WRONG: web_search(X) yourself → summarize → delegate only infographic\n")
sb.WriteString("→ RIGHT: task 1 \"Research X\" (assignee=researcher) → task 2 \"Create infographic about X\" (assignee=artist, blocked_by=[task1.id])\n")
sb.WriteString("\n## Follow-up Reminders\n\n")
sb.WriteString("When you need user input/decision: create+claim task, then `ask_user` with text=<question>. ONLY use when you have a question for the user — NOT for waiting on teammates or status updates.\n")
sb.WriteString("IMPORTANT: Present the question directly to the user in your response. `ask_user` only sets up periodic REMINDERS in case they don't reply — it does NOT present the question for you.\n")
sb.WriteString("System auto-sends reminders. Call `clear_ask_user` when user replies.\n")
} else {
sb.WriteString("Create a task with `team_tasks` (with `assignee`), then the system dispatches automatically.\n")
sb.WriteString("Tasks auto-complete when the member finishes.\n\n")
sb.WriteString("Rules:\n")
sb.WriteString("- Always specify `assignee` when creating tasks\n")
sb.WriteString("- Create all tasks first, then briefly tell the user what you delegated\n")
sb.WriteString("- Do NOT say \"done\", \"xong\", \"finished\", or any completion language after delegating — delegation is NOT completion\n")
sb.WriteString("- Do NOT phrase delegation as a question or request (\"nha?\", \"nhé?\", \"okay?\"). State it declaratively\n")
sb.WriteString("- Results arrive automatically — do NOT present partial results\n")
}
sb.WriteString("\n## Follow-up Reminders\n\n")
sb.WriteString("When you need user input/decision: create+claim task, then `ask_user` with text=<question>. ONLY use when you have a question for the user — NOT for waiting on teammates or status updates.\n")
sb.WriteString("IMPORTANT: Present the question directly to the user in your response. `ask_user` only sets up periodic REMINDERS in case they don't reply — it does NOT present the question for you.\n")
sb.WriteString("System auto-sends reminders. Call `clear_ask_user` when user replies.\n")
sb.WriteString("\nFor simple questions about team composition, answer directly from the member list above.\n")
} else {
+50 -4
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"strings"
"sync"
"time"
@@ -143,8 +144,45 @@ func (t *TeamTasksTool) toDetailItem(ctx context.Context, task *store.TeamTaskDa
}
}
// buildCreateHint generates task creation guidance with member+model info.
// Injected into search/list results so weaker models (MiniMax, Qwen) get
// actionable hints before calling create.
func (t *TeamTasksTool) buildCreateHint(ctx context.Context, teamID, leadAgentID, callerAgentID uuid.UUID) string {
members, err := t.manager.cachedListMembers(ctx, teamID, callerAgentID)
if err != nil || len(members) == 0 {
return ""
}
var sb strings.Builder
sb.WriteString("[Task creation guide]\nAvailable members and their models:\n")
for _, m := range members {
if m.AgentID == leadAgentID {
continue // skip lead from member list
}
model := ""
if ag, err := t.manager.cachedGetAgentByID(ctx, m.AgentID); err == nil {
model = ag.Model
}
entry := fmt.Sprintf("- %s (%s)", m.AgentKey, model)
if m.Frontmatter != "" {
fm := m.Frontmatter
if len([]rune(fm)) > 80 {
fm = string([]rune(fm)[:80]) + "…"
}
entry += " — " + fm
}
sb.WriteString(entry + "\n")
}
sb.WriteString("\nBefore creating a task:\n")
sb.WriteString("1. DESCRIPTION: Include clear objective, relevant context, constraints, and expected output format. The member will only see this description — make it self-contained.\n")
sb.WriteString("2. COMPLEXITY: If a task requires multiple steps or different skills, break it into separate tasks with blocked_by.\n")
sb.WriteString("3. MODEL MATCH: Assign complex reasoning/analysis tasks to members with stronger models. Simple tasks can go to any.\n")
sb.WriteString("4. INDEPENDENCE: Each task must be completable without asking the lead for clarification.")
return sb.String()
}
func (t *TeamTasksTool) executeList(ctx context.Context, args map[string]any) *Result {
team, _, err := t.manager.resolveTeam(ctx)
team, agentID, err := t.manager.resolveTeam(ctx)
if err != nil {
return ErrorResult(err.Error())
}
@@ -208,6 +246,9 @@ func (t *TeamTasksTool) executeList(ctx context.Context, args map[string]any) *R
if hasMore {
resp["has_more"] = true
}
if hint := t.buildCreateHint(ctx, team.ID, team.LeadAgentID, agentID); hint != "" {
resp["hint"] = hint
}
out, _ := json.Marshal(resp)
return SilentResult(string(out))
@@ -322,7 +363,7 @@ func (t *TeamTasksTool) executeGet(ctx context.Context, args map[string]any) *Re
}
func (t *TeamTasksTool) executeSearch(ctx context.Context, args map[string]any) *Result {
team, _, err := t.manager.resolveTeam(ctx)
team, agentID, err := t.manager.resolveTeam(ctx)
if err != nil {
return ErrorResult(err.Error())
}
@@ -358,9 +399,14 @@ func (t *TeamTasksTool) executeSearch(ctx context.Context, args map[string]any)
items = append(items, t.toListItem(ctx, task))
}
out, _ := json.Marshal(map[string]any{
resp := map[string]any{
"tasks": items,
"count": len(items),
})
}
if hint := t.buildCreateHint(ctx, team.ID, team.LeadAgentID, agentID); hint != "" {
resp["hint"] = hint
}
out, _ := json.Marshal(resp)
return SilentResult(string(out))
}
-18
View File
@@ -94,27 +94,9 @@ func (t *TeamTasksTool) Parameters() map[string]any {
}
}
// v2Actions lists team_tasks actions that require team version >= 2.
var v2Actions = map[string]bool{
"approve": true, "reject": true, "review": true, "comment": true,
"progress": true, "attach": true, "update": true,
"ask_user": true, "clear_ask_user": true, "retry": true,
}
func (t *TeamTasksTool) Execute(ctx context.Context, args map[string]any) *Result {
action, _ := args["action"].(string)
// Gate v2-only actions: resolve team once and check version.
if v2Actions[action] {
team, _, err := t.manager.resolveTeam(ctx)
if err != nil {
return ErrorResult(err.Error())
}
if !IsTeamV2(team) {
return ErrorResult(fmt.Sprintf("action '%s' requires team version 2 — upgrade in team settings", action))
}
}
switch action {
case "list":
return t.executeList(ctx, args)
+7
View File
@@ -80,6 +80,13 @@ 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" +
"- When done: team_tasks(action=\"complete\", result=\"summary of your work\")\n" +
"- Write output files to team workspace so lead can review"
// Use task's stored channel/chat as primary source for routing.
// Falls back to ctx values for initial dispatch (task just created, fields match ctx).
originChannel := task.Channel
+3 -29
View File
@@ -53,20 +53,6 @@ func (m *TeamToolManager) agentDisplayName(ctx context.Context, key string) stri
// Version helpers
// ============================================================
// IsTeamV2 checks if team has version >= 2 in settings.
// Returns false for nil team, nil/empty settings, or version < 2.
func IsTeamV2(team *store.TeamData) bool {
if team == nil || team.Settings == nil {
return false
}
var s struct {
Version int `json:"version"`
}
if json.Unmarshal(team.Settings, &s) != nil {
return false
}
return s.Version >= 2
}
// ============================================================
// Follow-up settings helpers
@@ -78,12 +64,8 @@ const (
)
// followupDelayMinutes returns the team's followup_interval_minutes setting, or the default.
// Returns 0 for v1 teams (followup disabled).
func (m *TeamToolManager) followupDelayMinutes(team *store.TeamData) int {
if !IsTeamV2(team) {
return 0
}
if team.Settings == nil {
if team == nil || team.Settings == nil {
return defaultFollowupDelayMinutes
}
var settings map[string]any
@@ -97,12 +79,8 @@ func (m *TeamToolManager) followupDelayMinutes(team *store.TeamData) int {
}
// followupMaxReminders returns the team's followup_max_reminders setting, or the default.
// Returns 0 for v1 teams (followup disabled).
func (m *TeamToolManager) followupMaxReminders(team *store.TeamData) int {
if !IsTeamV2(team) {
return 0
}
if team.Settings == nil {
if team == nil || team.Settings == nil {
return defaultFollowupMaxReminders
}
var settings map[string]any
@@ -130,12 +108,8 @@ const (
)
// checkEscalation parses the team's escalation_mode and escalation_actions settings.
// Returns EscalationNone for v1 teams.
func (m *TeamToolManager) checkEscalation(team *store.TeamData, action string) EscalationResult {
if !IsTeamV2(team) {
return EscalationNone
}
if team.Settings == nil {
if team == nil || team.Settings == nil {
return EscalationNone
}
var settings map[string]any
+2 -2
View File
@@ -241,7 +241,7 @@
"versionAdvancedDesc": "All V1 features plus execution locking, follow-up reminders, review workflow, progress tracking, comments, audit trail, and auto-recovery.",
"whatsNew": "What's different?",
"versionModal": {
"title": "Team Versions",
"title": "Super Team Features",
"feature": "Feature",
"taskManagement": "Task Management",
"taskManagementDesc": "Create, assign, complete, cancel tasks",
@@ -263,7 +263,7 @@
"escalationPolicyDesc": "Control what agents can do autonomously",
"comingSoon": "Coming soon",
"downgradeNote": "Downgrade is safe — V2 data is preserved but hidden.",
"betaNote": "V2 is currently in Beta.",
"betaNote": "Some features are currently in Beta.",
"gotIt": "Got it"
}
},
+2 -2
View File
@@ -241,7 +241,7 @@
"versionAdvancedDesc": "Tất cả tính năng V1 cùng khóa thực thi, nhắc nhở theo dõi, quy trình duyệt, theo dõi tiến độ, bình luận, nhật ký hoạt động và tự phục hồi.",
"whatsNew": "Có gì mới?",
"versionModal": {
"title": "Phiên bản Team",
"title": "Tính năng Super Team",
"feature": "Tính năng",
"taskManagement": "Quản lý nhiệm vụ",
"taskManagementDesc": "Tạo, phân công, hoàn thành, hủy nhiệm vụ",
@@ -263,7 +263,7 @@
"escalationPolicyDesc": "Kiểm soát những gì agent có thể tự thực hiện",
"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": "V2 hiện đang ở giai đoạn Beta.",
"betaNote": "Một số tính năng đang ở giai đoạn Beta.",
"gotIt": "Đã hiểu"
}
},
+2 -2
View File
@@ -241,7 +241,7 @@
"versionAdvancedDesc": "所有V1功能,外加执行锁定、跟进提醒、审核工作流、进度追踪、评论、审计日志和自动恢复。",
"whatsNew": "有什么不同?",
"versionModal": {
"title": "团队版本",
"title": "超级团队功能",
"feature": "功能",
"taskManagement": "任务管理",
"taskManagementDesc": "创建、分配、完成、取消任务",
@@ -263,7 +263,7 @@
"escalationPolicyDesc": "控制代理可以自主执行的操作",
"comingSoon": "即将推出",
"downgradeNote": "降级是安全的 — V2数据会保留但隐藏。",
"betaNote": "V2目前处于Beta阶段。",
"betaNote": "部分功能目前处于Beta阶段。",
"gotIt": "知道了"
}
},
+11 -12
View File
@@ -1,8 +1,10 @@
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 { useTranslation } from "react-i18next";
import type { TeamData, TeamMemberData, TeamAccessSettings } from "@/types/team";
import type { TeamData, TeamMemberData } from "@/types/team";
import { TeamFeaturesModal } from "../team-features-modal";
interface BoardHeaderProps {
team: TeamData;
@@ -11,13 +13,11 @@ interface BoardHeaderProps {
onDelete: () => void;
onSettings: () => void;
onMembers: () => void;
onV2Click?: () => void;
}
export function BoardHeader({ team, members, onBack, onDelete, onSettings, onMembers, onV2Click }: BoardHeaderProps) {
export function BoardHeader({ team, members, onBack, onDelete, onSettings, onMembers }: BoardHeaderProps) {
const { t } = useTranslation("teams");
const settings = (team.settings ?? {}) as TeamAccessSettings;
const isV2 = (settings.version ?? 1) >= 2;
const [featuresOpen, setFeaturesOpen] = 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;
@@ -36,14 +36,11 @@ export function BoardHeader({ team, members, onBack, onDelete, onSettings, onMem
<Badge variant={team.status === "active" ? "success" : "secondary"} className="text-[10px]">
{team.status}
</Badge>
{isV2 && (
<Badge
className="bg-gradient-to-r from-orange-500 to-amber-500 text-[10px] px-2 py-0.5 text-white border-0 font-semibold shadow-sm cursor-pointer hover:opacity-80 transition-opacity"
onClick={onV2Click}
>
v2 Super Team (Beta)
<button type="button" className="relative inline-flex items-center" onClick={() => setFeaturesOpen(true)}>
<Badge className="bg-gradient-to-r from-orange-500 to-amber-500 text-[10px] px-2 py-0.5 text-white border-0 font-semibold hover:from-orange-600 hover:to-amber-600">
v2 Super Team
</Badge>
)}
</button>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{leadName && (
@@ -75,6 +72,8 @@ export function BoardHeader({ team, members, onBack, onDelete, onSettings, onMem
<Trash2 className="h-4 w-4" />
<span className="hidden sm:inline">{t("delete.title")}</span>
</Button>
<TeamFeaturesModal open={featuresOpen} onOpenChange={setFeaturesOpen} />
</div>
);
}
@@ -5,8 +5,8 @@ import {
import { Badge } from "@/components/ui/badge";
import { useTranslation } from "react-i18next";
import { TeamSettingsTab } from "../team-settings-tab";
import { TeamVersionModal } from "../team-version-modal";
import type { TeamData, TeamMemberData, TeamAccessSettings } from "@/types/team";
import { TeamFeaturesModal } from "../team-features-modal";
import type { TeamData, TeamMemberData } from "@/types/team";
interface TeamInfoDialogProps {
open: boolean;
@@ -21,10 +21,7 @@ export function TeamInfoDialog({
open, onOpenChange, team, teamId, members, onSaved,
}: TeamInfoDialogProps) {
const { t } = useTranslation("teams");
const [versionModalOpen, setVersionModalOpen] = useState(false);
const settings = (team.settings ?? {}) as TeamAccessSettings;
const isV2 = (settings.version ?? 1) >= 2;
const [featuresOpen, setFeaturesOpen] = useState(false);
// Resolve lead name from members (more reliable than team.lead_display_name which can be empty)
const leadMember = members.find((m) => m.role === "lead");
@@ -41,14 +38,11 @@ export function TeamInfoDialog({
<Badge variant={team.status === "active" ? "success" : "secondary"} className="text-[10px]">
{team.status}
</Badge>
{isV2 && (
<Badge
className="bg-gradient-to-r from-orange-500 to-amber-500 text-[10px] px-2 py-0.5 text-white border-0 font-semibold shadow-sm cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => setVersionModalOpen(true)}
>
v2 Super Team (Beta)
<button type="button" className="relative inline-flex items-center" onClick={() => setFeaturesOpen(true)}>
<Badge className="bg-gradient-to-r from-orange-500 to-amber-500 text-[10px] px-2 py-0.5 text-white border-0 font-semibold hover:from-orange-600 hover:to-amber-600">
v2 Super Team
</Badge>
)}
</button>
</DialogTitle>
</DialogHeader>
@@ -76,8 +70,7 @@ export function TeamInfoDialog({
</div>
</DialogContent>
</Dialog>
<TeamVersionModal open={versionModalOpen} onOpenChange={setVersionModalOpen} />
<TeamFeaturesModal open={featuresOpen} onOpenChange={setFeaturesOpen} />
</>
);
}
@@ -8,7 +8,6 @@ import { BoardContainer } from "./board/board-container";
import { TeamInfoDialog } from "./board/team-info-dialog";
import { TeamMembersDialog } from "./board/team-members-dialog";
import { TeamWorkspaceDialog } from "./board/team-workspace-dialog";
import { TeamVersionModal } from "./team-version-modal";
import type { TeamData, TeamMemberData, TeamAccessSettings, ScopeEntry } from "@/types/team";
interface TeamDetailPageProps {
@@ -41,7 +40,6 @@ export function TeamDetailPage({ teamId, onBack }: TeamDetailPageProps) {
const [infoOpen, setInfoOpen] = useState(false);
const [membersOpen, setMembersOpen] = useState(false);
const [workspaceOpen, setWorkspaceOpen] = useState(false);
const [versionModalOpen, setVersionModalOpen] = useState(false);
const reload = useCallback(async () => {
try {
@@ -97,7 +95,6 @@ export function TeamDetailPage({ teamId, onBack }: TeamDetailPageProps) {
onDelete={() => setDeleteOpen(true)}
onSettings={() => setInfoOpen(true)}
onMembers={() => setMembersOpen(true)}
onV2Click={() => setVersionModalOpen(true)}
/>
<BoardContainer
@@ -141,9 +138,6 @@ export function TeamDetailPage({ teamId, onBack }: TeamDetailPageProps) {
scopes={scopes}
/>
{/* V2 version comparison modal */}
<TeamVersionModal open={versionModalOpen} onOpenChange={setVersionModalOpen} />
{/* Delete confirmation */}
<ConfirmDeleteDialog
open={deleteOpen}
@@ -0,0 +1,103 @@
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Check, Minus,
ListTodo, FolderOpen, Lock, Bell,
ClipboardCheck, BarChart3, MessageSquare, RotateCcw, ShieldCheck,
} from "lucide-react";
import { useTranslation } from "react-i18next";
interface TeamFeaturesModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
const FEATURES = [
{ key: "taskManagement", icon: ListTodo },
{ key: "sharedWorkspace", icon: FolderOpen },
{ key: "executionLocking", icon: Lock },
{ 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 },
] as const;
export function TeamFeaturesModal({ open, onOpenChange }: TeamFeaturesModalProps) {
const { t } = useTranslation("teams");
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[85vh] w-[95vw] flex flex-col sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{t("settings.versionModal.title")}
<Badge className="bg-gradient-to-r from-orange-500 to-amber-500 text-[10px] px-2 py-0.5 text-white border-0 font-semibold">
Beta
</Badge>
</DialogTitle>
<DialogDescription className="sr-only">{t("settings.versionModal.title")}</DialogDescription>
</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" : ""}`}
>
<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>
<p className="text-xs text-muted-foreground leading-relaxed">
{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" />
)}
</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>
</div>
<div className="flex justify-end">
<Button variant="outline" size="sm" onClick={() => onOpenChange(false)}>
{t("settings.versionModal.gotIt")}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
+8 -65
View File
@@ -3,12 +3,11 @@ import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
import { Combobox } from "@/components/ui/combobox";
import { X, Save, Bell, ShieldAlert, Clock, Info, FolderLock, FolderSync, Zap, Bot, Loader2 } from "lucide-react";
import { X, Save, Bell, ShieldAlert, Clock, FolderLock, FolderSync, Zap, Bot, Loader2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { CHANNEL_TYPES } from "@/constants/channels";
import type { TeamData, TeamAccessSettings, TeamNotifyConfig, EscalationMode, EscalationAction } from "@/types/team";
import { useTeams } from "./hooks/use-teams";
import { TeamVersionModal } from "./team-version-modal";
interface TeamSettingsTabProps {
teamId: string;
@@ -66,7 +65,6 @@ export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps)
// Parse initial settings
const initial = (team.settings ?? {}) as TeamAccessSettings;
const [version, setVersion] = useState<number>(initial.version ?? 1);
const [allowUserIds, setAllowUserIds] = useState<string[]>(initial.allow_user_ids ?? []);
const [denyUserIds, setDenyUserIds] = useState<string[]>(initial.deny_user_ids ?? []);
const [allowChannels, setAllowChannels] = useState<string[]>(initial.allow_channels ?? []);
@@ -88,10 +86,8 @@ export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps)
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");
const isTeamV2 = version >= 2;
const [saving, setSaving] = useState(false);
const [versionModalOpen, setVersionModalOpen] = useState(false);
// Load known users for combobox
useEffect(() => {
@@ -101,7 +97,6 @@ export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps)
// Reset when team changes
useEffect(() => {
const s = (team.settings ?? {}) as TeamAccessSettings;
setVersion(s.version ?? 1);
setAllowUserIds(s.allow_user_ids ?? []);
setDenyUserIds(s.deny_user_ids ?? []);
setAllowChannels(s.allow_channels ?? []);
@@ -157,70 +152,19 @@ export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps)
if (followupInterval !== 30) settings.followup_interval_minutes = followupInterval;
if (followupMaxReminders !== 0) settings.followup_max_reminders = followupMaxReminders;
settings.workspace_scope = workspaceScope || "isolated";
if (version >= 2) settings.version = version;
await updateTeamSettings(teamId, settings);
onSaved();
} catch { // toast shown by hook
} finally {
setSaving(false);
}
}, [teamId, version, allowUserIds, denyUserIds, allowChannels, denyChannels, notifyDispatched, notifyProgress, notifyFailed, notifyCompleted, notifyCommented, notifyNewTask, notifySlowTool, notifyMode, memberRequestsEnabled, memberRequestsAutoDispatch, escalationMode, escalationActions, followupInterval, followupMaxReminders, workspaceScope, updateTeamSettings, onSaved, t]);
}, [teamId, allowUserIds, denyUserIds, allowChannels, denyChannels, notifyDispatched, notifyProgress, notifyFailed, notifyCompleted, notifyCommented, notifyNewTask, notifySlowTool, notifyMode, memberRequestsEnabled, memberRequestsAutoDispatch, escalationMode, escalationActions, 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 }));
return (
<div className="space-y-6">
{/* Team Version */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium">{t("settings.teamVersion")}</h3>
<button
type="button"
onClick={() => setVersionModalOpen(true)}
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
<Info className="h-3.5 w-3.5" />
{t("settings.whatsNew")}
</button>
</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<button
type="button"
onClick={() => setVersion(1)}
className={
"rounded-lg border p-3 text-left transition-colors " +
(version === 1
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50")
}
>
<div className="text-sm font-medium">V1 — {t("settings.versionBasic")}</div>
<div className="mt-0.5 text-xs text-muted-foreground">
{t("settings.versionBasicDesc")}
</div>
</button>
<button
type="button"
onClick={() => setVersion(2)}
className={
"rounded-lg border p-3 text-left transition-colors " +
(version >= 2
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50")
}
>
<div className="flex items-center gap-1.5">
<span className="text-sm font-medium">V2 — {t("settings.versionAdvanced")}</span>
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">Beta</Badge>
</div>
<div className="mt-0.5 text-xs text-muted-foreground">
{t("settings.versionAdvancedDesc")}
</div>
</button>
</div>
</div>
{/* Notifications */}
<div className="space-y-4">
<h3 className="text-sm font-medium">{t("settings.notifications")}</h3>
@@ -384,8 +328,8 @@ export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps)
</div>
</div>
{/* Escalation Policy (V2 only — coming soon) */}
{isTeamV2 && <div className="space-y-4 opacity-40 pointer-events-none select-none">
{/* 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>
@@ -403,10 +347,10 @@ export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps)
</div>
</div>
</div>
</div>}
</div>
{/* Follow-up Reminders (V2 only) */}
{isTeamV2 && <div className="space-y-4">
{/* Follow-up Reminders */}
<div className="space-y-4">
<h3 className="text-sm font-medium">{t("settings.followupReminders")}</h3>
<div className="rounded-lg border bg-gradient-to-r from-amber-500/5 to-yellow-500/5 p-4">
<div className="flex items-start gap-4">
@@ -445,7 +389,7 @@ export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps)
</div>
</div>
</div>
</div>}
</div>
{/* User Access Control */}
<div className="space-y-4">
@@ -517,7 +461,6 @@ export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps)
</Button>
</div>
<TeamVersionModal open={versionModalOpen} onOpenChange={setVersionModalOpen} />
</div>
);
}
@@ -1,131 +0,0 @@
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Check, Minus,
ListTodo, FolderOpen, Lock, Bell,
ClipboardCheck, BarChart3, MessageSquare, RotateCcw, ShieldCheck,
} from "lucide-react";
import { useTranslation } from "react-i18next";
interface TeamVersionModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
const FEATURES = [
{ key: "taskManagement", icon: ListTodo, v1: true },
{ key: "sharedWorkspace", icon: FolderOpen, v1: true },
{ key: "executionLocking", icon: Lock, v1: false },
{ key: "followupReminders", icon: Bell, v1: false },
{ key: "progressTracking", icon: BarChart3, v1: false },
{ key: "autoRecovery", icon: RotateCcw, v1: false },
{ key: "commentsAudit", icon: MessageSquare, v1: false, comingSoon: true },
{ key: "escalationPolicy", icon: ShieldCheck, v1: false, comingSoon: true },
{ key: "reviewWorkflow", icon: ClipboardCheck, v1: false, comingSoon: true },
] as const;
export function TeamVersionModal({ open, onOpenChange }: TeamVersionModalProps) {
const { t } = useTranslation("teams");
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[85vh] w-[95vw] flex flex-col sm:max-w-3xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{t("settings.versionModal.title")}
<Badge className="bg-gradient-to-r from-orange-500 to-amber-500 text-[10px] px-2 py-0.5 text-white border-0 font-semibold">
Beta
</Badge>
</DialogTitle>
<DialogDescription className="sr-only">{t("settings.versionModal.title")}</DialogDescription>
</DialogHeader>
<div className="overflow-y-auto min-h-0 space-y-4 -mx-4 px-4 sm:-mx-6 sm:px-6">
{/* Version columns header */}
<div className="grid grid-cols-2 gap-3">
<div className="rounded-lg border bg-muted/30 p-3 text-center">
<p className="text-lg font-bold">V1</p>
<p className="text-xs text-muted-foreground">{t("settings.versionBasicDesc")}</p>
</div>
<div className="rounded-lg border border-orange-500/30 bg-gradient-to-br from-orange-500/5 to-amber-500/5 p-3 text-center">
<p className="text-lg font-bold bg-gradient-to-r from-orange-600 to-amber-600 bg-clip-text text-transparent">V2 Super Team</p>
<p className="text-xs text-muted-foreground">{t("settings.versionAdvancedDesc")}</p>
</div>
</div>
{/* 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_60px_60px] items-center gap-2 rounded-lg px-3 py-2.5 hover:bg-muted/30 ${dimmed ? "opacity-40" : ""}`}
>
<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>
<p className="text-xs text-muted-foreground leading-relaxed">
{t(`settings.versionModal.${f.key}Desc`)}
</p>
</div>
</div>
<div className="flex justify-center">
{f.v1 ? (
<Check className="h-4 w-4 text-green-600 dark:text-green-400" />
) : (
<Minus className="h-4 w-4 text-muted-foreground/30" />
)}
</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" />
)}
</div>
</div>
);
})}
</div>
{/* Column labels for V1/V2 */}
<div className="grid grid-cols-[1fr_60px_60px] gap-2 px-3 text-[10px] font-medium text-muted-foreground uppercase tracking-wider">
<span />
<span className="text-center">V1</span>
<span className="text-center">V2</span>
</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.downgradeNote")}</p>
<p>{t("settings.versionModal.betaNote")}</p>
</div>
</div>
<div className="flex justify-end">
<Button variant="outline" size="sm" onClick={() => onOpenChange(false)}>
{t("settings.versionModal.gotIt")}
</Button>
</div>
</DialogContent>
</Dialog>
);
}