diff --git a/internal/gateway/methods/teams_crud.go b/internal/gateway/methods/teams_crud.go
index 392a882a..d29f021a 100644
--- a/internal/gateway/methods/teams_crud.go
+++ b/internal/gateway/methods/teams_crud.go
@@ -260,8 +260,10 @@ func (m *TeamsMethods) handleTaskActiveBySession(ctx context.Context, client *ga
// --- Update (settings) ---
type teamsUpdateParams struct {
- TeamID string `json:"teamId"`
- Settings map[string]any `json:"settings"`
+ TeamID string `json:"teamId"`
+ Name string `json:"name,omitempty"`
+ Description *string `json:"description,omitempty"`
+ Settings map[string]any `json:"settings"`
}
func (m *TeamsMethods) handleUpdate(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
@@ -333,6 +335,12 @@ func (m *TeamsMethods) handleUpdate(ctx context.Context, client *gateway.Client,
cleaned, _ := json.Marshal(access)
updates := map[string]any{"settings": json.RawMessage(cleaned)}
+ if params.Name != "" {
+ updates["name"] = params.Name
+ }
+ if params.Description != nil {
+ updates["description"] = *params.Description
+ }
if err := m.teamStore.UpdateTeam(ctx, teamID, updates); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToUpdate, "team", err.Error())))
return
diff --git a/ui/desktop/frontend/src/components/chat/ChatCanvas.tsx b/ui/desktop/frontend/src/components/chat/ChatCanvas.tsx
index ba4e896d..9f57f47b 100644
--- a/ui/desktop/frontend/src/components/chat/ChatCanvas.tsx
+++ b/ui/desktop/frontend/src/components/chat/ChatCanvas.tsx
@@ -1,22 +1,27 @@
-import { useEffect, useRef, useCallback, useMemo } from 'react'
+import { useEffect, useRef, useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useChat } from '../../hooks/use-chat'
import { useAgents } from '../../hooks/use-agents'
+import { useTeamTasks } from '../../hooks/use-team-tasks'
import { useSessionStore } from '../../stores/session-store'
import { ChatTopBar } from './ChatTopBar'
import { MessageBubble } from './MessageBubble'
import { ActivityIndicator } from './ActivityIndicator'
import { InputBar, type AttachedFile } from './InputBar'
import { TaskPanel } from './TaskPanel'
+import { TaskDetailModal } from '../teams/TaskDetailModal'
+import type { TeamTaskData } from '../../types/team'
export function ChatCanvas() {
const { t } = useTranslation('common')
const { messages, isRunning, activity, sendMessage } = useChat()
const { selectedAgent } = useAgents()
+ const { members, fetchTaskDetail } = useTeamTasks()
const activeSessionKey = useSessionStore((s) => s.activeSessionKey)
const messagesEndRef = useRef(null)
const scrollAreaRef = useRef(null)
const userScrolledUp = useRef(false)
+ const [selectedTask, setSelectedTask] = useState(null)
// Find last assistant message ID for streaming cursor
const lastAssistantId = useMemo(() => {
@@ -85,7 +90,7 @@ export function ChatCanvas() {
{/* Team task panel */}
-
+
{/* Input bar */}
+
+ {/* Task detail modal (from TaskPanel click) */}
+ {selectedTask && (
+ setSelectedTask(null)}
+ onAssign={async () => {}}
+ onDelete={async () => {}}
+ onFetchDetail={fetchTaskDetail}
+ />
+ )}
)
}
diff --git a/ui/desktop/frontend/src/components/common/Icons.tsx b/ui/desktop/frontend/src/components/common/Icons.tsx
new file mode 100644
index 00000000..8f716be2
--- /dev/null
+++ b/ui/desktop/frontend/src/components/common/Icons.tsx
@@ -0,0 +1,116 @@
+/**
+ * Shared SVG icon components — replaces inline SVGs across the desktop UI.
+ * All icons use currentColor and accept className for sizing/color overrides.
+ */
+
+interface IconProps {
+ size?: number
+ className?: string
+}
+
+function svg(size: number, className: string | undefined, children: React.ReactNode, extra?: Record) {
+ return (
+
+ {children}
+
+ )
+}
+
+export function IconClose({ size = 18, className }: IconProps) {
+ return svg(size, className, <>
+
+
+ >)
+}
+
+export function IconChevronDown({ size = 14, className }: IconProps) {
+ return svg(size, className, )
+}
+
+export function IconChevronLeft({ size = 16, className }: IconProps) {
+ return svg(size, className, )
+}
+
+export function IconPlus({ size = 14, className }: IconProps) {
+ return svg(size, className, <>
+
+
+ >, { strokeWidth: 2.5 })
+}
+
+export function IconGear({ size = 14, className }: IconProps) {
+ return svg(size, className, <>
+
+
+ >)
+}
+
+export function IconTrash({ size = 14, className }: IconProps) {
+ return svg(size, className, <>
+
+
+ >)
+}
+
+export function IconDocument({ size = 16, className }: IconProps) {
+ return svg(size, className, <>
+
+
+
+
+
+ >)
+}
+
+export function IconCheckCircle({ size = 16, className }: IconProps) {
+ return svg(size, className, <>
+
+
+ >)
+}
+
+export function IconCheck({ size = 14, className }: IconProps) {
+ return svg(size, className, )
+}
+
+export function IconBlocked({ size = 14, className }: IconProps) {
+ return svg(size, className, <>
+
+
+ >)
+}
+
+export function IconChat({ size = 20, className }: IconProps) {
+ return svg(size, className, )
+}
+
+export function IconUser({ size = 16, className }: IconProps) {
+ return svg(size, className, <>
+
+
+ >)
+}
+
+export function IconPaperclip({ size = 10, className }: IconProps) {
+ return svg(size, className, )
+}
+
+export function IconSpinner({ size = 14, className }: IconProps) {
+ return (
+
+ )
+}
diff --git a/ui/desktop/frontend/src/components/teams/KanbanCard.tsx b/ui/desktop/frontend/src/components/teams/KanbanCard.tsx
index f004d4b9..81052dc8 100644
--- a/ui/desktop/frontend/src/components/teams/KanbanCard.tsx
+++ b/ui/desktop/frontend/src/components/teams/KanbanCard.tsx
@@ -1,6 +1,7 @@
import { motion } from 'framer-motion'
+import { IconBlocked, IconChat, IconPaperclip } from '../common/Icons'
+import { isTaskLocked, TERMINAL_STATUSES } from '../../types/team'
import type { TeamTaskData } from '../../types/team'
-import { isTaskLocked } from '../../types/team'
/** Priority: plain text color, matching web kanban-card.tsx */
const PRIORITY_STYLE: Record = {
@@ -10,7 +11,6 @@ const PRIORITY_STYLE: Record = {
3: { label: 'P-3', color: 'text-red-500' },
}
-const TERMINAL = new Set(['completed', 'failed', 'cancelled'])
interface KanbanCardProps {
task: TeamTaskData
@@ -24,7 +24,7 @@ export function KanbanCard({ task, ownerName, ownerEmoji, onClick }: KanbanCardP
const blocked = task.status === 'blocked'
const prio = PRIORITY_STYLE[task.priority] ?? PRIORITY_STYLE[0]
const hasBlockers = task.blocked_by && task.blocked_by.length > 0
- const isTerminal = TERMINAL.has(task.status)
+ const isTerminal = TERMINAL_STATUSES.has(task.status)
return (
-
-
-
+
{task.blocked_by!.map((id) => id.slice(0, 8)).join(', ')}
)}
- {/* Bottom: owner */}
+ {/* Bottom: owner + counts */}
{ownerEmoji && {ownerEmoji} }
-
+
{ownerName || task.owner_agent_key || 'Unassigned'}
+ {(task.comment_count ?? 0) > 0 && (
+
+
+ {task.comment_count}
+
+ )}
+ {(task.attachment_count ?? 0) > 0 && (
+
+
+ {task.attachment_count}
+
+ )}
{/* Progress bar */}
diff --git a/ui/desktop/frontend/src/components/teams/TaskCreateDialog.tsx b/ui/desktop/frontend/src/components/teams/TaskCreateDialog.tsx
index 3a508d4c..fb47420d 100644
--- a/ui/desktop/frontend/src/components/teams/TaskCreateDialog.tsx
+++ b/ui/desktop/frontend/src/components/teams/TaskCreateDialog.tsx
@@ -1,6 +1,7 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Combobox } from '../common/Combobox'
+import { IconClose } from '../common/Icons'
import type { TeamMemberData } from '../../types/team'
interface TaskCreateDialogProps {
@@ -56,9 +57,7 @@ export function TaskCreateDialog({ teamId, members, onClose, onCreate }: TaskCre
{t('createTask', 'Create Task')}
-
-
-
+
diff --git a/ui/desktop/frontend/src/components/teams/TaskDetailModal.tsx b/ui/desktop/frontend/src/components/teams/TaskDetailModal.tsx
index 23f4751b..1271f062 100644
--- a/ui/desktop/frontend/src/components/teams/TaskDetailModal.tsx
+++ b/ui/desktop/frontend/src/components/teams/TaskDetailModal.tsx
@@ -1,10 +1,12 @@
-import { useState } from 'react'
+import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { MarkdownRenderer } from '../chat/MarkdownRenderer'
import { Combobox } from '../common/Combobox'
import { ConfirmDialog } from '../common/ConfirmDialog'
-import { STATUS_BADGE, PRIORITY_BADGE, isTaskLocked } from '../../types/team'
-import type { TeamTaskData, TeamMemberData, TaskStatus } from '../../types/team'
+import { IconClose, IconChevronDown, IconDocument, IconCheckCircle, IconTrash } from '../common/Icons'
+import { getApiClient } from '../../lib/api'
+import { STATUS_BADGE, PRIORITY_BADGE, isTaskLocked, TERMINAL_STATUSES } from '../../types/team'
+import type { TeamTaskData, TeamMemberData, TeamTaskAttachment } from '../../types/team'
interface TaskDetailModalProps {
task: TeamTaskData
@@ -12,10 +14,9 @@ interface TaskDetailModalProps {
onClose: () => void
onAssign: (taskId: string, agentKey: string) => Promise
onDelete: (taskId: string) => Promise
+ onFetchDetail?: (teamId: string, taskId: string) => Promise<{ task: TeamTaskData; attachments: TeamTaskAttachment[] } | null>
}
-const TERMINAL: Set = new Set(['completed', 'failed', 'cancelled'])
-
/** Metadata label + value pair */
function MetaItem({ label, children }: { label: string; children: React.ReactNode }) {
return (
@@ -40,9 +41,7 @@ function CollapsibleSection({ title, icon, defaultOpen = true, children }: {
>
{icon}
{title}
-
-
-
+
{open && (
@@ -53,14 +52,30 @@ function CollapsibleSection({ title, icon, defaultOpen = true, children }: {
)
}
-export function TaskDetailModal({ task, members, onClose, onAssign, onDelete }: TaskDetailModalProps) {
+/** Format bytes to human-readable size */
+function formatFileSize(bytes: number): string {
+ if (bytes < 1024) return `${bytes} B`
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
+}
+
+export function TaskDetailModal({ task, members, onClose, onAssign, onDelete, onFetchDetail }: TaskDetailModalProps) {
const { t } = useTranslation('teams')
const [confirmDelete, setConfirmDelete] = useState(false)
+ const [attachments, setAttachments] = useState([])
+
+ // Fetch full detail (with attachments) on mount
+ useEffect(() => {
+ if (!onFetchDetail) return
+ onFetchDetail(task.team_id, task.id).then((res) => {
+ if (res) setAttachments(res.attachments)
+ })
+ }, [task.id, task.team_id, onFetchDetail])
const prio = PRIORITY_BADGE[task.priority] ?? PRIORITY_BADGE[3]
const statusCls = STATUS_BADGE[task.status] ?? ''
const locked = isTaskLocked(task)
- const isTerminal = TERMINAL.has(task.status)
+ const isTerminal = TERMINAL_STATUSES.has(task.status)
const member = task.owner_agent_id ? members.find((m) => m.agent_id === task.owner_agent_id) : undefined
const memberOptions = members.map((m) => ({
@@ -105,9 +120,7 @@ export function TaskDetailModal({ task, members, onClose, onAssign, onDelete }:
{task.subject}
-
-
-
+
@@ -182,7 +195,7 @@ export function TaskDetailModal({ task, members, onClose, onAssign, onDelete }:
{task.description && (
}
+ icon={ }
>
@@ -194,13 +207,52 @@ export function TaskDetailModal({ task, members, onClose, onAssign, onDelete }:
{task.result && (
}
+ icon={ }
>
)}
+
+ {/* Attachments section */}
+ {attachments.length > 0 && (
+
}
+ >
+
+ {attachments.map((a) => {
+ const fileName = a.path?.split('/').pop() || 'file'
+ const baseUrl = getApiClient()?.getBaseUrl() || ''
+ const fullUrl = a.download_url?.startsWith('http') ? a.download_url : `${baseUrl}${a.download_url}`
+ return (
+
+ )
+ })}
+
+
+ )}
{/* ── Footer ── */}
@@ -221,9 +273,7 @@ export function TaskDetailModal({ task, members, onClose, onAssign, onDelete }:
onClick={() => setConfirmDelete(true)}
className="flex items-center gap-1.5 text-sm text-error hover:text-error/80 px-4 py-2 rounded-lg border border-error/30 hover:bg-error/10 transition-colors cursor-pointer"
>
-
-
-
+
{t('delete', 'Delete')}
)}
diff --git a/ui/desktop/frontend/src/components/teams/TeamBoard.tsx b/ui/desktop/frontend/src/components/teams/TeamBoard.tsx
index 5066a874..c9014ad0 100644
--- a/ui/desktop/frontend/src/components/teams/TeamBoard.tsx
+++ b/ui/desktop/frontend/src/components/teams/TeamBoard.tsx
@@ -3,10 +3,12 @@ import { useTranslation } from 'react-i18next'
import { useUiStore } from '../../stores/ui-store'
import { useTeamTasks } from '../../hooks/use-team-tasks'
import { RefreshButton } from '../common/RefreshButton'
+import { IconChevronLeft, IconGear, IconChevronDown, IconPlus, IconChat } from '../common/Icons'
import { KanbanColumn } from './KanbanColumn'
import { TaskDetailModal } from './TaskDetailModal'
-import { KANBAN_STATUSES, groupByStatus } from '../../types/team'
-import type { TeamTaskData, TaskStatus } from '../../types/team'
+import { TeamSettingsModal } from './TeamSettingsModal'
+import { KANBAN_STATUSES, TERMINAL_STATUSES, groupByStatus } from '../../types/team'
+import type { TeamTaskData } from '../../types/team'
type ViewMode = 'kanban' | 'list'
const FILTER_OPTIONS = [
@@ -19,13 +21,15 @@ export function TeamBoard() {
const { t } = useTranslation('teams')
const activeTeamId = useUiStore((s) => s.activeTeamId)
const closeSettings = useUiStore((s) => s.closeSettings)
- const { teams, tasks, members, loading, fetchTeams, fetchTasks, assignTask, deleteTask, deleteBulk } = useTeamTasks()
+ const { teams, tasks, members, loading, fetchTeams, fetchTasks, fetchTaskDetail, assignTask, deleteTask, deleteBulk } = useTeamTasks()
const [viewMode, setViewMode] = useState('kanban')
const [statusFilter, setStatusFilter] = useState('')
const [filterOpen, setFilterOpen] = useState(false)
const [selectedTask, setSelectedTask] = useState(null)
const [selected, setSelected] = useState>(new Set())
+ const [settingsOpen, setSettingsOpen] = useState(false)
+ const [infoOpen, setInfoOpen] = useState(false)
const team = teams.find((t) => t.id === activeTeamId)
@@ -49,8 +53,6 @@ export function TeamBoard() {
}
}, [tasks, selectedTask])
- const terminalStatuses = new Set(['completed', 'failed', 'cancelled'])
-
const handleBulkDelete = async () => {
if (selected.size === 0) return
await deleteBulk(Array.from(selected))
@@ -72,9 +74,7 @@ export function TeamBoard() {
{/* Header */}
-
-
-
+
@@ -82,6 +82,14 @@ export function TeamBoard() {
{team?.name || t('team', 'Team')}
+ {/* Gear — team settings */}
+ setSettingsOpen(true)}
+ className="text-text-muted hover:text-text-primary cursor-pointer p-1 rounded hover:bg-surface-tertiary"
+ title={t('teamSettings', 'Team Settings')}
+ >
+
+
@@ -108,9 +116,7 @@ export function TeamBoard() {
className="flex items-center gap-1.5 text-[11px] bg-surface-tertiary border border-border rounded-lg px-2.5 py-1.5 text-text-secondary hover:border-accent/30 transition-colors cursor-pointer"
>
{t(currentFilterLabel)}
-
-
-
+
{filterOpen && (
<>
@@ -134,6 +140,15 @@ export function TeamBoard() {
{ handleRefresh() }} />
+
+ {/* "+" info button */}
+ setInfoOpen(true)}
+ className="flex items-center justify-center w-7 h-7 rounded-lg bg-accent/10 text-accent hover:bg-accent/20 cursor-pointer transition-colors"
+ title={t('createTask', 'Create Task')}
+ >
+
+
{/* Kanban view */}
@@ -169,10 +184,10 @@ export function TeamBoard() {
0 && selected.size === tasks.filter((t) => terminalStatuses.has(t.status)).length}
+ checked={selected.size > 0 && selected.size === tasks.filter((t) => TERMINAL_STATUSES.has(t.status)).length}
onChange={(e) => {
if (e.target.checked) {
- setSelected(new Set(tasks.filter((t) => terminalStatuses.has(t.status)).map((t) => t.id)))
+ setSelected(new Set(tasks.filter((t) => TERMINAL_STATUSES.has(t.status)).map((t) => t.id)))
} else {
setSelected(new Set())
}
@@ -189,7 +204,7 @@ export function TeamBoard() {
{tasks.map((task) => {
const member = task.owner_agent_id ? members.find((m) => m.agent_id === task.owner_agent_id) : undefined
- const canSelect = terminalStatuses.has(task.status)
+ const canSelect = TERMINAL_STATUSES.has(task.status)
return (
setSelectedTask(null)}
onAssign={assignTask}
onDelete={deleteTask}
+ onFetchDetail={fetchTaskDetail}
/>
)}
+ {/* Team settings modal */}
+ {settingsOpen && activeTeamId && (
+ setSettingsOpen(false)}
+ onSaved={() => { fetchTeams(); if (activeTeamId) fetchTasks(activeTeamId, statusFilter || undefined) }}
+ />
+ )}
+
+ {/* Task create info modal */}
+ {infoOpen && (
+ setInfoOpen(false)}>
+
e.stopPropagation()} className="bg-surface-primary border border-border rounded-xl shadow-xl max-w-sm mx-4 p-6 text-center space-y-3">
+
+
+
+
{t('taskCreateInfo.title', 'How tasks are created')}
+
+ {t('taskCreateInfo.body', 'Tasks are created by chatting with the team leader agent. Start a conversation with {{leader}} to create and manage tasks.', {
+ leader: team?.lead_display_name || team?.lead_agent_key || 'the leader',
+ })}
+
+
setInfoOpen(false)}
+ className="mt-2 px-4 py-1.5 text-xs font-medium bg-surface-tertiary text-text-primary rounded-lg hover:bg-surface-tertiary/80 cursor-pointer"
+ >
+ {t('settings.ok', 'OK')}
+
+
+
+ )}
+
)
}
diff --git a/ui/desktop/frontend/src/components/teams/TeamCreateDialog.tsx b/ui/desktop/frontend/src/components/teams/TeamCreateDialog.tsx
index a1f33ca8..fbcd91bb 100644
--- a/ui/desktop/frontend/src/components/teams/TeamCreateDialog.tsx
+++ b/ui/desktop/frontend/src/components/teams/TeamCreateDialog.tsx
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'
import { getWsClient } from '../../lib/ws'
import { toast } from '../../stores/toast-store'
import { Combobox } from '../common/Combobox'
+import { IconClose, IconCheck } from '../common/Icons'
import type { TeamData } from '../../types/team'
interface Agent {
@@ -64,9 +65,7 @@ export function TeamCreateDialog({ agents, onClose, onCreated }: TeamCreateDialo
{t('createTeam', 'Create Team')}
-
-
-
+
@@ -119,11 +118,7 @@ export function TeamCreateDialog({ agents, onClose, onCreated }: TeamCreateDialo
'w-4 h-4 rounded flex items-center justify-center shrink-0 transition-colors',
checked ? 'bg-accent' : 'border border-border bg-surface-tertiary',
].join(' ')}>
- {checked && (
-
-
-
- )}
+ {checked && }
{opt.label}
diff --git a/ui/desktop/frontend/src/components/teams/TeamSettingsModal.tsx b/ui/desktop/frontend/src/components/teams/TeamSettingsModal.tsx
new file mode 100644
index 00000000..63a8bbf4
--- /dev/null
+++ b/ui/desktop/frontend/src/components/teams/TeamSettingsModal.tsx
@@ -0,0 +1,321 @@
+import { useState, useEffect, useMemo, useCallback } from 'react'
+import { useTranslation } from 'react-i18next'
+import { Combobox } from '../common/Combobox'
+import { IconClose, IconPlus, IconUser, IconSpinner } from '../common/Icons'
+import { useAgents } from '../../hooks/use-agents'
+import { useTeamManage } from '../../hooks/use-team-manage'
+import type { TeamData, TeamMemberData, TeamNotifyConfig } from '../../types/team'
+
+interface TeamSettingsModalProps {
+ teamId: string
+ onClose: () => void
+ /** Called after successful save so parent can refresh */
+ onSaved?: () => void
+}
+
+const ROLE_COLORS: Record = {
+ lead: 'bg-amber-500/15 text-amber-600 dark:text-amber-400',
+ reviewer: 'bg-orange-500/15 text-orange-600 dark:text-orange-400',
+ member: 'bg-surface-tertiary text-text-muted',
+}
+
+const NOTIFY_KEYS = ['dispatched', 'progress', 'failed', 'completed', 'new_task'] as const
+type NotifyKey = typeof NOTIFY_KEYS[number]
+
+export function TeamSettingsModal({ teamId, onClose, onSaved }: TeamSettingsModalProps) {
+ const { t } = useTranslation('teams')
+ const { fetchTeamDetail, updateTeam, addMember, removeMember } = useTeamManage()
+ const { agents, refreshAgents } = useAgents()
+
+ const [team, setTeam] = useState(null)
+ const [members, setMembers] = useState([])
+ const [loading, setLoading] = useState(true)
+
+ // Editable fields
+ const [name, setName] = useState('')
+ const [description, setDescription] = useState('')
+
+ // Notification settings
+ const [notify, setNotify] = useState>({
+ dispatched: true, progress: true, failed: true, completed: true, new_task: true,
+ })
+ const [notifyMode, setNotifyMode] = useState<'direct' | 'leader'>('direct')
+
+ // Add member
+ const [showAdd, setShowAdd] = useState(false)
+ const [addAgent, setAddAgent] = useState('')
+ const [adding, setAdding] = useState(false)
+ const [removing, setRemoving] = useState(null)
+ const [saving, setSaving] = useState(false)
+
+ // Load team detail (once on mount)
+ useEffect(() => {
+ refreshAgents()
+ fetchTeamDetail(teamId).then((res) => {
+ if (!res) return
+ setTeam(res.team)
+ setMembers(res.members ?? [])
+ setName(res.team.name)
+ setDescription(res.team.description ?? '')
+ // Parse notification settings
+ const settings = (res.team.settings ?? {}) as Record
+ const n = (settings.notifications ?? {}) as Record
+ setNotify({
+ dispatched: n.dispatched !== false,
+ progress: n.progress !== false,
+ failed: n.failed !== false,
+ completed: n.completed !== false,
+ new_task: n.new_task !== false,
+ })
+ setNotifyMode((n.mode as 'direct' | 'leader') || 'direct')
+ setLoading(false)
+ })
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [teamId])
+
+ const leadMember = members.find((m) => m.role === 'lead')
+
+ const sorted = useMemo(
+ () => [...members].sort((a, b) => {
+ if (a.role === 'lead' && b.role !== 'lead') return -1
+ if (b.role === 'lead' && a.role !== 'lead') return 1
+ return (a.display_name || a.agent_key || '').localeCompare(b.display_name || b.agent_key || '')
+ }),
+ [members],
+ )
+
+ const memberIds = useMemo(() => new Set(members.map((m) => m.agent_id)), [members])
+ const availableAgents = useMemo(
+ () => agents
+ .filter((a) => !memberIds.has(a.id))
+ .map((a) => ({ value: a.id, label: `${a.emoji || ''} ${a.name}`.trim() })),
+ [agents, memberIds],
+ )
+
+ const toggleNotify = useCallback((key: NotifyKey) => {
+ setNotify((prev) => ({ ...prev, [key]: !prev[key] }))
+ }, [])
+
+ const handleAddMember = async () => {
+ if (!addAgent) return
+ setAdding(true)
+ try {
+ await addMember(teamId, addAgent)
+ const res = await fetchTeamDetail(teamId)
+ if (res) setMembers(res.members ?? [])
+ setAddAgent('')
+ setShowAdd(false)
+ } catch { /* toast in hook */ } finally { setAdding(false) }
+ }
+
+ const handleRemoveMember = async (agentId: string) => {
+ setRemoving(agentId)
+ try {
+ await removeMember(teamId, agentId)
+ setMembers((prev) => prev.filter((m) => m.agent_id !== agentId))
+ } catch { /* toast in hook */ } finally { setRemoving(null) }
+ }
+
+ const handleSave = async () => {
+ setSaving(true)
+ try {
+ const notifications: TeamNotifyConfig = { ...notify, mode: notifyMode }
+ const settings = { ...(team?.settings ?? {}), notifications } as Record
+ await updateTeam(teamId, {
+ name: name !== team?.name ? name : undefined,
+ description: description !== (team?.description ?? '') ? description : undefined,
+ settings,
+ })
+ onSaved?.()
+ } catch { /* toast in hook */ } finally { setSaving(false) }
+ }
+
+ if (loading) {
+ return (
+
+ )
+ }
+
+ return (
+
+
e.stopPropagation()}
+ className="bg-surface-primary border border-border rounded-xl shadow-xl w-[95vw] max-w-2xl max-h-[85vh] flex flex-col mx-4"
+ >
+ {/* Header */}
+
+
{t('teamSettings', 'Team Settings')}
+
+
+
+
+
+ {/* Scrollable body */}
+
+
+ {/* ── Section 1: Team Info ── */}
+
+ {t('settings.teamInfo', 'Team Info')}
+
+ {t('teamName', 'Team name')}
+ setName(e.target.value)}
+ className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-accent/50"
+ />
+
+
+ {t('description', 'Description')}
+
+
+
+
{t('status', 'Status')}
+
{team?.status || 'active'}
+
+
+
{t('leadAgent', 'Lead agent')}
+
+ {leadMember?.emoji && {leadMember.emoji} }
+ {leadMember?.display_name || leadMember?.agent_key || '—'}
+
+
+
+
+
+ {/* ── Section 2: Members ── */}
+
+
+
{t('members', 'Members')} ({members.length})
+ setShowAdd(!showAdd)}
+ className="text-xs text-accent hover:text-accent/80 cursor-pointer flex items-center gap-1"
+ >
+
+ {t('settings.addMember', 'Add member')}
+
+
+
+ {showAdd && (
+
+
+
+
+
+ {adding ? '...' : t('settings.add', 'Add')}
+
+
+ )}
+
+
+ {sorted.map((m) => (
+
+ {m.emoji ? (
+ {m.emoji}
+ ) : (
+
+ )}
+ {m.display_name || m.agent_key || m.agent_id.slice(0, 8)}
+
+ {m.role}
+
+ {m.role !== 'lead' && members.filter((x) => x.role !== 'lead').length > 1 && (
+ handleRemoveMember(m.agent_id)}
+ disabled={removing === m.agent_id}
+ className="opacity-0 group-hover:opacity-100 text-text-muted hover:text-error cursor-pointer transition-opacity disabled:opacity-50"
+ >
+ {removing === m.agent_id ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+ ))}
+ {members.length === 0 && (
+
{t('settings.noMembers', 'No members')}
+ )}
+
+
+
+ {/* ── Section 3: Notifications ── */}
+
+ {t('settings.notifications', 'Notifications')}
+
+ {NOTIFY_KEYS.map((key) => (
+
+ {t(`settings.notify_${key}`, key)}
+ toggleNotify(key)}
+ className={`relative w-9 h-5 rounded-full transition-colors cursor-pointer ${notify[key] ? 'bg-accent' : 'bg-surface-tertiary border border-border'}`}
+ >
+
+
+
+ ))}
+
+
+ {/* Notification mode */}
+
+
{t('settings.notifyMode', 'Notification mode')}
+
+ {(['direct', 'leader'] as const).map((mode) => (
+
setNotifyMode(mode)}
+ className={`text-left rounded-lg border p-3 transition-colors cursor-pointer ${
+ notifyMode === mode
+ ? 'border-accent bg-accent/5'
+ : 'border-border hover:border-accent/30'
+ }`}
+ >
+ {t(`settings.notifyMode_${mode}`, mode)}
+ {t(`settings.notifyMode_${mode}_desc`)}
+
+ ))}
+
+ {notifyMode === 'leader' && (
+
{t('settings.notifyModeLeaderWarn', 'Only the lead agent will receive notifications.')}
+ )}
+
+
+
+
+ {/* Footer */}
+
+
+ {saving && }
+ {t('settings.save', 'Save')}
+
+
+
+
+ )
+}
diff --git a/ui/desktop/frontend/src/hooks/use-team-manage.ts b/ui/desktop/frontend/src/hooks/use-team-manage.ts
new file mode 100644
index 00000000..e8571035
--- /dev/null
+++ b/ui/desktop/frontend/src/hooks/use-team-manage.ts
@@ -0,0 +1,60 @@
+import { useCallback } from 'react'
+import { getWsClient } from '../lib/ws'
+import { toast } from '../stores/toast-store'
+import type { TeamData, TeamMemberData } from '../types/team'
+
+interface TeamDetailResult {
+ team: TeamData
+ members: TeamMemberData[]
+}
+
+export function useTeamManage() {
+ const fetchTeamDetail = useCallback(async (teamId: string): Promise => {
+ try {
+ const ws = getWsClient()
+ const res = await ws.call('teams.get', { teamId }) as TeamDetailResult
+ return res
+ } catch (err) {
+ console.error('Failed to fetch team detail:', err)
+ return null
+ }
+ }, [])
+
+ const updateTeam = useCallback(async (
+ teamId: string,
+ params: { name?: string; description?: string; settings?: Record },
+ ) => {
+ try {
+ const ws = getWsClient()
+ await ws.call('teams.update', { teamId, ...params })
+ toast.success('Team updated')
+ } catch (err) {
+ toast.error('Failed to update team', (err as Error).message)
+ throw err
+ }
+ }, [])
+
+ const addMember = useCallback(async (teamId: string, agentId: string, role?: string) => {
+ try {
+ const ws = getWsClient()
+ await ws.call('teams.members.add', { teamId, agent: agentId, role: role || 'member' })
+ toast.success('Member added')
+ } catch (err) {
+ toast.error('Failed to add member', (err as Error).message)
+ throw err
+ }
+ }, [])
+
+ const removeMember = useCallback(async (teamId: string, agentId: string) => {
+ try {
+ const ws = getWsClient()
+ await ws.call('teams.members.remove', { teamId, agentId })
+ toast.success('Member removed')
+ } catch (err) {
+ toast.error('Failed to remove member', (err as Error).message)
+ throw err
+ }
+ }, [])
+
+ return { fetchTeamDetail, updateTeam, addMember, removeMember }
+}
diff --git a/ui/desktop/frontend/src/hooks/use-team-tasks.ts b/ui/desktop/frontend/src/hooks/use-team-tasks.ts
index 92d1939e..98f03997 100644
--- a/ui/desktop/frontend/src/hooks/use-team-tasks.ts
+++ b/ui/desktop/frontend/src/hooks/use-team-tasks.ts
@@ -1,7 +1,7 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { getWsClient } from '../lib/ws'
import { toast } from '../stores/toast-store'
-import type { TeamData, TeamTaskData, TeamMemberData } from '../types/team'
+import type { TeamData, TeamTaskData, TeamMemberData, TeamTaskAttachment } from '../types/team'
/** Event payload shape from team.task.* WS events */
interface TaskEventPayload {
@@ -199,5 +199,20 @@ export function useTeamTasks() {
return () => { for (const fn of unsubs) fn() }
}, [debouncedFetchTask])
- return { teams, tasks, members, loading, fetchTeams, fetchTasks, createTask, assignTask, deleteTask, deleteBulk }
+ /** Fetch full task detail including attachments (for modal view) */
+ const fetchTaskDetail = useCallback(async (teamId: string, taskId: string) => {
+ try {
+ const ws = getWsClient()
+ const res = await ws.call('teams.tasks.get', { teamId, taskId }) as {
+ task: TeamTaskData
+ attachments?: TeamTaskAttachment[]
+ }
+ return { task: res.task, attachments: res.attachments ?? [] }
+ } catch (err) {
+ console.error('Failed to fetch task detail:', err)
+ return null
+ }
+ }, [])
+
+ return { teams, tasks, members, loading, fetchTeams, fetchTasks, fetchTaskDetail, createTask, assignTask, deleteTask, deleteBulk }
}
diff --git a/ui/desktop/frontend/src/i18n/locales/en/teams.json b/ui/desktop/frontend/src/i18n/locales/en/teams.json
index 1635dfee..ab29d95a 100644
--- a/ui/desktop/frontend/src/i18n/locales/en/teams.json
+++ b/ui/desktop/frontend/src/i18n/locales/en/teams.json
@@ -24,6 +24,8 @@
"selectAgent": "Select agent...",
"delete": "Delete",
"deleting": "Deleting...",
+ "attachments": "Attachments",
+ "download": "Download",
"deleteSelected": "Delete selected",
"cancel": "Cancel",
"create": "Create",
@@ -44,6 +46,32 @@
"noTeams": "No teams yet",
"selected": "selected",
"needMembers": "Select at least 1 member for the team",
+ "teamSettings": "Team Settings",
+ "settings": {
+ "teamInfo": "Team Info",
+ "addMember": "Add member",
+ "searchAgent": "Search agent...",
+ "add": "Add",
+ "noMembers": "No members",
+ "notifications": "Notifications",
+ "notify_dispatched": "Task dispatched",
+ "notify_progress": "Progress updates",
+ "notify_failed": "Task failures",
+ "notify_completed": "Task completions",
+ "notify_new_task": "New tasks",
+ "notifyMode": "Notification mode",
+ "notifyMode_direct": "Direct",
+ "notifyMode_direct_desc": "Send updates directly to members",
+ "notifyMode_leader": "Via Leader",
+ "notifyMode_leader_desc": "Leader rephrases updates before sending",
+ "notifyModeLeaderWarn": "Leader mode uses AI to rephrase (costs tokens, may be slower).",
+ "save": "Save",
+ "ok": "OK"
+ },
+ "taskCreateInfo": {
+ "title": "How tasks are created",
+ "body": "Tasks are created by chatting with the team leader agent. Start a conversation with {{leader}} to create and manage tasks."
+ },
"editionCompare": "GoClaw Lite vs Standard",
"editionUpgrade": "Upgrade to Standard with PostgreSQL for full features",
"feature": "Feature",
diff --git a/ui/desktop/frontend/src/i18n/locales/vi/teams.json b/ui/desktop/frontend/src/i18n/locales/vi/teams.json
index ea911177..c485893b 100644
--- a/ui/desktop/frontend/src/i18n/locales/vi/teams.json
+++ b/ui/desktop/frontend/src/i18n/locales/vi/teams.json
@@ -24,6 +24,8 @@
"selectAgent": "Chọn agent...",
"delete": "Xóa",
"deleting": "Đang xóa...",
+ "attachments": "Tệp đính kèm",
+ "download": "Tải xuống",
"deleteSelected": "Xóa đã chọn",
"cancel": "Hủy",
"create": "Tạo",
@@ -44,6 +46,32 @@
"noTeams": "Chưa có nhóm",
"selected": "đã chọn",
"needMembers": "Chọn ít nhất 1 thành viên cho nhóm",
+ "teamSettings": "Cài đặt nhóm",
+ "settings": {
+ "teamInfo": "Thông tin nhóm",
+ "addMember": "Thêm thành viên",
+ "searchAgent": "Tìm agent...",
+ "add": "Thêm",
+ "noMembers": "Chưa có thành viên",
+ "notifications": "Thông báo",
+ "notify_dispatched": "Tác vụ được giao",
+ "notify_progress": "Cập nhật tiến độ",
+ "notify_failed": "Tác vụ thất bại",
+ "notify_completed": "Tác vụ hoàn thành",
+ "notify_new_task": "Tác vụ mới",
+ "notifyMode": "Chế độ thông báo",
+ "notifyMode_direct": "Trực tiếp",
+ "notifyMode_direct_desc": "Gửi cập nhật trực tiếp đến các thành viên",
+ "notifyMode_leader": "Qua Leader",
+ "notifyMode_leader_desc": "Leader diễn đạt lại cập nhật trước khi gửi",
+ "notifyModeLeaderWarn": "Chế độ Leader dùng AI để diễn đạt lại (tốn token, có thể chậm hơn).",
+ "save": "Lưu",
+ "ok": "OK"
+ },
+ "taskCreateInfo": {
+ "title": "Cách tạo tác vụ",
+ "body": "Tác vụ được tạo bằng cách trò chuyện với agent trưởng nhóm. Bắt đầu cuộc trò chuyện với {{leader}} để tạo và quản lý tác vụ."
+ },
"editionCompare": "GoClaw Lite vs Standard",
"editionUpgrade": "Nâng cấp lên Standard với PostgreSQL để có đầy đủ tính năng",
"feature": "Tính năng",
diff --git a/ui/desktop/frontend/src/i18n/locales/zh/teams.json b/ui/desktop/frontend/src/i18n/locales/zh/teams.json
index 442aec62..19b265f5 100644
--- a/ui/desktop/frontend/src/i18n/locales/zh/teams.json
+++ b/ui/desktop/frontend/src/i18n/locales/zh/teams.json
@@ -24,6 +24,8 @@
"selectAgent": "选择代理...",
"delete": "删除",
"deleting": "删除中...",
+ "attachments": "附件",
+ "download": "下载",
"deleteSelected": "删除已选",
"cancel": "取消",
"create": "创建",
@@ -44,6 +46,32 @@
"noTeams": "暂无团队",
"selected": "已选",
"needMembers": "请至少选择1名团队成员",
+ "teamSettings": "团队设置",
+ "settings": {
+ "teamInfo": "团队信息",
+ "addMember": "添加成员",
+ "searchAgent": "搜索代理...",
+ "add": "添加",
+ "noMembers": "暂无成员",
+ "notifications": "通知",
+ "notify_dispatched": "任务分配",
+ "notify_progress": "进度更新",
+ "notify_failed": "任务失败",
+ "notify_completed": "任务完成",
+ "notify_new_task": "新任务",
+ "notifyMode": "通知模式",
+ "notifyMode_direct": "直接",
+ "notifyMode_direct_desc": "直接向成员发送更新",
+ "notifyMode_leader": "经由 Leader",
+ "notifyMode_leader_desc": "Leader 重新表述更新后再发送",
+ "notifyModeLeaderWarn": "Leader 模式使用 AI 重新表述(消耗 token,可能较慢)。",
+ "save": "保存",
+ "ok": "确定"
+ },
+ "taskCreateInfo": {
+ "title": "如何创建任务",
+ "body": "通过与团队负责人代理对话来创建任务。与 {{leader}} 开始对话以创建和管理任务。"
+ },
"editionCompare": "GoClaw Lite vs Standard",
"editionUpgrade": "升级到 Standard 版(PostgreSQL)以获取完整功能",
"feature": "功能",
diff --git a/ui/desktop/frontend/src/types/team.ts b/ui/desktop/frontend/src/types/team.ts
index 48bcb3d8..cbf82425 100644
--- a/ui/desktop/frontend/src/types/team.ts
+++ b/ui/desktop/frontend/src/types/team.ts
@@ -3,6 +3,7 @@
export interface TeamData {
id: string
name: string
+ description?: string
lead_agent_id: string
lead_agent_key?: string
lead_display_name?: string
@@ -48,6 +49,27 @@ export interface TeamTaskData {
updated_at?: string
}
+export interface TeamTaskAttachment {
+ id: string
+ task_id: string
+ team_id: string
+ path: string
+ file_size: number
+ mime_type?: string
+ created_at: string
+ download_url?: string
+}
+
+/** Notification config stored in team.settings.notifications */
+export interface TeamNotifyConfig {
+ dispatched?: boolean
+ progress?: boolean
+ failed?: boolean
+ completed?: boolean
+ new_task?: boolean
+ mode?: 'direct' | 'leader'
+}
+
/** All kanban column statuses in display order */
export const KANBAN_STATUSES: TaskStatus[] = [
'pending', 'blocked', 'in_progress', 'completed', 'failed', 'cancelled',
@@ -81,6 +103,9 @@ export const PRIORITY_BADGE: Record = {
3: { label: 'P-3', cls: 'bg-slate-500/15 text-slate-600 dark:text-slate-400' },
}
+/** Terminal task statuses (no further state transitions) */
+export const TERMINAL_STATUSES: Set = new Set(['completed', 'failed', 'cancelled'])
+
/** Check if agent is actively running on a task */
export function isTaskLocked(task: TeamTaskData): boolean {
if (!task.locked_at) return false