mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-09-03 04:18:07 +00:00
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:
@@ -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": "功能",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user