mateclaw/mateclaw-ui/src/views/Teams.vue

1828 lines
53 KiB
Vue

<template>
<div class="mc-page-shell">
<div class="mc-page-frame">
<div class="mc-page-inner">
<!-- ==================== Team list ==================== -->
<template v-if="!store.currentTeam">
<div class="mc-page-header">
<div>
<div class="mc-page-kicker">{{ t('teams.kicker') }}</div>
<h1 class="mc-page-title">{{ t('teams.title') }}</h1>
<p class="mc-page-desc">{{ t('teams.subtitle') }}</p>
</div>
<div class="header-right">
<button class="btn-primary" @click="openCreateDialog">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
{{ t('teams.create') }}
</button>
</div>
</div>
<div v-if="!store.loading && store.teams.length === 0" class="empty-state mc-surface-card">
<div class="empty-state__icon">
<svg width="34" height="34" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
</div>
<p>{{ t('teams.empty') }}</p>
</div>
<div class="team-grid">
<div
v-for="vo in store.teams"
:key="vo.team.id"
class="team-card mc-surface-card"
@click="openTeam(vo.team.id)"
>
<div class="team-card__top">
<h3 class="team-card__name">{{ vo.team.name }}</h3>
<span class="status-pill" :class="vo.team.status === 'active' ? 'is-active' : 'is-paused'">
<span class="status-pill__dot"></span>{{ vo.team.status }}
</span>
</div>
<p class="team-card__desc">{{ vo.team.description || '—' }}</p>
<div class="team-card__foot">
<span class="lead-chip">
<span class="lead-chip__icon" :style="{ color: agentIconColor(agentIcon(vo.team.leadAgentId, vo.leadIcon)) }">
<SkillIcon :value="agentIcon(vo.team.leadAgentId, vo.leadIcon)" :size="15" />
</span>
{{ vo.leadName }}
</span>
<span class="team-card__count">{{ t('teams.memberCount', { count: vo.memberCount }) }}</span>
</div>
</div>
</div>
</template>
<!-- ==================== Team detail ==================== -->
<template v-else>
<div class="detail-header">
<div class="detail-header__left">
<button class="btn-secondary" @click="store.closeTeam()">← {{ t('teams.back') }}</button>
<h1 class="detail-header__title">{{ store.currentTeam.team.name }}</h1>
<span class="lead-chip">
<span
class="lead-chip__icon"
:style="{ color: agentIconColor(agentIcon(store.currentTeam.team.leadAgentId, store.currentTeam.leadIcon)) }"
>
<SkillIcon
:value="agentIcon(store.currentTeam.team.leadAgentId, store.currentTeam.leadIcon)"
:size="15"
/>
</span>
{{ store.currentTeam.leadName }}
</span>
</div>
<div class="detail-header__right">
<div class="view-switch">
<button
class="view-seg"
:class="{ 'is-active': activeTab === 'board' }"
@click="activeTab = 'board'"
>{{ t('teams.board') }}</button>
<button
class="view-seg"
:class="{ 'is-active': activeTab === 'members' }"
@click="activeTab = 'members'"
>{{ t('teams.members') }}</button>
</div>
<button
v-if="activeTab === 'board'"
class="btn-primary"
@click="openTaskCreateDialog"
>+ {{ t('teams.createTask') }}</button>
<button class="btn-secondary" @click="refreshBoard">{{ t('common.refresh') }}</button>
<button class="btn-danger" @click="removeTeam">{{ t('common.delete') }}</button>
</div>
</div>
<!-- Live activity feed -->
<transition-group
v-if="activeTab === 'board' && activityFeed.length > 0"
name="activity"
tag="div"
class="activity-feed"
>
<div v-for="item in activityFeed" :key="item.key" class="activity-line">
{{ item.text }}
</div>
</transition-group>
<!-- Kanban board -->
<div v-if="activeTab === 'board'" class="board-grid">
<div v-for="col in boardColumns" :key="col.key" class="board-col">
<div class="board-col__head">
<span class="board-col__dot" :class="`dot--${col.key}`"></span>
<span class="board-col__label">{{ col.label }}</span>
<span class="board-col__count">{{ col.total }}</span>
</div>
<div class="board-col__body">
<div
v-for="vo in col.tasks"
:key="vo.task.id"
class="task-card"
@click="openTask(vo)"
>
<div class="task-card__num">#{{ vo.task.taskNumber }}</div>
<div class="task-card__subject">{{ vo.task.subject }}</div>
<div class="task-card__meta">
<span class="assignee-chip">{{ vo.assigneeName || '—' }}</span>
<span v-if="vo.task.requireApproval" class="task-card__lock">🔒</span>
</div>
<div
v-if="vo.task.status === 'in_progress' && vo.task.progressPercent != null"
class="task-card__progress"
>
<div class="task-card__progress-bar" :style="{ width: vo.task.progressPercent + '%' }"></div>
</div>
</div>
<button
v-if="col.hasMore"
class="board-col__more"
@click="loadMoreColumn(col.key)"
>{{ t('teams.loadMore', { loaded: col.tasks.length, total: col.total }) }}</button>
</div>
</div>
</div>
<!-- Members -->
<div v-else class="members-panel mc-surface-card">
<div class="members-panel__toolbar">
<button class="btn-secondary" @click="memberDialogVisible = true">
+ {{ t('teams.addMember') }}
</button>
</div>
<div class="member-list">
<div v-for="m in store.members" :key="m.agentId" class="member-row">
<span
class="member-row__avatar"
:style="{ color: agentIconColor(agentIcon(m.agentId, m.icon)) }"
>
<SkillIcon :value="agentIcon(m.agentId, m.icon)" :size="20" />
</span>
<span class="member-row__name">{{ m.name }}</span>
<span class="role-chip" :class="{ 'is-lead': m.role === 'lead' }">
{{ t(`teams.roles.${m.role}`, m.role) }}
</span>
<button
v-if="m.role !== 'lead'"
class="member-row__remove"
@click="removeMember(m)"
>{{ t('common.delete') }}</button>
</div>
</div>
</div>
</template>
</div>
</div>
<!-- ==================== Create team dialog ==================== -->
<Teleport to="body">
<div v-if="createDialogVisible" class="modal-overlay" @click.self="createDialogVisible = false">
<div class="modal modal--wide">
<div class="modal-header">
<h3>{{ t('teams.create') }}</h3>
<button class="modal-close" @click="createDialogVisible = false">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>{{ t('teams.name') }} <i>*</i></label>
<input v-model.trim="createForm.name" class="form-input" maxlength="128" />
</div>
<div class="form-group">
<label>{{ t('teams.description') }}</label>
<textarea v-model="createForm.description" class="form-input form-textarea" rows="2"></textarea>
</div>
<div class="form-group">
<label>{{ t('teams.lead') }} <i>*</i></label>
<div class="agent-picker">
<button
v-for="agent in agentStore.agents"
:key="String(agent.id)"
class="agent-pill"
:class="{ 'is-selected is-lead': createForm.leadAgentId === String(agent.id) }"
@click="selectLead(String(agent.id))"
>
<span class="agent-pill__icon" :style="{ color: agentIconColor(agent.icon) }">
<SkillIcon :value="agent.icon || 'pi:user'" :size="14" />
</span>
{{ agent.name }}
</button>
</div>
</div>
<div class="form-group">
<label>{{ t('teams.membersField') }} <i>*</i></label>
<div class="agent-picker">
<button
v-for="agent in memberCandidates"
:key="String(agent.id)"
class="agent-pill"
:class="{ 'is-selected': createForm.memberAgentIds.includes(String(agent.id)) }"
@click="toggleMember(String(agent.id))"
>
<span v-if="createForm.memberAgentIds.includes(String(agent.id))" class="agent-pill__check">✓</span>
<span class="agent-pill__icon" :style="{ color: agentIconColor(agent.icon) }">
<SkillIcon :value="agent.icon || 'pi:user'" :size="14" />
</span>
{{ agent.name }}
</button>
</div>
<p class="form-hint">{{ t('teams.pickHint') }}</p>
</div>
</div>
<div class="modal-footer">
<button class="btn-secondary" @click="createDialogVisible = false">{{ t('common.cancel') }}</button>
<button class="btn-primary" :disabled="creating" @click="submitCreate">
{{ creating ? t('common.processing') : t('common.confirm') }}
</button>
</div>
</div>
</div>
</Teleport>
<!-- ==================== Add member dialog ==================== -->
<Teleport to="body">
<div v-if="memberDialogVisible" class="modal-overlay" @click.self="memberDialogVisible = false">
<div class="modal">
<div class="modal-header">
<h3>{{ t('teams.addMember') }}</h3>
<button class="modal-close" @click="memberDialogVisible = false">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>{{ t('teams.memberName') }} <i>*</i></label>
<div class="agent-picker">
<button
v-for="agent in addMemberCandidates"
:key="String(agent.id)"
class="agent-pill"
:class="{ 'is-selected': memberForm.agentId === String(agent.id) }"
@click="memberForm.agentId = String(agent.id)"
>
<span v-if="memberForm.agentId === String(agent.id)" class="agent-pill__check">✓</span>
<span class="agent-pill__icon" :style="{ color: agentIconColor(agent.icon) }">
<SkillIcon :value="agent.icon || 'pi:user'" :size="14" />
</span>
{{ agent.name }}
</button>
<p v-if="addMemberCandidates.length === 0" class="form-hint">{{ t('teams.noCandidates') }}</p>
</div>
</div>
<div class="form-group">
<label>{{ t('teams.role') }}</label>
<div class="view-switch">
<button
class="view-seg"
:class="{ 'is-active': memberForm.role === 'member' }"
@click="memberForm.role = 'member'"
>{{ t('teams.roles.member') }}</button>
<button
class="view-seg"
:class="{ 'is-active': memberForm.role === 'reviewer' }"
@click="memberForm.role = 'reviewer'"
>{{ t('teams.roles.reviewer') }}</button>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn-secondary" @click="memberDialogVisible = false">{{ t('common.cancel') }}</button>
<button class="btn-primary" :disabled="!memberForm.agentId" @click="submitAddMember">
{{ t('common.confirm') }}
</button>
</div>
</div>
</div>
</Teleport>
<!-- ==================== Create task dialog ==================== -->
<Teleport to="body">
<div v-if="taskCreateDialogVisible" class="modal-overlay" @click.self="taskCreateDialogVisible = false">
<div class="modal modal--wide">
<div class="modal-header">
<h3>{{ t('teams.createTask') }}</h3>
<button class="modal-close" @click="taskCreateDialogVisible = false">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>{{ t('teams.taskSubject') }} <i>*</i></label>
<input v-model.trim="taskForm.subject" class="form-input" maxlength="256" />
</div>
<div class="form-group">
<label>{{ t('teams.taskDescription') }}</label>
<textarea v-model="taskForm.description" class="form-input form-textarea" rows="3"></textarea>
</div>
<div class="form-group">
<label>{{ t('teams.assignee') }} <i>*</i></label>
<div class="agent-picker">
<button
v-for="m in assigneeCandidates"
:key="m.agentId"
class="agent-pill"
:class="{ 'is-selected': taskForm.assigneeAgentId === String(m.agentId) }"
@click="taskForm.assigneeAgentId = String(m.agentId)"
>
<span v-if="taskForm.assigneeAgentId === String(m.agentId)" class="agent-pill__check">✓</span>
<span class="agent-pill__icon" :style="{ color: agentIconColor(agentIcon(m.agentId, m.icon)) }">
<SkillIcon :value="agentIcon(m.agentId, m.icon)" :size="14" />
</span>
{{ m.name }}
</button>
</div>
</div>
<div v-if="blockerCandidates.length > 0" class="form-group">
<label>{{ t('teams.blockedBy') }}</label>
<div class="agent-picker">
<button
v-for="vo in blockerCandidates"
:key="vo.task.id"
class="agent-pill"
:class="{ 'is-selected': taskForm.blockedBy.includes(String(vo.task.id)) }"
@click="toggleBlocker(String(vo.task.id))"
>
<span v-if="taskForm.blockedBy.includes(String(vo.task.id))" class="agent-pill__check">✓</span>
#{{ vo.task.taskNumber }} {{ vo.task.subject }}
</button>
</div>
<p class="form-hint">{{ t('teams.blockedByHint') }}</p>
</div>
<div class="form-group form-group--inline">
<label>{{ t('teams.priority') }}</label>
<input
v-model.number="taskForm.priority"
type="number"
class="form-input form-input--narrow"
min="0"
max="99"
/>
<label class="form-check">
<input v-model="taskForm.requireApproval" type="checkbox" />
{{ t('teams.requireApprovalField') }}
</label>
</div>
</div>
<div class="modal-footer">
<button class="btn-secondary" @click="taskCreateDialogVisible = false">{{ t('common.cancel') }}</button>
<button class="btn-primary" :disabled="taskCreating" @click="submitTaskCreate">
{{ taskCreating ? t('common.processing') : t('common.confirm') }}
</button>
</div>
</div>
</div>
</Teleport>
<!-- ==================== Task detail dialog ==================== -->
<Teleport to="body">
<div v-if="taskDialogVisible && currentTask" class="modal-overlay" @click.self="taskDialogVisible = false">
<div class="modal modal--wide">
<div class="modal-header">
<div class="task-dialog__head">
<span class="task-dialog__title">
#{{ currentTask.task.taskNumber }} {{ currentTask.task.subject }}
</span>
<span class="status-pill" :class="`pill--${currentTask.task.status}`">
{{ statusLabel(currentTask.task.status) }}
</span>
</div>
<button class="modal-close" @click="taskDialogVisible = false">&times;</button>
</div>
<div class="modal-body task-detail">
<div class="task-detail__meta">
<span>{{ t('teams.assignee') }}: {{ currentTask.assigneeName || '—' }}</span>
<span v-if="currentTask.task.progressStep">
{{ currentTask.task.progressPercent }}% — {{ currentTask.task.progressStep }}
</span>
</div>
<div v-if="currentTask.task.description" class="task-detail__block">
<div class="task-detail__label">{{ t('teams.taskDescription') }}</div>
<div class="task-detail__text">{{ currentTask.task.description }}</div>
</div>
<div v-if="currentTask.task.result" class="task-detail__block">
<div class="task-detail__label">{{ t('teams.result') }}</div>
<div class="task-detail__text task-detail__text--boxed">{{ currentTask.task.result }}</div>
</div>
<div v-if="currentTask.task.reason" class="task-detail__reason">
{{ currentTask.task.reason }}
</div>
<div v-if="currentDeliverables.length > 0" class="task-detail__block">
<div class="task-detail__label">{{ t('teams.deliverables') }}</div>
<div class="deliverable-list">
<a
v-for="(file, idx) in currentDeliverables"
:key="idx"
class="deliverable-row"
:href="file.url"
target="_blank"
rel="noopener"
>
<span class="deliverable-row__icon">📄</span>
<span class="deliverable-row__name">{{ file.name }}</span>
</a>
</div>
</div>
<div v-if="taskEvents.length > 0" class="task-detail__block">
<div class="task-detail__label">{{ t('teams.timeline') }}</div>
<div class="timeline">
<div v-for="ev in taskEvents" :key="ev.id" class="timeline-row">
<span class="timeline-row__dot" :class="`dot-ev--${ev.eventType}`"></span>
<span class="timeline-row__time">{{ (ev.createTime || '').slice(5, 16) }}</span>
<span class="timeline-row__type">{{ t(`teams.eventType.${ev.eventType}`, ev.eventType) }}</span>
<span v-if="ev.actorType === 'agent'" class="timeline-row__actor">
{{ agentStore.agents.find(a => String(a.id) === String(ev.actorId))?.name || ev.actorId }}
</span>
<span v-else-if="ev.actorId" class="timeline-row__actor">{{ ev.actorId }}</span>
<span v-if="ev.detail" class="timeline-row__detail">{{ ev.detail }}</span>
</div>
</div>
</div>
<div class="task-detail__block">
<div class="task-detail__label">{{ t('teams.comments') }}</div>
<div v-if="comments.length === 0" class="task-detail__muted">—</div>
<div
v-for="c in comments"
:key="c.id"
class="comment-row"
:class="{ 'is-blocker': c.commentType === 'blocker' }"
>
<span class="comment-row__author">[{{ c.authorType }} {{ c.authorId }}]</span>
<span>{{ c.content }}</span>
</div>
<div class="comment-input">
<input
v-model.trim="newComment"
class="form-input"
:placeholder="t('teams.commentPlaceholder')"
@keyup.enter="submitComment"
/>
<button class="btn-secondary" @click="submitComment">{{ t('common.confirm') }}</button>
</div>
</div>
</div>
<div class="modal-footer">
<button
v-if="currentTask.task.conversationId"
class="btn-secondary modal-footer__left"
@click="openTaskRun"
>{{ t('teams.viewRun') }}</button>
<button
v-if="currentTask.task.status === 'in_review'"
class="btn-success"
@click="approveTask"
>{{ t('teams.approve') }}</button>
<button
v-if="currentTask.task.status === 'in_review'"
class="btn-danger"
@click="rejectTask"
>{{ t('teams.reject') }}</button>
<button
v-if="['failed', 'stale'].includes(currentTask.task.status)"
class="btn-primary"
@click="retryTask"
>{{ t('teams.retry') }}</button>
<button
v-if="['pending', 'blocked', 'in_progress'].includes(currentTask.task.status)"
class="btn-danger"
@click="cancelTask"
>{{ t('common.cancel') }}</button>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { teamApi } from '@/api/index'
import type { TeamMemberVO, TeamTaskComment, TeamTaskDeliverable, TeamTaskEvent, TeamTaskVO } from '@/api/index'
import { subscribeTeamEvents } from '@/composables/useTeamEvents'
import SkillIcon from '@/components/common/SkillIcon.vue'
import { agentIconColor } from '@/utils/agentIconColor'
import { useAgentStore } from '@/stores/useAgentStore'
import { useTeamStore } from '@/stores/useTeamStore'
const { t } = useI18n()
const router = useRouter()
const store = useTeamStore()
const agentStore = useAgentStore()
const activeTab = ref('board')
// ==================== board columns ====================
const COLUMN_DEFS = [
{ key: 'todo', statuses: ['pending', 'blocked'], terminal: false },
{ key: 'in_progress', statuses: ['in_progress'], terminal: false },
{ key: 'in_review', statuses: ['in_review'], terminal: false },
{ key: 'completed', statuses: ['completed'], terminal: true },
{ key: 'closed', statuses: ['failed', 'cancelled', 'stale'], terminal: true },
] as const
const boardColumns = computed(() =>
COLUMN_DEFS.map((col) => {
const colTasks = store.tasks.filter((vo) =>
(col.statuses as readonly string[]).includes(vo.task.status),
)
// Terminal columns are windowed: the header shows the true database
// total and the column body ends with a load-more control.
const total = col.key === 'completed' ? store.completedTotal
: col.key === 'closed' ? store.closedTotal
: colTasks.length
const hasMore = col.key === 'completed' ? store.completedHasMore
: col.key === 'closed' ? store.closedHasMore
: false
return {
key: col.key,
label: t(`teams.column.${col.key}`),
tasks: colTasks,
total,
hasMore,
}
}),
)
async function loadMoreColumn(key: string) {
if (!store.currentTeam) return
const teamId = store.currentTeam.team.id
if (key === 'completed') await store.loadMoreCompleted(teamId)
else if (key === 'closed') await store.loadMoreClosed(teamId)
}
function statusLabel(status?: string) {
return status ? t(`teams.status.${status}`, status) : ''
}
/**
* Resolve an agent's icon: prefer the value the teams API returned, fall
* back to the agent store (covers a backend that predates the icon field),
* and finally a neutral pixel icon so no emoji placeholder ever renders.
*/
function agentIcon(agentId?: string | null, apiIcon?: string | null): string {
if (apiIcon) return apiIcon
const agent = agentId
? agentStore.agents.find((a) => String(a.id) === String(agentId))
: undefined
return agent?.icon || 'pi:user'
}
// ==================== live board events ====================
/** Recent activity lines shown above the board; each expires after a few seconds. */
const activityFeed = ref<{ key: number; text: string }[]>([])
let activityKey = 0
let unsubscribeEvents: (() => void) | null = null
let refreshDebounce: ReturnType<typeof setTimeout> | null = null
function onBoardEvent(e: { event: string; data: Record<string, unknown> }) {
if (!e.event.startsWith('team_task_')) return
// Event-driven refresh, debounced so bursts collapse into one fetch.
if (refreshDebounce) clearTimeout(refreshDebounce)
refreshDebounce = setTimeout(() => {
refreshDebounce = null
refreshBoard()
}, 300)
const type = e.event.slice('team_task_'.length)
const subject = String(e.data.subject ?? '')
const taskNumber = e.data.taskNumber != null ? `#${e.data.taskNumber} ` : ''
const key = ++activityKey
activityFeed.value.push({
key,
text: `${taskNumber}${subject} · ${t(`teams.eventType.${type}`, type)}`,
})
if (activityFeed.value.length > 3) activityFeed.value.shift()
setTimeout(() => {
activityFeed.value = activityFeed.value.filter((item) => item.key !== key)
}, 8000)
}
function startEventSubscription(teamId: string) {
stopEventSubscription()
unsubscribeEvents = subscribeTeamEvents(teamId, onBoardEvent)
}
function stopEventSubscription() {
if (unsubscribeEvents) {
unsubscribeEvents()
unsubscribeEvents = null
}
activityFeed.value = []
}
// ==================== polling ====================
let pollTimer: ReturnType<typeof setInterval> | null = null
function startPolling() {
stopPolling()
pollTimer = setInterval(() => {
const team = store.currentTeam
if (team && store.hasActiveTasks) {
store.fetchTasks(team.team.id)
}
}, 3000)
}
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
watch(
() => store.currentTeam,
(team) => {
if (team) {
startPolling()
startEventSubscription(String(team.team.id))
} else {
stopPolling()
stopEventSubscription()
}
},
)
onMounted(() => {
store.fetchTeams()
if (agentStore.agents.length === 0) {
agentStore.fetchAgents()
}
})
onBeforeUnmount(() => {
stopPolling()
stopEventSubscription()
})
// ==================== team actions ====================
async function openTeam(teamId: string) {
try {
await store.openTeam(teamId)
activeTab.value = 'board'
} catch (e: any) {
ElMessage.error(e?.message || 'failed')
}
}
function refreshBoard() {
if (store.currentTeam) {
store.fetchTasks(store.currentTeam.team.id)
}
}
async function removeTeam() {
if (!store.currentTeam) return
try {
await ElMessageBox.confirm(t('teams.deleteConfirm'), { type: 'warning' })
} catch {
return
}
await store.deleteTeam(store.currentTeam.team.id)
ElMessage.success(t('common.success'))
}
// ==================== create team ====================
const createDialogVisible = ref(false)
const creating = ref(false)
const createForm = reactive({
name: '',
description: '',
leadAgentId: '',
memberAgentIds: [] as string[],
})
const memberCandidates = computed(() =>
agentStore.agents.filter((a) => String(a.id) !== createForm.leadAgentId),
)
function openCreateDialog() {
createForm.name = ''
createForm.description = ''
createForm.leadAgentId = ''
createForm.memberAgentIds = []
createDialogVisible.value = true
}
function selectLead(agentId: string) {
createForm.leadAgentId = createForm.leadAgentId === agentId ? '' : agentId
// The lead cannot double as a member.
createForm.memberAgentIds = createForm.memberAgentIds.filter((id) => id !== agentId)
}
function toggleMember(agentId: string) {
const idx = createForm.memberAgentIds.indexOf(agentId)
if (idx >= 0) {
createForm.memberAgentIds.splice(idx, 1)
} else {
createForm.memberAgentIds.push(agentId)
}
}
async function submitCreate() {
if (!createForm.name || !createForm.leadAgentId || createForm.memberAgentIds.length === 0) {
ElMessage.warning(t('teams.createIncomplete'))
return
}
creating.value = true
try {
await store.createTeam({ ...createForm })
createDialogVisible.value = false
ElMessage.success(t('common.success'))
} catch (e: any) {
ElMessage.error(e?.message || 'failed')
} finally {
creating.value = false
}
}
// ==================== create task ====================
const TERMINAL_TASK_STATUSES = ['completed', 'failed', 'cancelled']
const taskCreateDialogVisible = ref(false)
const taskCreating = ref(false)
const taskForm = reactive({
subject: '',
description: '',
assigneeAgentId: '',
priority: 0,
requireApproval: false,
blockedBy: [] as string[],
})
// The lead orchestrates and cannot execute tasks — assignable members only.
const assigneeCandidates = computed(() => store.members.filter((m) => m.role !== 'lead'))
const blockerCandidates = computed(() =>
store.tasks.filter((vo) => !TERMINAL_TASK_STATUSES.includes(vo.task.status)),
)
function openTaskCreateDialog() {
taskForm.subject = ''
taskForm.description = ''
taskForm.assigneeAgentId = ''
taskForm.priority = 0
taskForm.requireApproval = false
taskForm.blockedBy = []
taskCreateDialogVisible.value = true
}
function toggleBlocker(taskId: string) {
const idx = taskForm.blockedBy.indexOf(taskId)
if (idx >= 0) {
taskForm.blockedBy.splice(idx, 1)
} else {
taskForm.blockedBy.push(taskId)
}
}
async function submitTaskCreate() {
if (!store.currentTeam) return
if (!taskForm.subject || !taskForm.assigneeAgentId) {
ElMessage.warning(t('teams.taskCreateIncomplete'))
return
}
taskCreating.value = true
try {
await teamApi.createTask(store.currentTeam.team.id, {
subject: taskForm.subject,
description: taskForm.description || undefined,
assigneeAgentId: taskForm.assigneeAgentId,
priority: taskForm.priority,
requireApproval: taskForm.requireApproval,
blockedBy: taskForm.blockedBy.length ? [...taskForm.blockedBy] : undefined,
})
taskCreateDialogVisible.value = false
ElMessage.success(t('common.success'))
refreshBoard()
} catch (e: any) {
ElMessage.error(e?.message || 'failed')
} finally {
taskCreating.value = false
}
}
// ==================== members ====================
const memberDialogVisible = ref(false)
const memberForm = reactive({ agentId: '', role: 'member' })
const addMemberCandidates = computed(() => {
const existing = new Set(store.members.map((m) => m.agentId))
return agentStore.agents.filter((a) => !existing.has(String(a.id)))
})
async function submitAddMember() {
if (!store.currentTeam || !memberForm.agentId) return
try {
await teamApi.addMember(store.currentTeam.team.id, memberForm.agentId, memberForm.role)
memberDialogVisible.value = false
memberForm.agentId = ''
await store.openTeam(store.currentTeam.team.id)
} catch (e: any) {
ElMessage.error(e?.message || 'failed')
}
}
async function removeMember(row: TeamMemberVO) {
if (!store.currentTeam) return
try {
await teamApi.removeMember(store.currentTeam.team.id, row.agentId)
await store.openTeam(store.currentTeam.team.id)
} catch (e: any) {
ElMessage.error(e?.message || 'failed')
}
}
// ==================== task detail ====================
const taskDialogVisible = ref(false)
const currentTask = ref<TeamTaskVO | null>(null)
const comments = ref<TeamTaskComment[]>([])
const newComment = ref('')
/** Deliverables live under the "deliverables" key of the task's metadata JSON. */
const currentDeliverables = computed<TeamTaskDeliverable[]>(() => {
const raw = currentTask.value?.task.metadata
if (!raw) return []
try {
const parsed = JSON.parse(raw)
return Array.isArray(parsed?.deliverables) ? parsed.deliverables : []
} catch {
return []
}
})
/** Open the member's execution transcript (its child conversation) in the chat console. */
function openTaskRun() {
const task = currentTask.value?.task
if (!task?.conversationId) return
router.push({
path: '/chat',
query: { agentId: task.assigneeAgentId ?? undefined, conversationId: task.conversationId },
})
}
const taskEvents = ref<TeamTaskEvent[]>([])
async function openTask(vo: TeamTaskVO) {
if (!store.currentTeam) return
try {
const res: any = await teamApi.getTask(store.currentTeam.team.id, vo.task.id)
currentTask.value = res.data?.task || vo
comments.value = res.data?.comments || []
taskDialogVisible.value = true
// Timeline loads after the dialog opens; a failure just leaves it empty.
taskEvents.value = []
teamApi.listTaskEvents(store.currentTeam.team.id, vo.task.id)
.then((eventsRes: any) => {
taskEvents.value = eventsRes.data || []
})
.catch(() => {})
} catch (e: any) {
ElMessage.error(e?.message || 'failed')
}
}
async function reloadTask() {
if (currentTask.value) {
const vo = currentTask.value
await openTask(vo)
refreshBoard()
}
}
async function submitComment() {
if (!store.currentTeam || !currentTask.value || !newComment.value) return
await teamApi.commentTask(store.currentTeam.team.id, currentTask.value.task.id, newComment.value)
newComment.value = ''
await reloadTask()
}
async function approveTask() {
if (!store.currentTeam || !currentTask.value) return
await teamApi.approveTask(store.currentTeam.team.id, currentTask.value.task.id)
ElMessage.success(t('teams.approved'))
await reloadTask()
}
async function rejectTask() {
if (!store.currentTeam || !currentTask.value) return
let reason = ''
try {
const input = await ElMessageBox.prompt(t('teams.rejectReason'), { type: 'warning' })
reason = input.value || ''
} catch {
return
}
await teamApi.rejectTask(store.currentTeam.team.id, currentTask.value.task.id, reason)
await reloadTask()
}
async function retryTask() {
if (!store.currentTeam || !currentTask.value) return
await teamApi.retryTask(store.currentTeam.team.id, currentTask.value.task.id)
await reloadTask()
}
async function cancelTask() {
if (!store.currentTeam || !currentTask.value) return
try {
await ElMessageBox.confirm(t('teams.cancelConfirm'), { type: 'warning' })
} catch {
return
}
await teamApi.cancelTask(store.currentTeam.team.id, currentTask.value.task.id)
await reloadTask()
}
</script>
<style scoped>
.header-right {
display: flex;
align-items: center;
gap: 10px;
}
.btn-primary {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 9px 18px;
background: var(--mc-primary);
color: #fff;
border: none;
border-radius: 12px;
font-size: 14px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
transition: filter 0.15s;
}
.btn-primary:hover {
filter: brightness(1.06);
}
.btn-secondary {
padding: 8px 16px;
background: var(--mc-bg-elevated);
color: var(--mc-text-primary);
border: 1px solid var(--mc-border);
border-radius: 12px;
font-size: 14px;
font-family: inherit;
cursor: pointer;
transition: background 0.15s;
}
.btn-secondary:hover {
background: var(--mc-bg-sunken);
}
.btn-danger {
padding: 8px 16px;
background: transparent;
color: var(--mc-danger, #d9534f);
border: 1px solid var(--mc-danger, #d9534f);
border-radius: 12px;
font-size: 14px;
font-family: inherit;
cursor: pointer;
transition: all 0.15s;
}
.btn-danger:hover {
background: var(--mc-danger, #d9534f);
color: #fff;
}
/* ==================== empty state ==================== */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
padding: 56px 20px;
color: var(--mc-text-secondary);
font-size: 14px;
}
.empty-state__icon {
display: flex;
align-items: center;
justify-content: center;
width: 64px;
height: 64px;
border-radius: 20px;
background: var(--mc-bg-muted);
border: 1px solid var(--mc-border-light);
color: var(--mc-text-tertiary);
}
/* ==================== team cards ==================== */
.team-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px;
}
.team-card {
display: flex;
flex-direction: column;
gap: 10px;
padding: 20px;
cursor: pointer;
transition: all 0.15s;
}
.team-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
}
.team-card__top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.team-card__name {
margin: 0;
font-size: 16px;
font-weight: 700;
color: var(--mc-text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.team-card__desc {
margin: 0;
font-size: 13px;
line-height: 1.6;
color: var(--mc-text-secondary);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
min-height: 2.2em;
}
.team-card__foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding-top: 10px;
border-top: 1px solid var(--mc-border-light);
}
.team-card__count {
font-size: 12.5px;
color: var(--mc-text-tertiary);
}
.lead-chip {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 10px;
border-radius: 999px;
background: var(--mc-bg-muted);
border: 1px solid var(--mc-border-light);
font-size: 12.5px;
font-weight: 600;
color: var(--mc-text-primary);
}
.lead-chip__icon,
.agent-pill__icon {
display: inline-flex;
align-items: center;
line-height: 1;
}
.status-pill {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
border: 1px solid var(--mc-border-light);
background: var(--mc-bg-muted);
color: var(--mc-text-secondary);
}
.status-pill__dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
.status-pill.is-active {
color: #16a34a;
background: rgba(22, 163, 74, 0.08);
border-color: rgba(22, 163, 74, 0.25);
}
.status-pill.is-paused {
color: var(--mc-text-tertiary);
}
.pill--completed { color: #16a34a; background: rgba(22, 163, 74, 0.08); border-color: rgba(22, 163, 74, 0.25); }
.pill--in_progress { color: var(--mc-primary); background: var(--mc-primary-bg); border-color: var(--mc-primary); }
.pill--in_review { color: #d97706; background: rgba(217, 119, 6, 0.08); border-color: rgba(217, 119, 6, 0.3); }
.pill--failed, .pill--cancelled { color: #dc2626; background: rgba(220, 38, 38, 0.07); border-color: rgba(220, 38, 38, 0.25); }
/* ==================== detail header ==================== */
.detail-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
flex-wrap: wrap;
margin-bottom: 22px;
}
.detail-header__left {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.detail-header__title {
margin: 0;
font-size: 22px;
font-weight: 800;
letter-spacing: -0.02em;
color: var(--mc-text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.detail-header__right {
display: flex;
align-items: center;
gap: 10px;
}
/* Segmented switch — same pattern as the employees page view switch. */
.view-switch {
display: inline-flex;
background: var(--mc-bg-sunken);
border: 1px solid var(--mc-border-light);
border-radius: 999px;
padding: 4px;
gap: 2px;
}
.view-seg {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 18px;
border-radius: 999px;
border: none;
background: transparent;
color: var(--mc-text-secondary);
font-size: 13.5px;
font-weight: 500;
font-family: inherit;
cursor: pointer;
transition: background 0.2s ease, color 0.2s ease, box-shadow 0.2s ease;
}
.view-seg:hover {
color: var(--mc-text-primary);
}
.view-seg.is-active {
background: var(--mc-bg-elevated);
color: var(--mc-text-primary);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
}
/* ==================== kanban board ==================== */
.board-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 14px;
}
@media (max-width: 1280px) {
.board-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 768px) {
.board-grid {
grid-template-columns: 1fr;
}
}
.board-col {
display: flex;
flex-direction: column;
border: 1px solid var(--mc-border-light);
border-radius: 18px;
background: var(--mc-bg-muted);
min-height: 260px;
}
.board-col__head {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 14px;
border-bottom: 1px solid var(--mc-border-light);
}
.board-col__dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.dot--todo { background: var(--mc-text-tertiary); }
.dot--in_progress { background: var(--mc-primary); }
.dot--in_review { background: #d97706; }
.dot--completed { background: #16a34a; }
.dot--closed { background: #dc2626; }
.board-col__label {
font-size: 13px;
font-weight: 700;
color: var(--mc-text-primary);
flex: 1;
}
.board-col__count {
min-width: 22px;
text-align: center;
padding: 1px 7px;
border-radius: 999px;
background: var(--mc-bg-elevated);
border: 1px solid var(--mc-border-light);
font-size: 12px;
font-weight: 600;
color: var(--mc-text-secondary);
}
.board-col__body {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px;
overflow-y: auto;
}
.task-card {
padding: 12px;
border-radius: 14px;
background: var(--mc-bg-elevated);
border: 1px solid var(--mc-border-light);
cursor: pointer;
transition: all 0.15s;
}
.task-card:hover {
transform: translateY(-1px);
border-color: var(--mc-primary);
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.07);
}
.task-card__num {
font-size: 11px;
font-weight: 600;
color: var(--mc-text-tertiary);
}
.task-card__subject {
margin-top: 2px;
font-size: 13.5px;
font-weight: 600;
line-height: 1.45;
color: var(--mc-text-primary);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.task-card__meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: 8px;
}
.task-card__lock {
font-size: 12px;
}
.assignee-chip {
display: inline-flex;
align-items: center;
padding: 2px 9px;
border-radius: 999px;
background: var(--mc-bg-muted);
border: 1px solid var(--mc-border-light);
font-size: 11.5px;
font-weight: 600;
color: var(--mc-text-secondary);
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-card__progress {
margin-top: 8px;
height: 4px;
border-radius: 999px;
background: var(--mc-bg-sunken);
overflow: hidden;
}
.task-card__progress-bar {
height: 100%;
border-radius: inherit;
background: var(--mc-primary);
transition: width 0.4s ease;
}
/* ==================== members panel ==================== */
.members-panel {
padding: 18px;
}
.members-panel__toolbar {
margin-bottom: 14px;
}
.member-list {
display: flex;
flex-direction: column;
}
.member-row {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 6px;
border-bottom: 1px solid var(--mc-border-light);
}
.member-row:last-child {
border-bottom: none;
}
.member-row__avatar {
display: flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 12px;
background: var(--mc-bg-muted);
border: 1px solid var(--mc-border-light);
font-size: 14px;
font-weight: 700;
color: var(--mc-text-primary);
flex-shrink: 0;
}
.member-row__name {
flex: 1;
font-size: 14px;
font-weight: 600;
color: var(--mc-text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.role-chip {
padding: 3px 10px;
border-radius: 999px;
background: var(--mc-bg-muted);
border: 1px solid var(--mc-border-light);
font-size: 12px;
font-weight: 600;
color: var(--mc-text-secondary);
}
.role-chip.is-lead {
color: #d97706;
background: rgba(217, 119, 6, 0.08);
border-color: rgba(217, 119, 6, 0.3);
}
.member-row__remove {
border: none;
background: transparent;
color: var(--mc-danger, #d9534f);
font-size: 13px;
font-family: inherit;
cursor: pointer;
padding: 4px 8px;
border-radius: 8px;
}
.member-row__remove:hover {
background: rgba(220, 38, 38, 0.07);
}
/* ==================== task detail dialog ==================== */
.task-dialog__head {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.task-dialog__title {
font-weight: 700;
color: var(--mc-text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-detail {
display: flex;
flex-direction: column;
gap: 14px;
font-size: 13.5px;
}
.task-detail__meta {
display: flex;
gap: 18px;
color: var(--mc-text-secondary);
}
.task-detail__label {
font-weight: 700;
margin-bottom: 4px;
color: var(--mc-text-primary);
}
.task-detail__text {
white-space: pre-wrap;
color: var(--mc-text-secondary);
line-height: 1.65;
}
.task-detail__text--boxed {
max-height: 240px;
overflow: auto;
border-radius: 12px;
background: var(--mc-bg-muted);
border: 1px solid var(--mc-border-light);
padding: 10px 12px;
}
.task-detail__reason {
border-radius: 12px;
border: 1px solid rgba(217, 119, 6, 0.3);
background: rgba(217, 119, 6, 0.07);
color: #b45309;
padding: 10px 12px;
}
.task-detail__muted {
color: var(--mc-text-tertiary);
}
.comment-row {
border-left: 2px solid var(--mc-border);
padding-left: 10px;
margin-bottom: 8px;
color: var(--mc-text-secondary);
line-height: 1.6;
}
.comment-row.is-blocker {
border-left-color: #dc2626;
}
.comment-row__author {
font-size: 12px;
color: var(--mc-text-tertiary);
margin-right: 6px;
}
.comment-input {
display: flex;
gap: 8px;
margin-top: 10px;
}
.comment-input .form-input {
flex: 1;
}
/* ==================== custom modal ==================== */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(124, 63, 30, 0.45);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
z-index: 1000;
animation: team-fade-in 0.15s ease;
}
.modal {
width: 420px;
max-width: 100%;
max-height: calc(100vh - 48px);
display: flex;
flex-direction: column;
background: var(--mc-bg-elevated);
border: 1px solid var(--mc-border);
border-radius: 16px;
box-shadow: 0 16px 48px rgba(25, 14, 8, 0.18);
overflow: hidden;
animation: team-slide-up 0.2s ease;
}
.modal--wide {
width: 620px;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 20px;
border-bottom: 1px solid var(--mc-border-light);
}
.modal-header h3 {
font-size: 17px;
font-weight: 600;
color: var(--mc-text-primary);
margin: 0;
}
.modal-close {
width: 28px;
height: 28px;
border: none;
background: none;
color: var(--mc-text-tertiary);
font-size: 20px;
line-height: 1;
cursor: pointer;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
flex-shrink: 0;
transition: background 0.15s;
}
.modal-close:hover {
background: var(--mc-bg-muted);
color: var(--mc-text-primary);
}
.modal-body {
padding: 20px;
display: flex;
flex-direction: column;
gap: 16px;
overflow-y: auto;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 14px 20px;
border-top: 1px solid var(--mc-border-light);
}
@keyframes team-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes team-slide-up {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
/* ==================== form controls ==================== */
.form-group {
display: flex;
flex-direction: column;
gap: 7px;
}
.form-group label {
font-size: 13px;
font-weight: 600;
color: var(--mc-text-secondary);
}
.form-group label i {
color: var(--mc-danger, #d9534f);
font-style: normal;
}
.form-input {
width: 100%;
padding: 9px 12px;
border: 1px solid var(--mc-border);
border-radius: 10px;
background: var(--mc-bg-sunken);
color: var(--mc-text-primary);
font-size: 14px;
font-family: inherit;
transition: border-color 0.15s, box-shadow 0.15s;
box-sizing: border-box;
}
.form-input:focus {
outline: none;
border-color: var(--mc-primary);
box-shadow: 0 0 0 3px rgba(217, 119, 87, 0.12);
}
.form-textarea {
resize: vertical;
min-height: 56px;
line-height: 1.5;
}
.form-hint {
margin: 0;
font-size: 12px;
color: var(--mc-text-tertiary);
}
.activity-feed {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 10px;
}
.activity-line {
font-size: 12px;
color: var(--mc-text-secondary);
background: var(--mc-bg-sunken);
border: 1px solid var(--mc-border);
border-radius: 8px;
padding: 5px 10px;
}
.activity-enter-active,
.activity-leave-active {
transition: opacity 0.3s, transform 0.3s;
}
.activity-enter-from,
.activity-leave-to {
opacity: 0;
transform: translateY(-4px);
}
.timeline {
display: flex;
flex-direction: column;
gap: 6px;
}
.timeline-row {
display: flex;
align-items: baseline;
gap: 8px;
font-size: 12px;
color: var(--mc-text-secondary);
}
.timeline-row__dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--mc-border);
flex-shrink: 0;
align-self: center;
}
.dot-ev--completed,
.dot-ev--approved { background: var(--mc-success, #67c23a); }
.dot-ev--failed,
.dot-ev--blocker,
.dot-ev--cancelled,
.dot-ev--rejected { background: var(--mc-danger, #d9534f); }
.dot-ev--dispatched,
.dot-ev--progress { background: var(--mc-primary); }
.timeline-row__time {
font-variant-numeric: tabular-nums;
color: var(--mc-text-tertiary);
flex-shrink: 0;
}
.timeline-row__type {
font-weight: 600;
color: var(--mc-text-primary);
flex-shrink: 0;
}
.timeline-row__actor {
color: var(--mc-text-secondary);
flex-shrink: 0;
}
.timeline-row__detail {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.board-col__more {
width: 100%;
padding: 8px 0;
margin-top: 2px;
border: 1px dashed var(--mc-border);
border-radius: 10px;
background: transparent;
color: var(--mc-text-secondary);
font-size: 12px;
cursor: pointer;
transition: border-color 0.15s, color 0.15s;
}
.board-col__more:hover {
border-color: var(--mc-primary);
color: var(--mc-primary);
}
.deliverable-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.deliverable-row {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border: 1px solid var(--mc-border);
border-radius: 10px;
background: var(--mc-bg-sunken);
color: var(--mc-text-primary);
font-size: 13px;
text-decoration: none;
transition: border-color 0.15s;
}
.deliverable-row:hover {
border-color: var(--mc-primary);
}
.deliverable-row__name {
font-weight: 600;
}
.modal-footer__left {
margin-right: auto;
}
.form-group--inline {
flex-direction: row;
align-items: center;
gap: 12px;
}
.form-input--narrow {
width: 90px;
}
.form-check {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--mc-text-secondary);
cursor: pointer;
}
/* ==================== agent chip picker ==================== */
.agent-picker {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 10px;
border: 1px solid var(--mc-border-light);
border-radius: 12px;
background: var(--mc-bg-muted);
max-height: 180px;
overflow-y: auto;
}
.agent-pill {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 6px 14px;
border-radius: 999px;
border: 1px solid var(--mc-border);
background: var(--mc-bg-elevated);
color: var(--mc-text-secondary);
font-size: 13px;
font-weight: 500;
font-family: inherit;
cursor: pointer;
transition: all 0.15s;
}
.agent-pill:hover {
border-color: var(--mc-primary);
color: var(--mc-text-primary);
}
.agent-pill.is-selected {
background: var(--mc-primary-bg);
border-color: var(--mc-primary);
color: var(--mc-primary);
font-weight: 600;
}
.agent-pill.is-lead {
background: rgba(217, 119, 6, 0.1);
border-color: rgba(217, 119, 6, 0.45);
color: #b45309;
}
.agent-pill__check {
font-size: 11px;
font-weight: 700;
}
.btn-success {
padding: 8px 16px;
background: #16a34a;
color: #fff;
border: none;
border-radius: 12px;
font-size: 14px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
transition: filter 0.15s;
}
.btn-success:hover {
filter: brightness(1.06);
}
.btn-primary:disabled {
opacity: 0.55;
cursor: not-allowed;
}
</style>