Initial commit

This commit is contained in:
LZH-YS1998
2026-07-01 17:56:31 +08:00
commit d78931979d
731 changed files with 311088 additions and 0 deletions
@@ -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">&#x21C4;</span>}
{onStart && (
<button
className="kanban-start-btn"
title={companyMode ? 'Start Work Item' : 'Start task'}
onClick={e => { e.stopPropagation(); onStart(task.id) }}
>
&#x25B6;
</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">&#x1F464;</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