Initial commit
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { AgentInfo } from '../types/visual'
|
||||
import type { AgentAnimStatus, KanbanTask } from '../types/kanban'
|
||||
import { AGENT_STATUS_LABEL } from '../types/kanban'
|
||||
|
||||
interface AgentStatusBarProps {
|
||||
agents: AgentInfo[]
|
||||
tasks: KanbanTask[]
|
||||
}
|
||||
|
||||
interface AgentState {
|
||||
agent: AgentInfo
|
||||
status: AgentAnimStatus
|
||||
currentTool?: string
|
||||
taskDisplayId?: string
|
||||
}
|
||||
|
||||
export function AgentStatusBar({ agents, tasks }: AgentStatusBarProps) {
|
||||
const agentStates = useMemo<AgentState[]>(() => {
|
||||
const tasksById = new Map(tasks.map((task) => [task.id, task]))
|
||||
return agents.map(agent => {
|
||||
// Find the first active (non-idle) task for this agent
|
||||
const activeTask = tasks.find(
|
||||
t => t.assigneeIds.includes(agent.agent_id)
|
||||
&& t.agentStatus && t.agentStatus !== 'idle'
|
||||
)
|
||||
const runtimeTask = agent.current_task_id ? tasksById.get(agent.current_task_id) : undefined
|
||||
return {
|
||||
agent,
|
||||
status: (activeTask?.agentStatus ?? agent.runtime_status ?? 'idle') as AgentAnimStatus,
|
||||
currentTool: activeTask?.currentTool ?? agent.current_tool,
|
||||
taskDisplayId: activeTask?.displayId ?? runtimeTask?.displayId,
|
||||
}
|
||||
})
|
||||
}, [agents, tasks])
|
||||
|
||||
if (agents.length === 0) return null
|
||||
|
||||
const activeCount = agentStates.filter(s => s.status !== 'idle').length
|
||||
|
||||
return (
|
||||
<div className="agent-status-bar">
|
||||
<span className="agent-status-summary">
|
||||
{activeCount > 0
|
||||
? `${activeCount}/${agents.length} active`
|
||||
: `${agents.length} agent${agents.length !== 1 ? 's' : ''}`}
|
||||
</span>
|
||||
<div className="agent-status-chips">
|
||||
{agentStates.map(({ agent, status, currentTool, taskDisplayId }) => (
|
||||
<div
|
||||
key={agent.agent_id}
|
||||
className={`agent-status-chip status-${status}`}
|
||||
title={`${agent.name}: ${status === 'tool_active' && currentTool ? currentTool : AGENT_STATUS_LABEL[status]}${taskDisplayId ? ` (${taskDisplayId})` : ''}`}
|
||||
>
|
||||
<span className="agent-status-avatar">{agent.name.charAt(0).toUpperCase()}</span>
|
||||
<span className="agent-status-name">{agent.name}</span>
|
||||
{status !== 'idle' && (
|
||||
<>
|
||||
<span className="kanban-runtime-dot" />
|
||||
<span className="agent-status-detail">
|
||||
{status === 'tool_active' && currentTool ? currentTool : AGENT_STATUS_LABEL[status]}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{taskDisplayId && (
|
||||
<span className="agent-status-task">{taskDisplayId}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { KanbanBoard } from '../types/kanban'
|
||||
|
||||
interface BoardSelectorProps {
|
||||
boards: KanbanBoard[]
|
||||
activeBoardId: string | null
|
||||
onSelect: (id: string) => void
|
||||
}
|
||||
|
||||
export function BoardSelector({ boards, activeBoardId, onSelect }: BoardSelectorProps) {
|
||||
return (
|
||||
<div className="board-selector">
|
||||
<div className="board-tabs">
|
||||
{boards.map(b => (
|
||||
<button
|
||||
key={b.id}
|
||||
className={`board-tab${b.id === activeBoardId ? ' active' : ''}`}
|
||||
style={{ '--board-color': b.color } as React.CSSProperties}
|
||||
onClick={() => onSelect(b.id)}
|
||||
>
|
||||
<span className="board-tab-dot" style={{ background: b.color }} />
|
||||
{b.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
/**
|
||||
* Regression tests for BoardStore selection behavior.
|
||||
*
|
||||
* Bug history:
|
||||
* - Original: BoardStore auto-selected boards[0] whenever activeBoardId was
|
||||
* null. Combined with the parent's session-driven clear, this created an
|
||||
* infinite toggle loop (screen flicker).
|
||||
* - Previous fix: limited auto-select to boards.length === 1 — but in
|
||||
* company mode with exactly 1 session (1 board) this STILL flickered
|
||||
* when no session was selected.
|
||||
* - Current fix: BoardStore does NOT auto-select at all. Selection is
|
||||
* entirely driven by the parent (WorkspacePage) which knows the mode.
|
||||
* initFromBackend only clears when the prior selection disappears.
|
||||
*/
|
||||
|
||||
// initFromBackend: only preserve-or-clear, no auto-default
|
||||
function resolveActiveBoardAfterInit(
|
||||
prev: string | null,
|
||||
bds: { id: string }[],
|
||||
): string | null {
|
||||
return prev && bds.some(b => b.id === prev) ? prev : null
|
||||
}
|
||||
|
||||
// Parent-driven selection (simulates WorkspacePage useEffect)
|
||||
function parentChooseBoard(opts: {
|
||||
isCompanyMode: boolean
|
||||
activeSessionBoardId: string | null
|
||||
boards: { id: string }[]
|
||||
currentActive: string | null
|
||||
}): string | null {
|
||||
const { isCompanyMode, activeSessionBoardId, boards, currentActive } = opts
|
||||
const hasId = (id: string | null) => !!id && boards.some(b => b.id === id)
|
||||
if (isCompanyMode) {
|
||||
if (activeSessionBoardId && hasId(activeSessionBoardId)) return activeSessionBoardId
|
||||
return null
|
||||
}
|
||||
if (currentActive && hasId(currentActive)) return currentActive
|
||||
return boards.length > 0 ? boards[0].id : null
|
||||
}
|
||||
|
||||
// ── initFromBackend ──────────────────────────────────────────────────────
|
||||
|
||||
// Single board: do NOT auto-select (parent picks)
|
||||
assert.strictEqual(
|
||||
resolveActiveBoardAfterInit(null, [{ id: 'project-board' }]),
|
||||
null,
|
||||
'init with null prev stays null even with 1 board',
|
||||
)
|
||||
|
||||
// Preserve valid prior
|
||||
assert.strictEqual(
|
||||
resolveActiveBoardAfterInit('session-a', [{ id: 'session-a' }, { id: 'session-b' }]),
|
||||
'session-a',
|
||||
'preserves existing active when still present',
|
||||
)
|
||||
|
||||
// Clear stale
|
||||
assert.strictEqual(
|
||||
resolveActiveBoardAfterInit('deleted', [{ id: 'session-a' }]),
|
||||
null,
|
||||
'clears stale active',
|
||||
)
|
||||
|
||||
// Empty
|
||||
assert.strictEqual(
|
||||
resolveActiveBoardAfterInit(null, []),
|
||||
null,
|
||||
'empty boards → null',
|
||||
)
|
||||
|
||||
// ── Parent-driven selection ──────────────────────────────────────────────
|
||||
|
||||
// Company mode: no session → null (shows empty state)
|
||||
assert.strictEqual(
|
||||
parentChooseBoard({
|
||||
isCompanyMode: true,
|
||||
activeSessionBoardId: null,
|
||||
boards: [{ id: 'session-a' }],
|
||||
currentActive: null,
|
||||
}),
|
||||
null,
|
||||
'company mode + no session → null',
|
||||
)
|
||||
|
||||
// Company mode: session selected, its board exists → select it
|
||||
assert.strictEqual(
|
||||
parentChooseBoard({
|
||||
isCompanyMode: true,
|
||||
activeSessionBoardId: 'session-a',
|
||||
boards: [{ id: 'session-a' }, { id: 'session-b' }],
|
||||
currentActive: null,
|
||||
}),
|
||||
'session-a',
|
||||
'company mode + session with board → select session board',
|
||||
)
|
||||
|
||||
// Company mode: session selected but its board doesn't exist yet → null
|
||||
assert.strictEqual(
|
||||
parentChooseBoard({
|
||||
isCompanyMode: true,
|
||||
activeSessionBoardId: 'new-session',
|
||||
boards: [{ id: 'session-a' }],
|
||||
currentActive: null,
|
||||
}),
|
||||
null,
|
||||
'company mode + new session (no board yet) → null (empty state)',
|
||||
)
|
||||
|
||||
// Non-company mode: auto-select project board
|
||||
assert.strictEqual(
|
||||
parentChooseBoard({
|
||||
isCompanyMode: false,
|
||||
activeSessionBoardId: null,
|
||||
boards: [{ id: 'project-board' }],
|
||||
currentActive: null,
|
||||
}),
|
||||
'project-board',
|
||||
'non-company mode → auto-select project board',
|
||||
)
|
||||
|
||||
// ── Flicker regression: 1-session company mode, no session selected ──────
|
||||
// The original bug: boards.length===1 triggered auto-select, parent cleared
|
||||
// → loop. With the current contract, BOTH init and parent agree on null.
|
||||
{
|
||||
const boards = [{ id: 'session-a' }]
|
||||
const afterInit = resolveActiveBoardAfterInit(null, boards)
|
||||
assert.strictEqual(afterInit, null, 'init: null with 1 board stays null')
|
||||
const afterParent = parentChooseBoard({
|
||||
isCompanyMode: true,
|
||||
activeSessionBoardId: null,
|
||||
boards,
|
||||
currentActive: afterInit,
|
||||
})
|
||||
assert.strictEqual(afterParent, null, 'parent: 1-session company + no active → null (stable, no loop)')
|
||||
}
|
||||
|
||||
console.log('BoardStore selection contract passed')
|
||||
@@ -0,0 +1,224 @@
|
||||
import { useCallback, useEffect, useMemo, useReducer, useState } from 'react'
|
||||
import type { KanbanBoard, KanbanColumn, KanbanTask, TaskPriority } from '../types/kanban'
|
||||
import { deriveColumnFromPhase } from '../lib/phaseHelpers'
|
||||
|
||||
function uid(): string {
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
|
||||
}
|
||||
|
||||
type BoardAction =
|
||||
| { type: 'SET'; boards: KanbanBoard[] }
|
||||
| { type: 'UPDATE_NAME'; boardId: string; name: string }
|
||||
|
||||
function boardReducer(state: KanbanBoard[], action: BoardAction): KanbanBoard[] {
|
||||
switch (action.type) {
|
||||
case 'SET': return action.boards
|
||||
case 'UPDATE_NAME':
|
||||
return state.map(b => b.id === action.boardId ? { ...b, name: action.name } : b)
|
||||
default: return state
|
||||
}
|
||||
}
|
||||
|
||||
type ColumnAction =
|
||||
| { type: 'SET'; columns: KanbanColumn[] }
|
||||
|
||||
function columnReducer(state: KanbanColumn[], action: ColumnAction): KanbanColumn[] {
|
||||
switch (action.type) {
|
||||
case 'SET': return action.columns
|
||||
default: return state
|
||||
}
|
||||
}
|
||||
|
||||
type TaskAction =
|
||||
| { type: 'SET'; tasks: KanbanTask[] }
|
||||
| { type: 'ADD'; task: KanbanTask }
|
||||
| { type: 'UPDATE'; id: string; partial: Partial<KanbanTask> }
|
||||
| { type: 'DELETE'; id: string }
|
||||
| { type: 'MOVE'; id: string; columnId: string; sortOrder: number }
|
||||
| { type: 'ASSIGN'; taskId: string; agentIds: string[] }
|
||||
| { type: 'REMOVE_ASSIGNEE'; agentId: string }
|
||||
|
||||
function taskReducer(state: KanbanTask[], action: TaskAction): KanbanTask[] {
|
||||
const now = Date.now()
|
||||
switch (action.type) {
|
||||
case 'SET': return action.tasks
|
||||
case 'ADD': return state.some(t => t.id === action.task.id) ? state : [...state, action.task]
|
||||
case 'UPDATE': return state.map(t => t.id === action.id ? { ...t, ...action.partial, updatedAt: now } : t)
|
||||
case 'DELETE': return state.filter(t => t.id !== action.id)
|
||||
case 'MOVE': return state.map(t => t.id === action.id ? { ...t, columnId: action.columnId, sortOrder: action.sortOrder, updatedAt: now } : t)
|
||||
case 'ASSIGN': return state.map(t => t.id === action.taskId ? { ...t, assigneeIds: action.agentIds, updatedAt: now } : t)
|
||||
case 'REMOVE_ASSIGNEE': return state.map(t =>
|
||||
t.assigneeIds.includes(action.agentId)
|
||||
? { ...t, assigneeIds: t.assigneeIds.filter(a => a !== action.agentId), updatedAt: now }
|
||||
: t
|
||||
)
|
||||
default: return state
|
||||
}
|
||||
}
|
||||
|
||||
export interface BoardStoreState {
|
||||
scopeProjectId: string
|
||||
boards: KanbanBoard[]
|
||||
columns: KanbanColumn[]
|
||||
tasks: KanbanTask[]
|
||||
activeBoardId: string | null
|
||||
activeBoard: KanbanBoard | null
|
||||
activeBoardColumns: KanbanColumn[]
|
||||
tasksByColumn: Record<string, KanbanTask[]>
|
||||
setActiveBoard: (boardId: string | null) => void
|
||||
|
||||
createTask: (opts: { boardId: string; columnId: string; title: string; description?: string; priority?: TaskPriority | null; assigneeIds?: string[]; tags?: string[]; taskId?: string; displayId?: string }) => KanbanTask
|
||||
updateTask: (id: string, partial: Partial<KanbanTask>) => void
|
||||
deleteTask: (id: string) => void
|
||||
moveTask: (id: string, columnId: string, sortOrder: number) => void
|
||||
assignTask: (taskId: string, agentIds: string[]) => void
|
||||
|
||||
dispatchTask: (action: TaskAction) => void
|
||||
getOpenTaskCount: () => number
|
||||
removeAssignee: (agentId: string) => void
|
||||
initFromBackend: (
|
||||
projectId: string,
|
||||
boards: KanbanBoard[],
|
||||
columns: KanbanColumn[],
|
||||
tasks: KanbanTask[],
|
||||
options?: { preserveTasksWhenIncomingEmpty?: boolean },
|
||||
) => void
|
||||
updateBoardName: (boardId: string, name: string) => void
|
||||
}
|
||||
|
||||
export function useBoardStore(): BoardStoreState {
|
||||
const [boards, dispatchBoard] = useReducer(boardReducer, [])
|
||||
const [columns, dispatchCol] = useReducer(columnReducer, [])
|
||||
const [tasks, dispatchTask] = useReducer(taskReducer, [])
|
||||
const [activeBoardId, setActiveBoardId] = useState<string | null>(null)
|
||||
const [scopeProjectId, setScopeProjectId] = useState<string>('default')
|
||||
|
||||
// NOTE: no auto-select logic here. Board selection is driven entirely by
|
||||
// the parent (WorkspacePage) which knows the execution mode:
|
||||
// - Non-company mode → 1 project board, parent sets it once.
|
||||
// - Company mode → 1 board per session, parent syncs to activeSession.
|
||||
// Having BoardStore auto-select to boards[0] would race with the parent's
|
||||
// session-driven clear, producing a render loop (screen flicker).
|
||||
|
||||
const activeBoard = useMemo(() => boards.find(b => b.id === activeBoardId) ?? null, [boards, activeBoardId])
|
||||
|
||||
const activeBoardColumns = useMemo(() =>
|
||||
columns.filter(c => c.boardId === activeBoardId).sort((a, b) => a.sortOrder - b.sortOrder),
|
||||
[columns, activeBoardId]
|
||||
)
|
||||
|
||||
// All tasks for active board, sorted by column.
|
||||
//
|
||||
// Column placement: prefer deriving from `phase` (the authoritative
|
||||
// single-source-of-truth field from the backend) and fall back to the
|
||||
// backend-supplied `columnId` only when phase is missing. During the
|
||||
// transition window both fields should agree — in dev mode we warn
|
||||
// loudly when they don't so the drift is caught immediately.
|
||||
const tasksByColumn = useMemo(() => {
|
||||
const boardTasks = tasks.filter(t => t.boardId === activeBoardId)
|
||||
const map: Record<string, KanbanTask[]> = {}
|
||||
for (const col of activeBoardColumns) map[col.id] = []
|
||||
for (const t of boardTasks) {
|
||||
const derived = t.phase ? deriveColumnFromPhase(t.phase) : t.columnId
|
||||
if (import.meta.env.DEV && t.phase && t.columnId && derived !== t.columnId) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`[phase/columnId drift] task=${t.id} phase=${t.phase} derived=${derived} backendColumn=${t.columnId}`,
|
||||
)
|
||||
}
|
||||
if (map[derived]) map[derived].push(t)
|
||||
}
|
||||
for (const key of Object.keys(map)) {
|
||||
map[key].sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
}
|
||||
return map
|
||||
}, [tasks, activeBoardId, activeBoardColumns])
|
||||
|
||||
const createTask = useCallback((opts: {
|
||||
boardId: string; columnId: string; title: string;
|
||||
description?: string; priority?: TaskPriority | null;
|
||||
assigneeIds?: string[]; tags?: string[];
|
||||
taskId?: string; displayId?: string
|
||||
}) => {
|
||||
const board = boards.find(b => b.id === opts.boardId)
|
||||
const num = board?.nextTaskNum ?? 1
|
||||
const prefix = board?.prefix ?? 'T'
|
||||
const task: KanbanTask = {
|
||||
id: opts.taskId ?? `task-${uid()}`,
|
||||
displayId: opts.displayId ?? `${prefix}-${String(num).padStart(3, '0')}`,
|
||||
boardId: opts.boardId,
|
||||
columnId: opts.columnId,
|
||||
title: opts.title,
|
||||
description: opts.description,
|
||||
priority: opts.priority ?? null,
|
||||
assigneeIds: opts.assigneeIds ?? [],
|
||||
tags: opts.tags ?? [],
|
||||
sortOrder: num,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
dispatchTask({ type: 'ADD', task })
|
||||
return task
|
||||
}, [boards])
|
||||
|
||||
const updateTask = useCallback((id: string, partial: Partial<KanbanTask>) => dispatchTask({ type: 'UPDATE', id, partial }), [])
|
||||
const deleteTask = useCallback((id: string) => dispatchTask({ type: 'DELETE', id }), [])
|
||||
const moveTask = useCallback((id: string, columnId: string, sortOrder: number) => dispatchTask({ type: 'MOVE', id, columnId, sortOrder }), [])
|
||||
const assignTask = useCallback((taskId: string, agentIds: string[]) => dispatchTask({ type: 'ASSIGN', taskId, agentIds }), [])
|
||||
|
||||
const getOpenTaskCount = useCallback(() => {
|
||||
const terminalColIds = new Set(columns.filter(c => c.isTerminal).map(c => c.id))
|
||||
return tasks.filter(t => !terminalColIds.has(t.columnId)).length
|
||||
}, [tasks, columns])
|
||||
|
||||
const removeAssignee = useCallback((agentId: string) => {
|
||||
dispatchTask({ type: 'REMOVE_ASSIGNEE', agentId })
|
||||
}, [])
|
||||
|
||||
const initFromBackend = useCallback((
|
||||
projectId: string,
|
||||
bds: KanbanBoard[],
|
||||
cols: KanbanColumn[],
|
||||
tks: KanbanTask[],
|
||||
options?: { preserveTasksWhenIncomingEmpty?: boolean },
|
||||
) => {
|
||||
const nextProjectId = projectId || 'default'
|
||||
const projectChanged = nextProjectId !== scopeProjectId
|
||||
const shouldPreserveTasks =
|
||||
!projectChanged
|
||||
&& !!options?.preserveTasksWhenIncomingEmpty
|
||||
&& tks.length === 0
|
||||
setScopeProjectId(nextProjectId)
|
||||
dispatchBoard({ type: 'SET', boards: bds })
|
||||
dispatchCol({ type: 'SET', columns: cols })
|
||||
dispatchTask({ type: 'SET', tasks: shouldPreserveTasks ? tasks : tks })
|
||||
// Only reset activeBoardId when the current selection no longer exists.
|
||||
// Otherwise preserve the parent's choice. Never auto-default to boards[0]
|
||||
// here — the parent decides based on execution mode.
|
||||
setActiveBoardId(prev => (!projectChanged && prev && bds.some(b => b.id === prev) ? prev : null))
|
||||
}, [scopeProjectId, tasks])
|
||||
|
||||
const setActiveBoard = useCallback((boardId: string | null) => {
|
||||
setActiveBoardId(boardId)
|
||||
}, [])
|
||||
|
||||
const updateBoardName = useCallback((boardId: string, name: string) => {
|
||||
dispatchBoard({ type: 'UPDATE_NAME', boardId, name })
|
||||
}, [])
|
||||
|
||||
return useMemo(() => ({
|
||||
scopeProjectId, boards, columns, tasks, activeBoardId, activeBoard, activeBoardColumns,
|
||||
tasksByColumn,
|
||||
setActiveBoard,
|
||||
createTask, updateTask, deleteTask, moveTask, assignTask,
|
||||
dispatchTask, getOpenTaskCount,
|
||||
removeAssignee, initFromBackend, updateBoardName,
|
||||
}), [
|
||||
scopeProjectId, boards, columns, tasks, activeBoardId, activeBoard, activeBoardColumns,
|
||||
tasksByColumn,
|
||||
setActiveBoard,
|
||||
createTask, updateTask, deleteTask, moveTask, assignTask,
|
||||
dispatchTask, getOpenTaskCount,
|
||||
removeAssignee, initFromBackend, updateBoardName,
|
||||
])
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type {
|
||||
ProgressEntry,
|
||||
RoleAggregatedStatus,
|
||||
RoleWorkItemActivitySection,
|
||||
RoleWorkItemRow,
|
||||
RoleWorkItemSummary,
|
||||
} from '../types/kanban'
|
||||
import type { AgentInfo } from '../types/visual'
|
||||
import { AgentProgressBlock } from '../chat/AgentProgressBlock'
|
||||
import { IconClose, IconTimeline, IconWorkItem } from '../chat/SvgIcons'
|
||||
|
||||
interface ExecutionPanelProps {
|
||||
role: RoleWorkItemSummary
|
||||
focusedWorkItemId?: string
|
||||
focusedExecutionTurnId?: string
|
||||
agents: AgentInfo[]
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const ROLE_STATUS_BADGE: Record<RoleAggregatedStatus, { label: string; cls: string }> = {
|
||||
active: { label: 'Working', cls: 'exec-badge-running' },
|
||||
waiting: { label: 'Waiting', cls: 'exec-badge-idle' },
|
||||
pending: { label: 'Pending', cls: 'exec-badge-pending' },
|
||||
done: { label: 'Done', cls: 'exec-badge-done' },
|
||||
failed: { label: 'Failed', cls: 'exec-badge-failed' },
|
||||
}
|
||||
|
||||
const ROW_STATUS_BADGE: Record<string, { label: string; cls: string }> = {
|
||||
todo: { label: 'To do', cls: 'exec-badge-pending' },
|
||||
'in-progress': { label: 'In progress', cls: 'exec-badge-running' },
|
||||
'in-review': { label: 'In review', cls: 'exec-badge-idle' },
|
||||
done: { label: 'Done', cls: 'exec-badge-done' },
|
||||
failed: { label: 'Failed', cls: 'exec-badge-failed' },
|
||||
cancelled: { label: 'Cancelled', cls: 'exec-badge-cancelled' },
|
||||
}
|
||||
|
||||
function formatRelativeTime(ts: number): string {
|
||||
const sec = Math.floor((Date.now() - ts) / 1000)
|
||||
if (sec < 5) return 'now'
|
||||
if (sec < 60) return `${sec}s ago`
|
||||
const min = Math.floor(sec / 60)
|
||||
if (min < 60) return `${min}m ago`
|
||||
const hr = Math.floor(min / 60)
|
||||
if (hr < 24) return `${hr}h ago`
|
||||
return `${Math.floor(hr / 24)}d ago`
|
||||
}
|
||||
|
||||
function humanize(value?: string): string {
|
||||
const text = String(value ?? '').trim()
|
||||
if (!text) return ''
|
||||
return text.replace(/[_-]/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
|
||||
}
|
||||
|
||||
function rowBadge(row: RoleWorkItemRow): { label: string; cls: string } {
|
||||
if (row.phase === 'failed') return ROW_STATUS_BADGE.failed
|
||||
if (row.phase === 'cancelled') return ROW_STATUS_BADGE.cancelled
|
||||
return ROW_STATUS_BADGE[row.kanbanColumn] ?? ROW_STATUS_BADGE.todo
|
||||
}
|
||||
|
||||
function rowSessionStatus(row: RoleWorkItemRow): string | undefined {
|
||||
if (row.phase === 'failed') return 'failed'
|
||||
if (row.phase === 'cancelled') return 'cancelled'
|
||||
if (row.kanbanColumn === 'done') return 'done'
|
||||
return undefined
|
||||
}
|
||||
|
||||
function countActivityEntries(row: RoleWorkItemRow): number {
|
||||
const sections = row.activitySections ?? []
|
||||
if (sections.length > 0) {
|
||||
return sections.reduce((count, section) => count + (section.entries?.length ?? 0), 0)
|
||||
}
|
||||
return row.progressLog.length
|
||||
}
|
||||
|
||||
function ActivitySections({
|
||||
sections,
|
||||
fallbackEntries,
|
||||
sessionStatus,
|
||||
}: {
|
||||
sections?: RoleWorkItemActivitySection[]
|
||||
fallbackEntries?: ProgressEntry[]
|
||||
sessionStatus?: string
|
||||
}) {
|
||||
const visibleSections = (sections ?? []).filter(section => (
|
||||
(section.entries?.length ?? 0) > 0 || !!section.runtimeTaskId
|
||||
))
|
||||
|
||||
if (visibleSections.length > 0) {
|
||||
return (
|
||||
<div className="exec-activity-sections">
|
||||
{visibleSections.map((section, index) => {
|
||||
const entries = section.entries ?? []
|
||||
const key = `${section.runtimeTaskId || section.kind}:${index}`
|
||||
return (
|
||||
<section key={key} className="exec-activity-section">
|
||||
<div className="exec-activity-section-head">
|
||||
<span className="exec-activity-section-title">{section.title}</span>
|
||||
{section.roleName && (
|
||||
<span className="exec-activity-section-role">{section.roleName}</span>
|
||||
)}
|
||||
{entries.length > 0 && (
|
||||
<span className="exec-section-count">{entries.length}</span>
|
||||
)}
|
||||
</div>
|
||||
{entries.length > 0 ? (
|
||||
<AgentProgressBlock
|
||||
entries={entries}
|
||||
sessionStatus={sessionStatus}
|
||||
expandedByDefault
|
||||
/>
|
||||
) : (
|
||||
<div className="exec-section-empty">No runtime activity yet</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!fallbackEntries || fallbackEntries.length === 0) {
|
||||
return <div className="exec-section-empty">No activity recorded yet</div>
|
||||
}
|
||||
return (
|
||||
<AgentProgressBlock
|
||||
entries={fallbackEntries}
|
||||
sessionStatus={sessionStatus}
|
||||
expandedByDefault
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function ExecutionPanel({
|
||||
role,
|
||||
focusedWorkItemId,
|
||||
focusedExecutionTurnId,
|
||||
agents,
|
||||
onClose,
|
||||
}: ExecutionPanelProps) {
|
||||
const rows = useMemo(() => (
|
||||
role.workItems
|
||||
.slice()
|
||||
.sort((a, b) => a.createdAt - b.createdAt)
|
||||
), [role.workItems])
|
||||
|
||||
const focused = useMemo(() => (
|
||||
rows.find(row => (
|
||||
(!!focusedWorkItemId && row.workItemId === focusedWorkItemId)
|
||||
|| (!!focusedExecutionTurnId && row.executionTurnId === focusedExecutionTurnId)
|
||||
)) ?? rows[rows.length - 1] ?? null
|
||||
), [focusedExecutionTurnId, focusedWorkItemId, rows])
|
||||
|
||||
const focusedRowKey = focused?.workItemId
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(() => {
|
||||
const init = new Set<string>()
|
||||
if (focusedRowKey) init.add(focusedRowKey)
|
||||
return init
|
||||
})
|
||||
const autoExpandedRef = useRef<Set<string>>(new Set())
|
||||
useEffect(() => {
|
||||
if (!focusedRowKey || autoExpandedRef.current.has(focusedRowKey)) return
|
||||
autoExpandedRef.current.add(focusedRowKey)
|
||||
setExpandedIds(prev => {
|
||||
if (prev.has(focusedRowKey)) return prev
|
||||
const next = new Set(prev)
|
||||
next.add(focusedRowKey)
|
||||
return next
|
||||
})
|
||||
}, [focusedRowKey])
|
||||
|
||||
const toggleExpanded = useCallback((workItemId: string) => {
|
||||
setExpandedIds(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(workItemId)) next.delete(workItemId)
|
||||
else next.add(workItemId)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const focusedCardRef = useRef<HTMLDivElement | null>(null)
|
||||
const lastScrolledIdRef = useRef<string | null>(null)
|
||||
useEffect(() => {
|
||||
if (!focusedRowKey || lastScrolledIdRef.current === focusedRowKey) return
|
||||
lastScrolledIdRef.current = focusedRowKey
|
||||
focusedCardRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||
}, [focusedRowKey])
|
||||
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}, [onClose])
|
||||
useEffect(() => {
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [handleKeyDown])
|
||||
|
||||
const roleAgent = agents.find(agent => agent.agent_id === role.roleId)
|
||||
const roleName = role.roleName || roleAgent?.name || humanize(role.roleId) || 'Role'
|
||||
const headerBadge = ROLE_STATUS_BADGE[role.aggregatedStatus] ?? ROLE_STATUS_BADGE.pending
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="exec-panel-backdrop" onClick={onClose} />
|
||||
<div className="exec-panel">
|
||||
<div className="exec-panel-header">
|
||||
<div className="exec-panel-title-row">
|
||||
<IconTimeline />
|
||||
<h3 className="exec-panel-title">{roleName}</h3>
|
||||
<span className={`exec-badge ${headerBadge.cls}`}>{headerBadge.label}</span>
|
||||
<span className="exec-panel-task-count">
|
||||
{rows.length} Work Item{rows.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
</div>
|
||||
<button className="exec-panel-close" onClick={onClose} title="Close (Esc)">
|
||||
<IconClose />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="exec-panel-identity">
|
||||
<div className="exec-panel-avatar">
|
||||
{roleName.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="exec-panel-agent-info">
|
||||
<span className="exec-panel-agent-name">{roleName}</span>
|
||||
<span className="exec-panel-agent-role">{humanize(role.roleId)}</span>
|
||||
{role.roleSessionId && (
|
||||
<span className="exec-panel-employee">{role.roleSessionId}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="exec-panel-body">
|
||||
{rows.length === 0 && (
|
||||
<div className="exec-section-empty">No work items yet</div>
|
||||
)}
|
||||
{rows.map((row, index) => {
|
||||
const isFocused = row.workItemId === focusedRowKey
|
||||
const isExpanded = expandedIds.has(row.workItemId)
|
||||
const badge = rowBadge(row)
|
||||
const activityCount = countActivityEntries(row)
|
||||
return (
|
||||
<div
|
||||
key={row.workItemId}
|
||||
ref={isFocused ? focusedCardRef : null}
|
||||
className={`exec-task-card${isFocused ? ' exec-task-card-focused' : ''}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="exec-task-card-header"
|
||||
onClick={() => toggleExpanded(row.workItemId)}
|
||||
title={isExpanded ? 'Collapse' : 'Expand'}
|
||||
>
|
||||
<span className="exec-task-card-index">Work item #{index + 1}</span>
|
||||
<span className="exec-task-card-title">{row.title || row.workItemId}</span>
|
||||
<span className={`exec-badge ${badge.cls}`}>{badge.label}</span>
|
||||
<span className="exec-task-card-time">{formatRelativeTime(row.updatedAt)}</span>
|
||||
<span className={`exec-task-card-chevron${isExpanded ? ' open' : ''}`} aria-hidden="true">
|
||||
▸
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="exec-task-card-body">
|
||||
<div className="exec-task-card-projection">
|
||||
<IconWorkItem />
|
||||
<span>{humanize(row.kind) || 'Work item'}</span>
|
||||
{row.workItemProjectionId && <code>{row.workItemProjectionId}</code>}
|
||||
{row.executionTurnId && <span className="exec-inline-tag">Execution Turn</span>}
|
||||
{row.isReviewTarget && <span className="exec-inline-tag">Review target</span>}
|
||||
{row.executorRoleName && <span>{row.executorRoleName}</span>}
|
||||
</div>
|
||||
|
||||
<div className="exec-section">
|
||||
<div className="exec-section-header">
|
||||
<IconTimeline />
|
||||
<span>Activity</span>
|
||||
<span className="exec-section-count">{activityCount}</span>
|
||||
</div>
|
||||
<div className="exec-section-content exec-activity-scroll">
|
||||
<ActivitySections
|
||||
sections={row.activitySections}
|
||||
fallbackEntries={row.progressLog}
|
||||
sessionStatus={rowSessionStatus(row)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { DragDropContext, type DropResult } from '@hello-pangea/dnd'
|
||||
import type { AgentInfo } from '../types/visual'
|
||||
import type { KanbanColumn as KanbanColumnType, KanbanTask } from '../types/kanban'
|
||||
import type { BoardStoreState } from './BoardStore'
|
||||
import { KanbanColumn } from './KanbanColumn'
|
||||
|
||||
interface KanbanBoardViewProps {
|
||||
columns: KanbanColumnType[]
|
||||
tasksByColumn: Record<string, KanbanTask[]>
|
||||
agents: AgentInfo[]
|
||||
officeMap?: Record<string, string>
|
||||
store: BoardStoreState
|
||||
companyMode?: boolean
|
||||
selectedTaskId?: string | null
|
||||
onCardClick: (task: KanbanTask) => void
|
||||
onStartTask?: (taskId: string) => void
|
||||
onQuickCreate?: (title: string) => void
|
||||
onMoveTask?: (taskId: string, columnId: string) => void
|
||||
}
|
||||
|
||||
export function KanbanBoardView({
|
||||
columns, tasksByColumn, agents, officeMap, store, companyMode, selectedTaskId, onCardClick, onStartTask, onQuickCreate, onMoveTask,
|
||||
}: KanbanBoardViewProps) {
|
||||
|
||||
const handleDragEnd = (result: DropResult) => {
|
||||
if (companyMode) return
|
||||
if (!result.destination) return
|
||||
|
||||
const srcColId = result.source.droppableId
|
||||
const destColId = result.destination.droppableId
|
||||
|
||||
if (srcColId !== destColId) {
|
||||
// All column transitions are automatic (driven by backend status).
|
||||
// No manual drag between columns.
|
||||
return
|
||||
}
|
||||
|
||||
// Same-column reorder — compute new sort orders atomically
|
||||
const destIndex = result.destination.index
|
||||
const taskId = result.draggableId
|
||||
const ordered = [...(tasksByColumn[destColId] ?? [])].filter(t => t.id !== taskId)
|
||||
const draggedTask = (tasksByColumn[destColId] ?? []).find(t => t.id === taskId)
|
||||
if (draggedTask) ordered.splice(destIndex, 0, draggedTask)
|
||||
ordered.forEach((t, i) => {
|
||||
store.moveTask(t.id, destColId, i)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<DragDropContext onDragEnd={handleDragEnd}>
|
||||
<div className="kanban-board">
|
||||
{columns.map(col => (
|
||||
<KanbanColumn
|
||||
key={col.id}
|
||||
column={col}
|
||||
tasks={tasksByColumn[col.id] ?? []}
|
||||
agents={agents}
|
||||
officeMap={officeMap}
|
||||
companyMode={companyMode}
|
||||
selectedTaskId={selectedTaskId}
|
||||
onCardClick={onCardClick}
|
||||
onStartTask={!companyMode && col.name === 'Todo' ? onStartTask : undefined}
|
||||
onQuickCreate={!companyMode && col.name === 'Todo' ? onQuickCreate : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</DragDropContext>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { Draggable } from '@hello-pangea/dnd'
|
||||
import type { AgentInfo } from '../types/visual'
|
||||
import { PRIORITY_META, AGENT_STATUS_LABEL, type KanbanTask } from '../types/kanban'
|
||||
import { getWorkItemRoleLabel, humanizeWorkItemRoleId } from '../lib/workItemIdentity'
|
||||
import { getLinkedRuntimeTaskId } from '../lib/workItemRuntimeIds'
|
||||
|
||||
const STATUS_BADGE: Record<string, { label: string; color: string }> = {
|
||||
todo: { label: 'To do', color: '#9ca3af' },
|
||||
in_progress: { label: 'In progress', color: '#f59e0b' },
|
||||
in_review: { label: 'In review', color: '#fbbf24' },
|
||||
done: { label: 'Done', color: '#34d399' },
|
||||
running: { label: 'Running', color: '#34d399' },
|
||||
idle: { label: 'Idle', color: '#6366f1' },
|
||||
blocked: { label: 'Blocked', color: '#f97316' },
|
||||
awaiting_peer: { label: 'Awaiting', color: '#fbbf24' },
|
||||
awaiting_manager_review: { label: 'Mgr Review', color: '#fbbf24' },
|
||||
awaiting_human: { label: 'Human Review', color: '#fbbf24' },
|
||||
awaiting_review: { label: 'In Review', color: '#fbbf24' },
|
||||
failed: { label: 'Failed', color: '#ef4444' },
|
||||
cancelled: { label: 'Cancelled', color: '#9ca3af' },
|
||||
}
|
||||
|
||||
interface KanbanCardProps {
|
||||
task: KanbanTask
|
||||
index: number
|
||||
agents: AgentInfo[]
|
||||
officeMap?: Record<string, string>
|
||||
companyMode?: boolean
|
||||
isSelected?: boolean
|
||||
onClick: (task: KanbanTask) => void
|
||||
onStart?: (taskId: string) => void
|
||||
}
|
||||
|
||||
export function KanbanCard({ task, index, agents, officeMap, companyMode, isSelected, onClick, onStart }: KanbanCardProps) {
|
||||
const assignees = task.assigneeIds
|
||||
.map(id => agents.find(a => a.agent_id === id))
|
||||
.filter(Boolean) as AgentInfo[]
|
||||
const priority = task.priority ? PRIORITY_META[task.priority] : null
|
||||
|
||||
const crossOffice = officeMap && assignees.length > 1 &&
|
||||
new Set(assignees.map(a => officeMap[a.agent_id]).filter(Boolean)).size > 1
|
||||
|
||||
const runtimeActive = task.agentStatus && task.agentStatus !== 'idle'
|
||||
const depCount = task.dependencies?.length ?? 0
|
||||
// Hide status badge when runtime bar is showing (avoids "Running" + "Thinking..." redundancy)
|
||||
// Also hide for 'pending' (default state, no badge needed in todo column)
|
||||
const phaseBadge = task.phase
|
||||
const statusBadge = (!runtimeActive && phaseBadge && phaseBadge !== 'ready')
|
||||
? STATUS_BADGE[phaseBadge] ?? null : null
|
||||
const employee = task.employeeAssignment
|
||||
const roleLabel = getWorkItemRoleLabel(task)
|
||||
const gate = task.workItemGate
|
||||
const managerLabel = humanizeWorkItemRoleId(task.managerRoleId)
|
||||
const blockerLabel = (task.blockedReason ?? '').trim()
|
||||
const reworkLabel = (task.reworkFeedback ?? '').trim()
|
||||
const linkedRuntimeTaskId = getLinkedRuntimeTaskId(task)
|
||||
|
||||
return (
|
||||
<Draggable draggableId={task.id} index={index} isDragDisabled={!!companyMode}>
|
||||
{(provided, snapshot) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
className={`kanban-card${snapshot.isDragging ? ' is-dragging' : ''}${runtimeActive ? ' is-active' : ''}${isSelected ? ' is-selected' : ''}`}
|
||||
data-task-id={task.id}
|
||||
onMouseUp={e => { if (e.button === 0 && !snapshot.isDragging) onClick(task) }}
|
||||
>
|
||||
<div className="kanban-card-top">
|
||||
<span className="kanban-card-id">{task.displayId}</span>
|
||||
{statusBadge && (
|
||||
<span className="kanban-status-badge" style={{ color: statusBadge.color }}>
|
||||
<span style={{ display: 'inline-block', width: 6, height: 6, borderRadius: '50%', background: statusBadge.color, marginRight: 3 }} />
|
||||
{statusBadge.label}
|
||||
</span>
|
||||
)}
|
||||
{depCount > 0 && (
|
||||
<span className="kanban-dep-badge" title={`${depCount} upstream dep(s)`}>{depCount} dep</span>
|
||||
)}
|
||||
{crossOffice && <span className="kanban-cross-badge" title="Cross-office">⇄</span>}
|
||||
{onStart && (
|
||||
<button
|
||||
className="kanban-start-btn"
|
||||
title={companyMode ? 'Start Work Item' : 'Start task'}
|
||||
onClick={e => { e.stopPropagation(); onStart(task.id) }}
|
||||
>
|
||||
▶
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="kanban-card-title">{task.title}</p>
|
||||
|
||||
{(roleLabel || employee?.name || gate?.type || task.originChannel || task.workItemProjectionId) && (
|
||||
<div className="kanban-card-meta-row">
|
||||
{roleLabel && (
|
||||
<span className="kanban-meta-badge kanban-role-badge" title={`Role: ${roleLabel}`}>
|
||||
{roleLabel}
|
||||
</span>
|
||||
)}
|
||||
{employee?.name && (
|
||||
<span className="kanban-meta-badge kanban-employee-badge" title={`Employee: ${employee.name}${employee.category ? ` (${employee.category})` : ''}`}>
|
||||
<span className="kanban-meta-icon">👤</span>
|
||||
{employee.name}
|
||||
</span>
|
||||
)}
|
||||
{task.workItemProjectionId && (
|
||||
<span className="kanban-meta-badge kanban-projection-badge" title={`Projection: ${task.workItemProjectionId}`}>
|
||||
{task.workItemProjectionId}
|
||||
</span>
|
||||
)}
|
||||
{gate?.type && (
|
||||
<span className={`kanban-meta-badge kanban-gate-badge kanban-gate-${gate.type}`} title={`Gate: ${gate.type}${gate.reviewerRole ? ` by ${gate.reviewerRole}` : ''}`}>
|
||||
{gate.type === 'review' ? '\u2709' : gate.type === 'approval' ? '\u2713' : '\u270B'}
|
||||
{gate.type}
|
||||
</span>
|
||||
)}
|
||||
{task.originChannel && (
|
||||
<span className="kanban-meta-badge kanban-origin-badge" title={`Origin: ${task.originChannel}`}>
|
||||
#{task.originChannel}
|
||||
</span>
|
||||
)}
|
||||
{managerLabel && (
|
||||
<span className="kanban-meta-badge" title={`Manager: ${managerLabel}`}>
|
||||
{managerLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(blockerLabel || reworkLabel || linkedRuntimeTaskId) && (
|
||||
<div className="kanban-card-tags">
|
||||
{blockerLabel && <span className="kanban-tag">{blockerLabel}</span>}
|
||||
{reworkLabel && <span className="kanban-tag">{reworkLabel}</span>}
|
||||
{linkedRuntimeTaskId && (
|
||||
<span className="kanban-tag" title={`Execution Turn: ${linkedRuntimeTaskId}`}>
|
||||
Runtime
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{runtimeActive && (
|
||||
<div className={`kanban-card-runtime status-${task.agentStatus}`}>
|
||||
<span className="kanban-runtime-dot" />
|
||||
<span className="kanban-runtime-label">
|
||||
{task.agentStatus === 'tool_active' && task.currentTool
|
||||
? task.currentTool
|
||||
: AGENT_STATUS_LABEL[task.agentStatus!] ?? task.agentStatus}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{task.tags.length > 0 && (
|
||||
<div className="kanban-card-tags">
|
||||
{task.tags.slice(0, 3).map(tag => (
|
||||
<span key={tag} className="kanban-tag">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(priority || assignees.length > 0) && (
|
||||
<div className="kanban-card-footer">
|
||||
<div className="kanban-card-footer-left">
|
||||
{priority && (
|
||||
<span className="kanban-priority" style={{ color: priority.color }} title={priority.label}>
|
||||
{priority.symbol}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="kanban-assignee-group">
|
||||
{assignees.slice(0, 3).map(a => (
|
||||
<span key={a.agent_id} className="kanban-assignee-badge" title={a.name}>
|
||||
{a.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
))}
|
||||
{assignees.length > 3 && (
|
||||
<span className="kanban-assignee-badge kanban-assignee-more">+{assignees.length - 3}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { Droppable } from '@hello-pangea/dnd'
|
||||
import type { AgentInfo } from '../types/visual'
|
||||
import type { KanbanColumn as KanbanColumnType, KanbanTask } from '../types/kanban'
|
||||
import { KanbanCard } from './KanbanCard'
|
||||
|
||||
interface KanbanColumnProps {
|
||||
column: KanbanColumnType
|
||||
tasks: KanbanTask[]
|
||||
agents: AgentInfo[]
|
||||
officeMap?: Record<string, string>
|
||||
companyMode?: boolean
|
||||
selectedTaskId?: string | null
|
||||
onCardClick: (task: KanbanTask) => void
|
||||
onStartTask?: (taskId: string) => void
|
||||
onQuickCreate?: (title: string) => void
|
||||
}
|
||||
|
||||
export function KanbanColumn({ column, tasks, agents, officeMap, companyMode, selectedTaskId, onCardClick, onStartTask, onQuickCreate }: KanbanColumnProps) {
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [draft, setDraft] = useState('')
|
||||
const committedRef = useRef(false)
|
||||
|
||||
const commitAdd = useCallback(() => {
|
||||
if (committedRef.current) return // guard: prevent double-fire from Enter + onBlur
|
||||
committedRef.current = true
|
||||
const title = draft.trim()
|
||||
if (title && onQuickCreate) {
|
||||
onQuickCreate(title)
|
||||
}
|
||||
setDraft('')
|
||||
setAdding(false)
|
||||
}, [draft, onQuickCreate])
|
||||
|
||||
return (
|
||||
<div className="kanban-column">
|
||||
<div className="kanban-column-header">
|
||||
<span className="kanban-col-dot" style={{ background: column.color }} />
|
||||
<span className="kanban-col-label">{column.name}</span>
|
||||
<span className="kanban-col-count">{tasks.length}</span>
|
||||
{onQuickCreate && (
|
||||
<button className="kanban-col-add" title="Add task" onClick={() => { committedRef.current = false; setAdding(true) }}>+</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{adding && (
|
||||
<div className="kanban-quick-add">
|
||||
<input
|
||||
className="kanban-quick-input"
|
||||
value={draft}
|
||||
onChange={e => setDraft(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); commitAdd() }
|
||||
if (e.key === 'Escape') { committedRef.current = true; setDraft(''); setAdding(false) }
|
||||
}}
|
||||
onBlur={commitAdd}
|
||||
placeholder="Task title..."
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Droppable droppableId={column.id} isDropDisabled={!!companyMode}>
|
||||
{(provided) => (
|
||||
<div ref={provided.innerRef} {...provided.droppableProps} className="kanban-col-body">
|
||||
{tasks.length === 0 && !adding && (
|
||||
<div className="kanban-empty"><span className="kanban-empty-icon">·</span></div>
|
||||
)}
|
||||
{tasks.map((task, index) => (
|
||||
<KanbanCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
index={index}
|
||||
agents={agents}
|
||||
officeMap={officeMap}
|
||||
companyMode={companyMode}
|
||||
isSelected={task.id === selectedTaskId}
|
||||
onClick={onCardClick}
|
||||
onStart={onStartTask}
|
||||
/>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user