feat(desktop): team settings modal, task detail attachments, shared icons

- Add TeamSettingsModal: editable name/description, member management
  (add/remove with minimum 1 member guard), notification toggles (5 events
  + direct/leader mode)
- Extend backend teams.update to support name/description fields
- Add gear button + "+" info button to team board header
- Task detail modal: fetch full detail with attachments on open,
  render attachment list with download links (resolved against local gateway)
- Chat view TaskPanel: clicking active tasks opens TaskDetailModal
- KanbanCard: show comment and attachment counts
- Extract 14 shared SVG icons to Icons.tsx, replace all inline SVGs
  in team components (0 remaining)
- Extract TERMINAL_STATUSES and TeamNotifyConfig to shared types
- i18n: en/vi/zh settings, members, notification, attachment keys
This commit is contained in:
viettranx
2026-03-27 12:18:01 +07:00
parent 865f724816
commit 5e936ebf8f
15 changed files with 805 additions and 57 deletions
+10 -2
View File
@@ -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
@@ -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<HTMLDivElement>(null)
const scrollAreaRef = useRef<HTMLDivElement>(null)
const userScrolledUp = useRef(false)
const [selectedTask, setSelectedTask] = useState<TeamTaskData | null>(null)
// Find last assistant message ID for streaming cursor
const lastAssistantId = useMemo(() => {
@@ -85,7 +90,7 @@ export function ChatCanvas() {
</div>
{/* Team task panel */}
<TaskPanel sessionKey={activeSessionKey} />
<TaskPanel sessionKey={activeSessionKey} onTaskClick={setSelectedTask} />
{/* Input bar */}
<InputBar
@@ -95,6 +100,18 @@ export function ChatCanvas() {
placeholder={selectedAgent ? t('sendMessage') : t('selectAgent')}
/>
</div>
{/* Task detail modal (from TaskPanel click) */}
{selectedTask && (
<TaskDetailModal
task={selectedTask}
members={members}
onClose={() => setSelectedTask(null)}
onAssign={async () => {}}
onDelete={async () => {}}
onFetchDetail={fetchTaskDetail}
/>
)}
</div>
)
}
@@ -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<string, unknown>) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className={className}
{...extra}
>
{children}
</svg>
)
}
export function IconClose({ size = 18, className }: IconProps) {
return svg(size, className, <>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</>)
}
export function IconChevronDown({ size = 14, className }: IconProps) {
return svg(size, className, <polyline points="6 9 12 15 18 9" />)
}
export function IconChevronLeft({ size = 16, className }: IconProps) {
return svg(size, className, <polyline points="15 18 9 12 15 6" />)
}
export function IconPlus({ size = 14, className }: IconProps) {
return svg(size, className, <>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</>, { strokeWidth: 2.5 })
}
export function IconGear({ size = 14, className }: IconProps) {
return svg(size, className, <>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</>)
}
export function IconTrash({ size = 14, className }: IconProps) {
return svg(size, className, <>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</>)
}
export function IconDocument({ size = 16, className }: IconProps) {
return svg(size, className, <>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10 9 9 9 8 9" />
</>)
}
export function IconCheckCircle({ size = 16, className }: IconProps) {
return svg(size, className, <>
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
<polyline points="22 4 12 14.01 9 11.01" />
</>)
}
export function IconCheck({ size = 14, className }: IconProps) {
return svg(size, className, <polyline points="20 6 9 17 4 12" />)
}
export function IconBlocked({ size = 14, className }: IconProps) {
return svg(size, className, <>
<circle cx="12" cy="12" r="10" />
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07" />
</>)
}
export function IconChat({ size = 20, className }: IconProps) {
return svg(size, className, <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />)
}
export function IconUser({ size = 16, className }: IconProps) {
return svg(size, className, <>
<rect x="3" y="11" width="18" height="11" rx="2" />
<circle cx="12" cy="5" r="4" />
</>)
}
export function IconPaperclip({ size = 10, className }: IconProps) {
return svg(size, className, <path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" />)
}
export function IconSpinner({ size = 14, className }: IconProps) {
return (
<div
className={`border-2 border-current border-t-transparent rounded-full animate-spin ${className ?? ''}`}
style={{ width: size, height: size }}
/>
)
}
@@ -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<number, { label: string; color: string }> = {
@@ -10,7 +11,6 @@ const PRIORITY_STYLE: Record<number, { label: string; color: string }> = {
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 (
<motion.button
@@ -64,21 +64,31 @@ export function KanbanCard({ task, ownerName, ownerEmoji, onClick }: KanbanCardP
{/* Blocked indicator */}
{hasBlockers && (
<p className="mt-1 flex items-center gap-1 text-[10px] text-amber-600 dark:text-amber-400">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} className="shrink-0">
<circle cx="12" cy="12" r="10" /><line x1="4.93" y1="4.93" x2="19.07" y2="19.07" />
</svg>
<IconBlocked size={10} className="shrink-0" />
<span className="truncate">
{task.blocked_by!.map((id) => id.slice(0, 8)).join(', ')}
</span>
</p>
)}
{/* Bottom: owner */}
{/* Bottom: owner + counts */}
<div className="mt-2 flex items-center gap-1.5">
{ownerEmoji && <span className="text-sm leading-none">{ownerEmoji}</span>}
<span className="truncate text-xs text-text-muted">
<span className="truncate text-xs text-text-muted flex-1">
{ownerName || task.owner_agent_key || 'Unassigned'}
</span>
{(task.comment_count ?? 0) > 0 && (
<span className="flex items-center gap-0.5 text-[10px] text-text-muted shrink-0">
<IconChat size={10} />
{task.comment_count}
</span>
)}
{(task.attachment_count ?? 0) > 0 && (
<span className="flex items-center gap-0.5 text-[10px] text-text-muted shrink-0">
<IconPaperclip size={10} />
{task.attachment_count}
</span>
)}
</div>
{/* Progress bar */}
@@ -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
<div className="flex items-center justify-between p-4 border-b border-border">
<h3 className="text-sm font-semibold text-text-primary">{t('createTask', 'Create Task')}</h3>
<button onClick={onClose} className="text-text-muted hover:text-text-primary cursor-pointer">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
<IconClose size={16} />
</button>
</div>
@@ -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<unknown>
onDelete: (taskId: string) => Promise<void>
onFetchDetail?: (teamId: string, taskId: string) => Promise<{ task: TeamTaskData; attachments: TeamTaskAttachment[] } | null>
}
const TERMINAL: Set<TaskStatus> = 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}
<span>{title}</span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} className={`ml-auto transition-transform ${open ? '' : '-rotate-90'}`}>
<polyline points="6 9 12 15 18 9" />
</svg>
<IconChevronDown className={`ml-auto transition-transform ${open ? '' : '-rotate-90'}`} />
</button>
{open && (
<div className="border-t border-border px-4 py-3">
@@ -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<TeamTaskAttachment[]>([])
// 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 }:
<h3 className="text-base font-semibold text-text-primary leading-snug sm:text-lg">{task.subject}</h3>
</div>
<button onClick={onClose} className="text-text-muted hover:text-text-primary p-1.5 cursor-pointer shrink-0 rounded-lg hover:bg-surface-tertiary">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
<IconClose />
</button>
</div>
</div>
@@ -182,7 +195,7 @@ export function TaskDetailModal({ task, members, onClose, onAssign, onDelete }:
{task.description && (
<CollapsibleSection
title={t('description', 'Description')}
icon={<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><polyline points="14 2 14 8 20 8" /><line x1="16" y1="13" x2="8" y2="13" /><line x1="16" y1="17" x2="8" y2="17" /><polyline points="10 9 9 9 8 9" /></svg>}
icon={<IconDocument />}
>
<div className="text-sm text-text-secondary prose prose-sm dark:prose-invert max-w-none max-h-60 overflow-y-auto">
<MarkdownRenderer content={task.description} />
@@ -194,13 +207,52 @@ export function TaskDetailModal({ task, members, onClose, onAssign, onDelete }:
{task.result && (
<CollapsibleSection
title={t('result', 'Result')}
icon={<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" /><polyline points="22 4 12 14.01 9 11.01" /></svg>}
icon={<IconCheckCircle />}
>
<div className="text-sm text-text-secondary prose prose-sm dark:prose-invert max-w-none max-h-[40vh] overflow-y-auto">
<MarkdownRenderer content={task.result} />
</div>
</CollapsibleSection>
)}
{/* Attachments section */}
{attachments.length > 0 && (
<CollapsibleSection
title={`${t('attachments', 'Attachments')} (${attachments.length})`}
icon={<IconDocument />}
>
<div className="space-y-2">
{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 (
<div key={a.id} className="flex items-center gap-3 rounded-lg border border-border bg-surface-tertiary/30 p-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-accent/10">
<IconDocument className="text-accent" />
</div>
<div className="flex-1 min-w-0">
<p className="truncate text-sm font-medium text-text-primary">{fileName}</p>
{a.file_size > 0 && (
<p className="text-xs text-text-muted">{formatFileSize(a.file_size)}</p>
)}
</div>
{a.download_url && (
<a
href={fullUrl}
download
onClick={(e) => e.stopPropagation()}
className="shrink-0 text-xs text-accent hover:text-accent/80 px-3 py-1.5 rounded-lg border border-accent/30 hover:bg-accent/10 transition-colors cursor-pointer"
>
{t('download', 'Download')}
</a>
)}
</div>
)
})}
</div>
</CollapsibleSection>
)}
</div>
{/* ── 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"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6" /><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
<IconTrash />
{t('delete', 'Delete')}
</button>
)}
@@ -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<ViewMode>('kanban')
const [statusFilter, setStatusFilter] = useState<string>('')
const [filterOpen, setFilterOpen] = useState(false)
const [selectedTask, setSelectedTask] = useState<TeamTaskData | null>(null)
const [selected, setSelected] = useState<Set<string>>(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<TaskStatus>(['completed', 'failed', 'cancelled'])
const handleBulkDelete = async () => {
if (selected.size === 0) return
await deleteBulk(Array.from(selected))
@@ -72,9 +74,7 @@ export function TeamBoard() {
{/* Header */}
<div className="flex items-center gap-3 px-4 py-3 border-b border-border shrink-0">
<button onClick={closeSettings} className="text-text-muted hover:text-text-primary cursor-pointer" title="Back to chat">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 18 9 12 15 6" />
</svg>
<IconChevronLeft size={16} />
</button>
<div className="flex-1 min-w-0">
@@ -82,6 +82,14 @@ export function TeamBoard() {
<h2 className="text-sm font-semibold text-text-primary truncate">
{team?.name || t('team', 'Team')}
</h2>
{/* Gear — team settings */}
<button
onClick={() => setSettingsOpen(true)}
className="text-text-muted hover:text-text-primary cursor-pointer p-1 rounded hover:bg-surface-tertiary"
title={t('teamSettings', 'Team Settings')}
>
<IconGear />
</button>
</div>
</div>
@@ -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"
>
<span>{t(currentFilterLabel)}</span>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
<polyline points="6 9 12 15 18 9" />
</svg>
<IconChevronDown size={10} />
</button>
{filterOpen && (
<>
@@ -134,6 +140,15 @@ export function TeamBoard() {
</div>
<RefreshButton onRefresh={async () => { handleRefresh() }} />
{/* "+" info button */}
<button
onClick={() => 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')}
>
<IconPlus />
</button>
</div>
{/* Kanban view */}
@@ -169,10 +184,10 @@ export function TeamBoard() {
<th className="pb-2 pr-2 w-8">
<input
type="checkbox"
checked={selected.size > 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() {
<tbody>
{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 (
<tr
key={task.id}
@@ -235,9 +250,42 @@ export function TeamBoard() {
onClose={() => setSelectedTask(null)}
onAssign={assignTask}
onDelete={deleteTask}
onFetchDetail={fetchTaskDetail}
/>
)}
{/* Team settings modal */}
{settingsOpen && activeTeamId && (
<TeamSettingsModal
teamId={activeTeamId}
onClose={() => setSettingsOpen(false)}
onSaved={() => { fetchTeams(); if (activeTeamId) fetchTasks(activeTeamId, statusFilter || undefined) }}
/>
)}
{/* Task create info modal */}
{infoOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => setInfoOpen(false)}>
<div onClick={(e) => 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">
<div className="mx-auto w-10 h-10 rounded-full bg-accent/10 flex items-center justify-center">
<IconChat size={20} className="text-accent" />
</div>
<h3 className="text-sm font-semibold text-text-primary">{t('taskCreateInfo.title', 'How tasks are created')}</h3>
<p className="text-xs text-text-secondary leading-relaxed">
{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',
})}
</p>
<button
onClick={() => 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')}
</button>
</div>
</div>
)}
</div>
)
}
@@ -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
<div className="flex items-center justify-between p-4 border-b border-border">
<h3 className="text-sm font-semibold text-text-primary">{t('createTeam', 'Create Team')}</h3>
<button onClick={onClose} className="text-text-muted hover:text-text-primary cursor-pointer">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
<IconClose size={16} />
</button>
</div>
@@ -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 && (
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth={3} strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
)}
{checked && <IconCheck size={10} className="text-white" />}
</div>
<span className={`text-xs font-medium ${checked ? 'text-accent' : 'text-text-secondary'}`}>
{opt.label}
@@ -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<string, string> = {
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<TeamData | null>(null)
const [members, setMembers] = useState<TeamMemberData[]>([])
const [loading, setLoading] = useState(true)
// Editable fields
const [name, setName] = useState('')
const [description, setDescription] = useState('')
// Notification settings
const [notify, setNotify] = useState<Record<NotifyKey, boolean>>({
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<string | null>(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<string, unknown>
const n = (settings.notifications ?? {}) as Record<string, unknown>
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<string, unknown>
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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-surface-primary rounded-xl border border-border p-8">
<IconSpinner size={24} className="border-accent mx-auto" />
</div>
</div>
)
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
<div
onClick={(e) => 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 */}
<div className="px-6 pt-5 pb-4 border-b border-border shrink-0 flex items-center justify-between">
<h2 className="text-base font-semibold text-text-primary">{t('teamSettings', 'Team Settings')}</h2>
<button onClick={onClose} className="text-text-muted hover:text-text-primary p-1.5 cursor-pointer rounded-lg hover:bg-surface-tertiary">
<IconClose />
</button>
</div>
{/* Scrollable body */}
<div className="flex-1 overflow-y-auto overscroll-contain space-y-5 px-6 py-4">
{/* ── Section 1: Team Info ── */}
<section className="space-y-3">
<h3 className="text-sm font-medium text-text-primary">{t('settings.teamInfo', 'Team Info')}</h3>
<div className="space-y-2">
<label className="block text-xs text-text-muted">{t('teamName', 'Team name')}</label>
<input
value={name}
onChange={(e) => 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"
/>
</div>
<div className="space-y-2">
<label className="block text-xs text-text-muted">{t('description', 'Description')}</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={2}
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 resize-none"
/>
</div>
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<span className="text-xs text-text-muted">{t('status', 'Status')}</span>
<p className="mt-0.5 font-medium capitalize text-text-primary">{team?.status || 'active'}</p>
</div>
<div>
<span className="text-xs text-text-muted">{t('leadAgent', 'Lead agent')}</span>
<p className="mt-0.5 font-medium text-text-primary">
{leadMember?.emoji && <span className="mr-1">{leadMember.emoji}</span>}
{leadMember?.display_name || leadMember?.agent_key || '—'}
</p>
</div>
</div>
</section>
{/* ── Section 2: Members ── */}
<section className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-text-primary">{t('members', 'Members')} ({members.length})</h3>
<button
onClick={() => setShowAdd(!showAdd)}
className="text-xs text-accent hover:text-accent/80 cursor-pointer flex items-center gap-1"
>
<IconPlus size={12} />
{t('settings.addMember', 'Add member')}
</button>
</div>
{showAdd && (
<div className="flex gap-2">
<div className="flex-1 min-w-0">
<Combobox
value={addAgent}
onChange={setAddAgent}
options={availableAgents}
placeholder={t('settings.searchAgent', 'Search agent...')}
allowCustom={false}
/>
</div>
<button
onClick={handleAddMember}
disabled={!addAgent || adding}
className="shrink-0 px-3 py-1.5 text-xs font-medium bg-accent text-white rounded-lg hover:bg-accent/90 disabled:opacity-50 cursor-pointer"
>
{adding ? '...' : t('settings.add', 'Add')}
</button>
</div>
)}
<div className="rounded-lg border border-border divide-y divide-border max-h-[200px] overflow-y-auto">
{sorted.map((m) => (
<div key={m.agent_id} className="group flex items-center gap-3 px-3 py-2.5 hover:bg-surface-tertiary/50">
{m.emoji ? (
<span className="text-base shrink-0">{m.emoji}</span>
) : (
<IconUser className="text-text-muted shrink-0" />
)}
<span className="text-sm text-text-primary truncate flex-1">{m.display_name || m.agent_key || m.agent_id.slice(0, 8)}</span>
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded ${ROLE_COLORS[m.role] ?? ''}`}>
{m.role}
</span>
{m.role !== 'lead' && members.filter((x) => x.role !== 'lead').length > 1 && (
<button
onClick={() => 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 ? (
<IconSpinner size={14} className="border-text-muted" />
) : (
<IconClose size={14} />
)}
</button>
)}
</div>
))}
{members.length === 0 && (
<div className="px-3 py-4 text-center text-xs text-text-muted">{t('settings.noMembers', 'No members')}</div>
)}
</div>
</section>
{/* ── Section 3: Notifications ── */}
<section className="space-y-3">
<h3 className="text-sm font-medium text-text-primary">{t('settings.notifications', 'Notifications')}</h3>
<div className="space-y-2">
{NOTIFY_KEYS.map((key) => (
<div key={key} className="flex items-center justify-between py-1.5">
<span className="text-sm text-text-secondary">{t(`settings.notify_${key}`, key)}</span>
<button
type="button"
onClick={() => toggleNotify(key)}
className={`relative w-9 h-5 rounded-full transition-colors cursor-pointer ${notify[key] ? 'bg-accent' : 'bg-surface-tertiary border border-border'}`}
>
<span className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform ${notify[key] ? 'translate-x-4' : ''}`} />
</button>
</div>
))}
</div>
{/* Notification mode */}
<div className="space-y-2 pt-2 border-t border-border">
<span className="text-xs text-text-muted">{t('settings.notifyMode', 'Notification mode')}</span>
<div className="grid grid-cols-2 gap-2">
{(['direct', 'leader'] as const).map((mode) => (
<button
type="button"
key={mode}
onClick={() => 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'
}`}
>
<div className="text-sm font-medium text-text-primary">{t(`settings.notifyMode_${mode}`, mode)}</div>
<div className="text-[11px] text-text-muted mt-0.5">{t(`settings.notifyMode_${mode}_desc`)}</div>
</button>
))}
</div>
{notifyMode === 'leader' && (
<p className="text-xs text-amber-500">{t('settings.notifyModeLeaderWarn', 'Only the lead agent will receive notifications.')}</p>
)}
</div>
</section>
</div>
{/* Footer */}
<div className="px-6 py-3 border-t border-border shrink-0 flex justify-end">
<button
onClick={handleSave}
disabled={saving || !name.trim()}
className="px-4 py-2 text-sm font-medium bg-accent text-white rounded-lg hover:bg-accent/90 disabled:opacity-50 cursor-pointer flex items-center gap-2"
>
{saving && <IconSpinner size={14} className="border-white" />}
{t('settings.save', 'Save')}
</button>
</div>
</div>
</div>
)
}
@@ -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<TeamDetailResult | null> => {
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<string, unknown> },
) => {
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 }
}
@@ -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 }
}
@@ -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",
@@ -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",
@@ -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": "功能",
+25
View File
@@ -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<number, { label: string; cls: string }> = {
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<TaskStatus> = 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