2816 lines
134 KiB
TypeScript
2816 lines
134 KiB
TypeScript
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||
import ReactMarkdown from 'react-markdown'
|
||
import remarkGfm from 'remark-gfm'
|
||
import { VisualSocketClient } from './lib/wsClient'
|
||
import { PhaserGame } from './game/PhaserGame'
|
||
import { GameBridge } from './game/GameBridge'
|
||
import { CollisionEditor } from './components/CollisionEditor'
|
||
import { registerTestRunner } from './game/test/eventTestRunner'
|
||
import { getOffices, type OfficeConfig } from './game/map/OfficeStore'
|
||
import { getOfficeDeskSeats } from './game/map/InteractionZones'
|
||
import type { AgentInfo, EmployeeDetailPayload, OrgCreateMemberInput, OrgSavedCreatePayload, OrgEmployee, OrgInfoPayload, OrgRole, ReorgProposalInfo, SavedOrgSummary, SocketStatus, TalentTemplate, VisualEvent, VisualSnapshot } from './types/visual'
|
||
import { useBoardStore, type BoardStoreState } from './kanban/BoardStore'
|
||
import { WorkspacePage } from './workspace/WorkspacePage'
|
||
import { useChatStore, type ChatStoreState } from './chat/ChatStore'
|
||
import { useSessionStore, type SessionStoreState } from './stores/SessionStore'
|
||
import { useProjectStore, type ProjectStoreState } from './stores/ProjectStore'
|
||
import { ExecutionPanel } from './kanban/ExecutionPanel'
|
||
import { ProjectSelector } from './components/ProjectSelector'
|
||
import { OrgTab } from './org/OrgTab'
|
||
import { notifyTaskAssigned } from './lib/taskChatBridge'
|
||
import { mapCollabSyncPayload, mapBackendMessage, mapBackendChannel, mapBackendSession, mapBackendBoard, mapBackendColumn, mapBackendTask, mergeSessionDetailHasMore } from './lib/collabSync'
|
||
import { normalizeOrgInfoPayload } from './lib/runtimeOrg'
|
||
import { companyRuntimeControlPatchForBoardStatus } from './lib/sessionRuntime'
|
||
import { getExecutionTurnId } from './lib/workItemRuntimeIds'
|
||
import { normalizeSessionCompanyProfile, normalizeSessionExecMode } from './lib/sessionIdentity'
|
||
import { extractSessionRecruitmentByRole, sessionChannelId } from './lib/sessionRecruitment'
|
||
import { unassignAgent } from './game/map/OfficeStore'
|
||
import type { AgentAnimStatus, EmployeeAssignment, KanbanPhase, KanbanTask, RoleAggregatedStatus, RoleWorkItemSummary, Session, TaskPreferredAgent } from './types/kanban'
|
||
|
||
function readOutdoorOverrideUi(): 'auto' | 'day' | 'night' {
|
||
try {
|
||
const o = localStorage.getItem('opc_outdoor_override')
|
||
if (o === 'day' || o === 'night') return o
|
||
if (localStorage.getItem('opc_outdoor_day') === '1') return 'day'
|
||
if (localStorage.getItem('opc_outdoor_night') === '1') return 'night'
|
||
} catch { /* private mode */ }
|
||
return 'auto'
|
||
}
|
||
|
||
const MAX_LOG_ITEMS = 80
|
||
const TASK_MODE_LOW_VALUE_RUNTIME_EVENTS = new Set([
|
||
'message_start',
|
||
'message_stop',
|
||
'tool_call_delta',
|
||
'status_snapshot',
|
||
'context_usage',
|
||
'cost_update',
|
||
'task_ledger_updated',
|
||
'prompt_prefix_state',
|
||
'prompt_prefix_cache_fingerprint',
|
||
'prefetch_started',
|
||
'prefetch_completed',
|
||
'prefetch_consumed',
|
||
'durable_memory_extracted',
|
||
'durable_memory_extraction_failed',
|
||
'session_memory_updated',
|
||
'session_memory_update_failed',
|
||
'tool_batch_started',
|
||
'tool_batch_completed',
|
||
'permission_predicted',
|
||
'turn_started',
|
||
'turn_completed',
|
||
])
|
||
const SESSION_DETAIL_REFRESH_LOW_VALUE_RUNTIME_EVENTS = new Set([
|
||
'member_inbox_updated',
|
||
])
|
||
|
||
type ThemeName = 'midnight' | 'neon' | 'paper' | 'retro' | 'terminal' | 'cozy' | 'openopc'
|
||
type AppPage = 'office' | 'workspace' | 'org' | 'mapEditor'
|
||
type AppExecMode = 'task' | 'company' | 'org'
|
||
|
||
function defaultWsUrl(): string {
|
||
const wsProto = window.location.protocol === 'https:' ? 'wss' : 'ws'
|
||
return `${wsProto}://${window.location.hostname}:${window.location.port || '8765'}/ws`
|
||
}
|
||
|
||
function statusClass(status: SocketStatus): string {
|
||
if (status === 'connected') return 'ok'
|
||
if (status === 'connecting') return 'warn'
|
||
if (status === 'error') return 'error'
|
||
return 'off'
|
||
}
|
||
|
||
function normalizeCompanyProfile(value?: string): 'corporate' | 'custom' {
|
||
return normalizeSessionCompanyProfile(value)
|
||
}
|
||
|
||
function normalizeExecMode(value?: string): AppExecMode {
|
||
return normalizeSessionExecMode(value)
|
||
}
|
||
|
||
function companyProfileForExecMode(mode: AppExecMode, profile?: string): 'corporate' | 'custom' | undefined {
|
||
if (mode === 'task') return undefined
|
||
if (mode === 'org') return 'custom'
|
||
return 'corporate'
|
||
}
|
||
|
||
function orgIdForExecMode(mode: AppExecMode, orgId?: string | null): string | undefined {
|
||
if (mode !== 'org') return undefined
|
||
const normalized = String(orgId ?? '').trim()
|
||
return normalized || undefined
|
||
}
|
||
|
||
function normalizeTaskPreferredAgent(value?: string): TaskPreferredAgent {
|
||
const normalized = String(value ?? '').trim().toLowerCase().replace('-', '_')
|
||
if (normalized === 'codex' || normalized === 'claude_code' || normalized === 'cursor' || normalized === 'opencode') {
|
||
return normalized
|
||
}
|
||
return 'native'
|
||
}
|
||
|
||
function truncateJson(data: unknown, maxLen = 120): string {
|
||
const s = JSON.stringify(data) ?? ''
|
||
if (s.length <= maxLen) return s
|
||
return s.slice(0, maxLen) + '\u2026'
|
||
}
|
||
|
||
function mapAgentPayload(raw: Record<string, unknown>, previous?: AgentInfo): AgentInfo {
|
||
const runtimeStatus = (
|
||
typeof raw.runtime_status === 'string'
|
||
? raw.runtime_status
|
||
: typeof raw.status === 'string'
|
||
? raw.status
|
||
: previous?.runtime_status ?? previous?.status ?? 'idle'
|
||
) as AgentAnimStatus | string
|
||
const appearance = raw.appearance && typeof raw.appearance === 'object'
|
||
? raw.appearance as AgentInfo['appearance']
|
||
: (previous?.appearance ?? { palette: 0, hue_shift: 0, seat_zone: 'work_area' })
|
||
const specialties = Array.isArray(raw.specialties)
|
||
? raw.specialties.filter((item): item is string => typeof item === 'string')
|
||
: (previous?.specialties ?? [])
|
||
const agentId = typeof raw.agent_id === 'string' ? raw.agent_id : (previous?.agent_id ?? '')
|
||
|
||
return {
|
||
agent_id: agentId,
|
||
name: typeof raw.name === 'string' && raw.name
|
||
? raw.name
|
||
: typeof raw.role_name === 'string' && raw.role_name
|
||
? raw.role_name
|
||
: (previous?.name ?? agentId),
|
||
description: typeof raw.description === 'string' ? raw.description : (previous?.description ?? ''),
|
||
specialties,
|
||
status: runtimeStatus,
|
||
office_id: typeof raw.office_id === 'string' ? raw.office_id : previous?.office_id,
|
||
appearance,
|
||
employee_id: typeof raw.employee_id === 'string' ? raw.employee_id : previous?.employee_id,
|
||
opc_role_id: typeof raw.opc_role_id === 'string' ? raw.opc_role_id : previous?.opc_role_id,
|
||
runtime_status: runtimeStatus as AgentAnimStatus,
|
||
current_tool: typeof raw.current_tool === 'string'
|
||
? raw.current_tool
|
||
: raw.current_tool == null
|
||
? undefined
|
||
: previous?.current_tool,
|
||
current_task_id: typeof raw.current_task_id === 'string'
|
||
? raw.current_task_id
|
||
: raw.current_task_id == null
|
||
? undefined
|
||
: previous?.current_task_id,
|
||
}
|
||
}
|
||
|
||
function mapAgentListPayload(rawAgents: unknown[], previous: AgentInfo[] = []): AgentInfo[] {
|
||
const prevById = new Map(previous.map((agent) => [agent.agent_id, agent]))
|
||
return rawAgents
|
||
.filter((raw): raw is Record<string, unknown> => !!raw && typeof raw === 'object')
|
||
.map((raw) => {
|
||
const agentId = typeof raw.agent_id === 'string' ? raw.agent_id : ''
|
||
return mapAgentPayload(raw, prevById.get(agentId))
|
||
})
|
||
.filter((agent) => !!agent.agent_id)
|
||
}
|
||
|
||
function mapEmployeeAssignmentPayload(raw: unknown): EmployeeAssignment | undefined {
|
||
if (!raw || typeof raw !== 'object') return undefined
|
||
const value = raw as Record<string, unknown>
|
||
return {
|
||
name: typeof value.name === 'string' ? value.name : undefined,
|
||
employeeId: typeof value.employee_id === 'string'
|
||
? value.employee_id
|
||
: typeof value.employeeId === 'string'
|
||
? value.employeeId
|
||
: undefined,
|
||
category: typeof value.category === 'string' ? value.category : undefined,
|
||
experienceScore: typeof value.experience_score === 'number'
|
||
? value.experience_score
|
||
: typeof value.experienceScore === 'number'
|
||
? value.experienceScore
|
||
: undefined,
|
||
}
|
||
}
|
||
|
||
function hasOwnPayloadField(raw: Record<string, unknown>, field: string): boolean {
|
||
return Object.prototype.hasOwnProperty.call(raw, field)
|
||
}
|
||
|
||
function runtimeStatusClearsDisplayTool(status: unknown): boolean {
|
||
const normalized = String(status ?? '').trim().toLowerCase()
|
||
return normalized === 'idle'
|
||
|| normalized === 'done'
|
||
|| normalized === 'failed'
|
||
|| normalized === 'cancelled'
|
||
}
|
||
|
||
function workItemIdentityPatchFromPayload(raw: Record<string, unknown>): Partial<KanbanTask> {
|
||
const patch: Partial<KanbanTask> = {}
|
||
const executionMode = typeof raw.execution_mode === 'string' ? raw.execution_mode : ''
|
||
const isTaskModeRuntime = executionMode === 'task_mode' || raw.work_item_projection_id === 'task_mode_execution'
|
||
if (isTaskModeRuntime) {
|
||
const employeeAssignment = mapEmployeeAssignmentPayload(raw.employee_assignment ?? raw.employeeAssignment)
|
||
if (employeeAssignment) patch.employeeAssignment = employeeAssignment
|
||
if (typeof raw.selected_execution_agent === 'string') patch.selectedExecutionAgent = normalizeTaskPreferredAgent(raw.selected_execution_agent)
|
||
else if (typeof raw.selectedExecutionAgent === 'string') patch.selectedExecutionAgent = normalizeTaskPreferredAgent(raw.selectedExecutionAgent)
|
||
return patch
|
||
}
|
||
if (typeof raw.work_item_projection_id === 'string') {
|
||
patch.workItemProjectionId = raw.work_item_projection_id
|
||
} else if (typeof raw.workItemProjectionId === 'string') {
|
||
patch.workItemProjectionId = raw.workItemProjectionId
|
||
}
|
||
if (typeof raw.work_item_turn_type === 'string') patch.workItemTurnType = raw.work_item_turn_type
|
||
else if (typeof raw.workItemTurnType === 'string') patch.workItemTurnType = raw.workItemTurnType
|
||
|
||
if (typeof raw.work_item_role_id === 'string') patch.workItemRoleId = raw.work_item_role_id
|
||
else if (typeof raw.workItemRoleId === 'string') patch.workItemRoleId = raw.workItemRoleId
|
||
|
||
if (typeof raw.work_item_role_name === 'string') patch.workItemRoleName = raw.work_item_role_name
|
||
else if (typeof raw.workItemRoleName === 'string') patch.workItemRoleName = raw.workItemRoleName
|
||
|
||
const employeeAssignment = mapEmployeeAssignmentPayload(raw.employee_assignment ?? raw.employeeAssignment)
|
||
if (employeeAssignment) patch.employeeAssignment = employeeAssignment
|
||
if (typeof raw.selected_execution_agent === 'string') patch.selectedExecutionAgent = normalizeTaskPreferredAgent(raw.selected_execution_agent)
|
||
else if (typeof raw.selectedExecutionAgent === 'string') patch.selectedExecutionAgent = normalizeTaskPreferredAgent(raw.selectedExecutionAgent)
|
||
return patch
|
||
}
|
||
|
||
function sessionRuntimePatchFromPayload(raw: Record<string, unknown>): Partial<import('./types/kanban').Session> {
|
||
const patch: Partial<import('./types/kanban').Session> = {}
|
||
if (raw.latest_notification && typeof raw.latest_notification === 'object') {
|
||
patch.latestNotification = raw.latest_notification as import('./types/kanban').WorkerNotification
|
||
}
|
||
if (typeof raw.runtime_session_id === 'string') patch.runtimeSessionId = raw.runtime_session_id
|
||
if (typeof raw.resume_cursor === 'number') patch.resumeCursor = raw.resume_cursor
|
||
if (Array.isArray(raw.active_subagents)) patch.activeSubagents = raw.active_subagents as Array<Record<string, unknown>>
|
||
if (Array.isArray(raw.permission_requests)) patch.permissionRequests = raw.permission_requests as Array<Record<string, unknown>>
|
||
if (typeof raw.worktree_path === 'string') patch.worktreePath = raw.worktree_path
|
||
if (typeof raw.current_tool === 'string') patch.currentTool = raw.current_tool
|
||
// displayTool is the sticky "last command" label; an empty string between
|
||
// tools must NOT clear it (that causes the header tool-pill to flicker once
|
||
// per tool call). Only write a real, non-empty label here.
|
||
if (typeof raw.display_tool === 'string' && raw.display_tool.trim()) patch.displayTool = raw.display_tool
|
||
if (typeof raw.tool_elapsed_ms === 'number') patch.toolElapsedMs = raw.tool_elapsed_ms
|
||
if (typeof raw.last_tool_summary === 'string') patch.lastToolSummary = raw.last_tool_summary
|
||
if (typeof raw.context_tokens === 'number') patch.contextTokens = raw.context_tokens
|
||
// Ignore a non-positive window: an intra-turn 0 would wipe the last known
|
||
// window and hide the context ring until the next tool call (flicker).
|
||
if (typeof raw.context_window === 'number' && raw.context_window > 0) patch.contextWindow = raw.context_window
|
||
if (typeof raw.context_remaining_pct === 'number') patch.contextRemainingPct = raw.context_remaining_pct
|
||
if (typeof raw.input_tokens === 'number') patch.inputTokens = raw.input_tokens
|
||
else if (typeof raw.input_tokens_total === 'number') patch.inputTokens = raw.input_tokens_total
|
||
else if (typeof raw.tokens_in === 'number') patch.inputTokens = raw.tokens_in
|
||
if (typeof raw.output_tokens === 'number') patch.outputTokens = raw.output_tokens
|
||
else if (typeof raw.output_tokens_total === 'number') patch.outputTokens = raw.output_tokens_total
|
||
else if (typeof raw.tokens_out === 'number') patch.outputTokens = raw.tokens_out
|
||
if (typeof raw.total_tokens === 'number') patch.totalTokens = raw.total_tokens
|
||
else if (typeof raw.tokens_total === 'number') patch.totalTokens = raw.tokens_total
|
||
if (typeof raw.turn_cost_usd === 'number') patch.turnCostUsd = raw.turn_cost_usd
|
||
if (typeof raw.session_cost_usd === 'number') patch.sessionCostUsd = raw.session_cost_usd
|
||
if (typeof raw.pending_permission_count === 'number') patch.pendingPermissionCount = raw.pending_permission_count
|
||
if (typeof raw.drain_mode === 'string') patch.drainMode = raw.drain_mode
|
||
if (typeof raw.resident_status === 'string') patch.residentStatus = raw.resident_status
|
||
if (typeof raw.actionable_inbox_count === 'number') patch.actionableInboxCount = raw.actionable_inbox_count
|
||
if (typeof raw.protocol_backlog_count === 'number') patch.protocolBacklogCount = raw.protocol_backlog_count
|
||
if (typeof raw.notification_backlog_count === 'number') patch.notificationBacklogCount = raw.notification_backlog_count
|
||
return patch
|
||
}
|
||
|
||
function kanbanRuntimePatchFromPayload(raw: Record<string, unknown>): Partial<KanbanTask> {
|
||
const patch: Partial<KanbanTask> = {}
|
||
if (raw.latest_notification && typeof raw.latest_notification === 'object') {
|
||
patch.latestNotification = raw.latest_notification as import('./types/kanban').WorkerNotification
|
||
}
|
||
if (typeof raw.current_tool === 'string') patch.currentTool = raw.current_tool
|
||
// Sticky display label — see sessionRuntimePatchFromPayload above.
|
||
if (typeof raw.display_tool === 'string' && raw.display_tool.trim()) patch.displayTool = raw.display_tool
|
||
if (typeof raw.tool_elapsed_ms === 'number') patch.toolElapsedMs = raw.tool_elapsed_ms
|
||
if (typeof raw.last_tool_summary === 'string') patch.lastToolSummary = raw.last_tool_summary
|
||
if (typeof raw.context_tokens === 'number') patch.contextTokens = raw.context_tokens
|
||
if (typeof raw.context_window === 'number' && raw.context_window > 0) patch.contextWindow = raw.context_window
|
||
if (typeof raw.context_remaining_pct === 'number') patch.contextRemainingPct = raw.context_remaining_pct
|
||
if (typeof raw.input_tokens === 'number') patch.inputTokens = raw.input_tokens
|
||
else if (typeof raw.input_tokens_total === 'number') patch.inputTokens = raw.input_tokens_total
|
||
else if (typeof raw.tokens_in === 'number') patch.inputTokens = raw.tokens_in
|
||
if (typeof raw.output_tokens === 'number') patch.outputTokens = raw.output_tokens
|
||
else if (typeof raw.output_tokens_total === 'number') patch.outputTokens = raw.output_tokens_total
|
||
else if (typeof raw.tokens_out === 'number') patch.outputTokens = raw.tokens_out
|
||
if (typeof raw.total_tokens === 'number') patch.totalTokens = raw.total_tokens
|
||
else if (typeof raw.tokens_total === 'number') patch.totalTokens = raw.tokens_total
|
||
if (typeof raw.turn_cost_usd === 'number') patch.turnCostUsd = raw.turn_cost_usd
|
||
if (typeof raw.session_cost_usd === 'number') patch.sessionCostUsd = raw.session_cost_usd
|
||
if (typeof raw.pending_permission_count === 'number') patch.pendingPermissionCount = raw.pending_permission_count
|
||
if (typeof raw.drain_mode === 'string') patch.drainMode = raw.drain_mode
|
||
if (typeof raw.resident_status === 'string') patch.residentStatus = raw.resident_status
|
||
if (typeof raw.actionable_inbox_count === 'number') patch.actionableInboxCount = raw.actionable_inbox_count
|
||
if (typeof raw.protocol_backlog_count === 'number') patch.protocolBacklogCount = raw.protocol_backlog_count
|
||
if (typeof raw.notification_backlog_count === 'number') patch.notificationBacklogCount = raw.notification_backlog_count
|
||
return patch
|
||
}
|
||
|
||
function shouldRefreshLiveSession(taskId: string, sessionStore: SessionStoreState | null): boolean {
|
||
if (!sessionStore || !taskId) return false
|
||
if (sessionStore.activeSessionId === taskId) return true
|
||
const active = sessionStore.activeSession
|
||
if (!active) return false
|
||
if (active.taskId === taskId || active.parentSessionId === taskId || active.sessionId === taskId) {
|
||
return true
|
||
}
|
||
|
||
const target = sessionStore.sessions.find((session) => session.taskId === taskId)
|
||
if (!target) return false
|
||
|
||
const activeKeys = new Set(
|
||
[String(active.taskId ?? '').trim(), String(active.sessionId ?? '').trim()].filter(Boolean),
|
||
)
|
||
const targetParent = String(target.parentSessionId ?? '').trim()
|
||
if (targetParent && activeKeys.has(targetParent)) {
|
||
return true
|
||
}
|
||
|
||
const activeParent = String(active.parentSessionId ?? '').trim()
|
||
if (!activeParent) return false
|
||
return String(target.taskId ?? '').trim() === activeParent || String(target.sessionId ?? '').trim() === activeParent
|
||
}
|
||
|
||
function legacyPhaseFromSessionStatus(status: string): KanbanPhase {
|
||
if (status === 'done' || status === 'delivered') return 'approved'
|
||
if (status === 'failed') return 'failed'
|
||
if (status === 'cancelled') return 'cancelled'
|
||
if (status === 'pending') return 'queued'
|
||
if (status === 'awaiting_manager_review' || status === 'awaiting_review') {
|
||
return 'awaiting_manager_review'
|
||
}
|
||
if (status === 'awaiting_human') return 'awaiting_human'
|
||
return 'running'
|
||
}
|
||
|
||
function legacyColumnForPhase(phase: KanbanPhase): string {
|
||
if (phase === 'approved' || phase === 'failed' || phase === 'cancelled') return 'done'
|
||
if (phase === 'awaiting_manager_review' || phase === 'awaiting_human') return 'in-review'
|
||
if (phase === 'queued' || phase === 'ready' || phase === 'ready_for_rework' || phase === 'waiting_dependencies') return 'todo'
|
||
return 'in-progress'
|
||
}
|
||
|
||
function legacyAggregatedStatus(status: string): RoleAggregatedStatus {
|
||
if (status === 'done' || status === 'delivered') return 'done'
|
||
if (status === 'failed' || status === 'cancelled') return 'failed'
|
||
if (status === 'pending') return 'pending'
|
||
if (status === 'awaiting_manager_review' || status === 'awaiting_review' || status === 'awaiting_human') return 'waiting'
|
||
return 'active'
|
||
}
|
||
|
||
function legacyRuntimeStatus(status: string | undefined): AgentAnimStatus {
|
||
if (status === 'reflecting' || status === 'tool_active' || status === 'idle') return status
|
||
return 'idle'
|
||
}
|
||
|
||
function roleSummaryFromLegacySession(session: Session): RoleWorkItemSummary {
|
||
const executionTurnId = getExecutionTurnId(session) || session.taskId
|
||
const roleId = session.workItemRoleId || session.assigneeIds[0] || session.taskId
|
||
const roleName = session.workItemRoleName || roleId.replace(/[_-]/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
|
||
const phase = legacyPhaseFromSessionStatus(session.status)
|
||
return {
|
||
roleKey: roleId,
|
||
roleId,
|
||
roleName,
|
||
runtimeStatus: legacyRuntimeStatus(session.agentStatus),
|
||
aggregatedStatus: legacyAggregatedStatus(session.status),
|
||
workItems: [
|
||
{
|
||
workItemId: session.workItemProjectionId || executionTurnId,
|
||
workItemProjectionId: session.workItemProjectionId,
|
||
phase,
|
||
kanbanColumn: legacyColumnForPhase(phase),
|
||
title: session.title || roleName,
|
||
kind: session.workItemTurnType,
|
||
executorRoleId: roleId,
|
||
executorRoleName: roleName,
|
||
createdAt: session.createdAt,
|
||
updatedAt: session.updatedAt,
|
||
executionTurnId,
|
||
progressLog: session.progressLog,
|
||
activitySections: session.progressLog.length > 0
|
||
? [{
|
||
kind: 'activity',
|
||
title: 'Runtime activity',
|
||
roleName,
|
||
runtimeTaskId: executionTurnId,
|
||
entries: session.progressLog,
|
||
}]
|
||
: [],
|
||
},
|
||
],
|
||
}
|
||
}
|
||
|
||
/** Thin wrapper so the execution-panel lookup is a normal component, not a JSX IIFE. */
|
||
function MaybeExecutionPanel({ taskId, sessions, agents, onClose }: {
|
||
taskId: string | null
|
||
sessions: Session[]
|
||
agents: AgentInfo[]
|
||
onClose: () => void
|
||
}) {
|
||
if (!taskId) return null
|
||
|
||
for (const session of sessions) {
|
||
const payload = session.roleWorkItems
|
||
if (!payload) continue
|
||
for (const role of Object.values(payload)) {
|
||
const row = role.workItems.find(workItem => workItem.executionTurnId === taskId)
|
||
if (!row) continue
|
||
return (
|
||
<ExecutionPanel
|
||
role={role}
|
||
focusedWorkItemId={row.workItemId}
|
||
focusedExecutionTurnId={row.executionTurnId}
|
||
agents={agents}
|
||
onClose={onClose}
|
||
/>
|
||
)
|
||
}
|
||
}
|
||
|
||
const focused = sessions.find(x => x.taskId === taskId || getExecutionTurnId(x) === taskId)
|
||
if (!focused) return null
|
||
const role = roleSummaryFromLegacySession(focused)
|
||
return (
|
||
<ExecutionPanel
|
||
role={role}
|
||
focusedExecutionTurnId={taskId}
|
||
agents={agents}
|
||
onClose={onClose}
|
||
/>
|
||
)
|
||
}
|
||
|
||
export default function App() {
|
||
const bridgeRef = useRef(new GameBridge())
|
||
useMemo(() => registerTestRunner(bridgeRef.current), [])
|
||
const clientRef = useRef<VisualSocketClient | null>(null)
|
||
|
||
const initialUrl = defaultWsUrl()
|
||
|
||
const [wsUrl, setWsUrl] = useState(initialUrl)
|
||
const [wsUrlInput, setWsUrlInput] = useState(initialUrl)
|
||
const [status, setStatus] = useState<SocketStatus>('disconnected')
|
||
const [statusDetail, setStatusDetail] = useState('')
|
||
const [snapshot, setSnapshot] = useState<VisualSnapshot | null>(null)
|
||
const [events, setEvents] = useState<VisualEvent[]>([])
|
||
const [uiTick, setUiTick] = useState(0)
|
||
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null)
|
||
const [theme, setTheme] = useState<ThemeName>('openopc')
|
||
const [showSubagents, setShowSubagents] = useState(true)
|
||
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
|
||
try { return localStorage.getItem('opc_office_sidebar_collapsed') === '1' } catch { return false }
|
||
})
|
||
const toggleSidebar = () => setSidebarCollapsed(v => {
|
||
const next = !v
|
||
try { localStorage.setItem('opc_office_sidebar_collapsed', next ? '1' : '0') } catch { /* private mode */ }
|
||
return next
|
||
})
|
||
const [eventTypeFilter, setEventTypeFilter] = useState('all')
|
||
const [activePage, setActivePage] = useState<AppPage>('workspace')
|
||
const [swarmAgents, setSwarmAgents] = useState<AgentInfo[]>([])
|
||
const [showDevTools, setShowDevTools] = useState(false)
|
||
const [lastTaskDoneAgent, setLastTaskDoneAgent] = useState<string | null>(null)
|
||
const [globalExecMode, setGlobalExecMode] = useState<AppExecMode>('task')
|
||
const [globalCompanyProfile, setGlobalCompanyProfile] = useState<'corporate' | 'custom'>('corporate')
|
||
const [globalTaskPreferredAgent, setGlobalTaskPreferredAgent] = useState<TaskPreferredAgent>('native')
|
||
const [orgInfoData, setOrgInfoData] = useState<OrgInfoPayload | null>(null)
|
||
const [recoveryStatus, setRecoveryStatus] = useState<any>(null)
|
||
const [commsState, setCommsState] = useState<import('./lib/wsClient').CommsStatePayload | null>(null)
|
||
const [commsMessage, setCommsMessage] = useState<import('./lib/wsClient').CommsMessagePayload | null>(null)
|
||
const [talentTemplates, setTalentTemplates] = useState<TalentTemplate[]>([])
|
||
const [defaultTalentDir, setDefaultTalentDir] = useState<string>('')
|
||
const [employeeDetail, setEmployeeDetail] = useState<EmployeeDetailPayload | null>(null)
|
||
const [reorgProposals, setReorgProposals] = useState<ReorgProposalInfo[]>([])
|
||
const [marketPresets, setMarketPresets] = useState<any[]>([])
|
||
const [marketPreviewData, setMarketPreviewData] = useState<any>(null)
|
||
const [configExportYaml, setConfigExportYaml] = useState<string | null>(null)
|
||
const [configImportPreview, setConfigImportPreview] = useState<{ roles_added: number; roles_removed: number; employees_changed: number } | null>(null)
|
||
const [configImportError, setConfigImportError] = useState<string | null>(null)
|
||
const [savedOrgsList, setSavedOrgsList] = useState<SavedOrgSummary[] | null>(null)
|
||
const [activeSavedOrg, setActiveSavedOrg] = useState<string | null>(null)
|
||
const [savedOrgVersionAtLoad, setSavedOrgVersionAtLoad] = useState<number | null>(null)
|
||
const [orgCreatePending, setOrgCreatePending] = useState(false)
|
||
const [orgCreateResult, setOrgCreateResult] = useState<(OrgSavedCreatePayload & { nonce: number }) | null>(null)
|
||
const [orgToast, setOrgToast] = useState<{ kind: 'ok' | 'error'; text: string } | null>(null)
|
||
const timersRef = useRef<Set<ReturnType<typeof setTimeout>>>(new Set())
|
||
const replayedEventIds = useRef<Set<string>>(new Set())
|
||
const swarmAgentsRef = useRef<AgentInfo[]>([])
|
||
const globalExecModeRef = useRef<AppExecMode>('task')
|
||
const kanbanCreateRef = useRef<((data: { title: string; description?: string; priority: null; assignee_id?: string }) => void) | null>(null)
|
||
const chatStoreRef = useRef<ChatStoreState | null>(null)
|
||
const boardStoreRef = useRef<BoardStoreState | null>(null)
|
||
const sessionStoreRef = useRef<SessionStoreState | null>(null)
|
||
const projectStoreRef = useRef<ProjectStoreState | null>(null)
|
||
const activeProjectIdRef = useRef<string>('default')
|
||
const pendingProjectSwitchRef = useRef<string | null>(null)
|
||
const currentSwitchSeqRef = useRef<string>('')
|
||
const projectViewGenerationRef = useRef<number>(0)
|
||
const userSelectedProjectRef = useRef<boolean>(false)
|
||
const projectsHydratedRef = useRef<boolean>(false)
|
||
const lastProjectIndexRefreshRef = useRef<number>(0)
|
||
const pendingSessionCreateRef = useRef(false)
|
||
const pendingSessionCreateProjectIdRef = useRef<string | null>(null)
|
||
const pendingSessionCreateTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||
const pendingSessionDetailRefreshRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map())
|
||
const [hiringTemplateId, setHiringTemplateId] = useState<string | null>(null)
|
||
const [toastMessage, setToastMessage] = useState<string | null>(null)
|
||
const [toastType, setToastType] = useState<'success' | 'error'>('success')
|
||
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null)
|
||
const [deletingAgentId, setDeletingAgentId] = useState<string | null>(null)
|
||
const [executionPanelTaskId, setExecutionPanelTaskId] = useState<string | null>(null)
|
||
const [outdoorOverride, setOutdoorOverride] = useState<'auto' | 'day' | 'night'>(() => readOutdoorOverrideUi())
|
||
|
||
const normalizeProjectId = useCallback((value: unknown): string => {
|
||
const projectId = typeof value === 'string' ? value.trim() : ''
|
||
return projectId || 'default'
|
||
}, [])
|
||
|
||
const getActiveProjectId = useCallback(() => normalizeProjectId(activeProjectIdRef.current), [normalizeProjectId])
|
||
|
||
const newSwitchSeq = useCallback(() => {
|
||
return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
||
}, [])
|
||
|
||
const projectIdFromPayload = useCallback((payload: Record<string, unknown> | null | undefined): string => {
|
||
if (!payload || typeof payload !== 'object') return ''
|
||
for (const key of ['project_id', 'projectId', 'active_project_id', 'activeProjectId']) {
|
||
const value = payload[key]
|
||
if (typeof value === 'string' && value.trim()) return value.trim()
|
||
}
|
||
const data = payload.data
|
||
if (data && typeof data === 'object') {
|
||
for (const key of ['project_id', 'projectId']) {
|
||
const value = (data as Record<string, unknown>)[key]
|
||
if (typeof value === 'string' && value.trim()) return value.trim()
|
||
}
|
||
}
|
||
return ''
|
||
}, [])
|
||
|
||
const payloadMatchesActiveProject = useCallback((payload: Record<string, unknown> | null | undefined, allowMissing = false): boolean => {
|
||
const projectId = projectIdFromPayload(payload)
|
||
if (!projectId) return allowMissing
|
||
return projectId === getActiveProjectId()
|
||
}, [getActiveProjectId, projectIdFromPayload])
|
||
|
||
const payloadSwitchSeq = useCallback((payload: Record<string, unknown> | null | undefined): string => {
|
||
if (!payload || typeof payload !== 'object') return ''
|
||
const value = payload.switch_seq ?? payload.switchSeq
|
||
return typeof value === 'string' ? value.trim() : ''
|
||
}, [])
|
||
|
||
const payloadViewGeneration = useCallback((payload: Record<string, unknown> | null | undefined): number | null => {
|
||
if (!payload || typeof payload !== 'object') return null
|
||
const value = payload.view_generation ?? payload.viewGeneration
|
||
if (typeof value === 'number') return value
|
||
if (typeof value === 'string' && value.trim()) {
|
||
const parsed = Number(value)
|
||
return Number.isFinite(parsed) ? parsed : null
|
||
}
|
||
return null
|
||
}, [])
|
||
|
||
const payloadMatchesCurrentSwitch = useCallback((payload: Record<string, unknown> | null | undefined): boolean => {
|
||
const projectId = projectIdFromPayload(payload)
|
||
if (!projectId) return false
|
||
const pendingProjectId = pendingProjectSwitchRef.current
|
||
if (projectId !== getActiveProjectId() && projectId !== pendingProjectId) return false
|
||
const generation = payloadViewGeneration(payload)
|
||
if (generation !== null && generation !== projectViewGenerationRef.current) return false
|
||
const seq = payloadSwitchSeq(payload)
|
||
return !seq || !currentSwitchSeqRef.current || seq === currentSwitchSeqRef.current
|
||
}, [getActiveProjectId, payloadSwitchSeq, payloadViewGeneration, projectIdFromPayload])
|
||
|
||
const shouldSuppressTaskNotFound = useCallback((payload: Record<string, unknown> | null | undefined): boolean => {
|
||
if (!payload || payload.error !== 'task_not_found') return false
|
||
if (!payloadMatchesActiveProject(payload, false)) return true
|
||
const action = typeof payload.action === 'string' ? payload.action : ''
|
||
if (action !== 'session_detail') return false
|
||
const generation = payloadViewGeneration(payload)
|
||
if (generation !== null && generation !== projectViewGenerationRef.current) return true
|
||
const taskId = typeof payload.task_id === 'string' ? payload.task_id : ''
|
||
if (!taskId) return true
|
||
const sessions = sessionStoreRef.current?.sessions ?? []
|
||
return !sessions.some(session => session.taskId === taskId)
|
||
}, [payloadMatchesActiveProject, payloadViewGeneration])
|
||
|
||
const clearPendingSessionDetailRefreshes = useCallback(() => {
|
||
for (const tid of pendingSessionDetailRefreshRef.current.values()) clearTimeout(tid)
|
||
pendingSessionDetailRefreshRef.current.clear()
|
||
}, [])
|
||
|
||
const scheduleSessionDetailRefresh = useCallback((taskId: string, detailLevel: 'summary' | 'full' = 'full', force = false, projectId?: string) => {
|
||
if (!taskId) return
|
||
const scopedProjectId = projectId || getActiveProjectId()
|
||
const generation = projectViewGenerationRef.current
|
||
// Check relevance using latest ref; skip non-live sessions unless forced.
|
||
// Re-check inside the timer so that a session that *becomes* active during
|
||
// the debounce window still gets its detail loaded.
|
||
if (!force && !shouldRefreshLiveSession(taskId, sessionStoreRef.current)) return
|
||
const timerKey = `${scopedProjectId}:${generation}:${taskId}`
|
||
const existing = pendingSessionDetailRefreshRef.current.get(timerKey)
|
||
if (existing) {
|
||
clearTimeout(existing)
|
||
pendingSessionDetailRefreshRef.current.delete(timerKey)
|
||
}
|
||
const tid = setTimeout(() => {
|
||
pendingSessionDetailRefreshRef.current.delete(timerKey)
|
||
if (scopedProjectId !== getActiveProjectId()) return
|
||
if (generation !== projectViewGenerationRef.current) return
|
||
// Re-check liveness at fire time (ref may have been updated by now)
|
||
if (!force && !shouldRefreshLiveSession(taskId, sessionStoreRef.current)) return
|
||
const client = clientRef.current
|
||
sessionStoreRef.current?.updateSession(taskId, {
|
||
detailLoading: true,
|
||
detailError: undefined,
|
||
viewGeneration: generation,
|
||
})
|
||
if (!client) {
|
||
sessionStoreRef.current?.updateSession(taskId, {
|
||
detailLoading: false,
|
||
detailError: 'connection_unavailable',
|
||
})
|
||
return
|
||
}
|
||
void client.sessionDetail(scopedProjectId, taskId, {
|
||
limit: 200,
|
||
detailLevel,
|
||
include: detailLevel === 'full'
|
||
? ['messages', 'session_state', 'progress', 'work_items', 'runtime_context']
|
||
: ['messages', 'session_state'],
|
||
viewGeneration: generation,
|
||
}).then((payload) => {
|
||
// Transport-local failures resolve the request Promise without
|
||
// producing a websocket ACK.
|
||
if (payload.ok !== false) return
|
||
if (scopedProjectId !== getActiveProjectId()) return
|
||
if (generation !== projectViewGenerationRef.current) return
|
||
sessionStoreRef.current?.updateSession(taskId, {
|
||
detailLoading: false,
|
||
detailError: String(payload.error ?? 'request_failed'),
|
||
})
|
||
})
|
||
}, 180)
|
||
pendingSessionDetailRefreshRef.current.set(timerKey, tid)
|
||
}, [getActiveProjectId])
|
||
|
||
const showToast = useCallback((msg: string, type: 'success' | 'error' = 'success') => {
|
||
setToastMessage(msg); setToastType(type)
|
||
setTimeout(() => setToastMessage(null), 3000)
|
||
}, [])
|
||
|
||
const clearPendingSessionCreate = useCallback(() => {
|
||
if (pendingSessionCreateTimerRef.current) {
|
||
clearTimeout(pendingSessionCreateTimerRef.current)
|
||
pendingSessionCreateTimerRef.current = null
|
||
}
|
||
pendingSessionCreateRef.current = false
|
||
pendingSessionCreateProjectIdRef.current = null
|
||
}, [])
|
||
|
||
const beginPendingSessionCreate = useCallback((projectId: string) => {
|
||
clearPendingSessionCreate()
|
||
pendingSessionCreateRef.current = true
|
||
pendingSessionCreateProjectIdRef.current = projectId
|
||
pendingSessionCreateTimerRef.current = setTimeout(() => {
|
||
if (!pendingSessionCreateRef.current || pendingSessionCreateProjectIdRef.current !== projectId) return
|
||
clearPendingSessionCreate()
|
||
setStatusDetail('create_session_timeout')
|
||
showToast('Session creation timed out. Please try again.', 'error')
|
||
}, 30_000)
|
||
}, [clearPendingSessionCreate, showToast])
|
||
|
||
// ── Store hooks: MUST be declared before WebSocket effect so refs are available ──
|
||
const boardStore = useBoardStore()
|
||
const chatStore = useChatStore()
|
||
const sessionStore = useSessionStore()
|
||
const projectStore = useProjectStore()
|
||
|
||
// Per-session recruitment (role_id -> recruited names) for the org canvas.
|
||
// The org_info payload is project-global, so the canvas otherwise can't show
|
||
// the *selected* session's hires; this derives them from that session's chat
|
||
// recruitment checkpoint (display-only — no backend change). Null when the
|
||
// selected session has no recruitment loaded -> canvas falls back to global.
|
||
const activeSessionId = sessionStore.activeSessionId
|
||
const sessionRecruitmentByRole = useMemo(() => {
|
||
if (!activeSessionId) return null
|
||
return extractSessionRecruitmentByRole(chatStore.getChannelMessages(sessionChannelId(activeSessionId)))
|
||
}, [activeSessionId, chatStore])
|
||
|
||
const resetProjectScopedView = useCallback((projectId: string) => {
|
||
const nextProjectId = normalizeProjectId(projectId)
|
||
activeProjectIdRef.current = nextProjectId
|
||
clearPendingSessionDetailRefreshes()
|
||
chatStore.initFromBackend(nextProjectId, [], [])
|
||
boardStore.initFromBackend(nextProjectId, [], [], [])
|
||
sessionStore.initFromBackend(nextProjectId, [])
|
||
clearPendingSessionCreate()
|
||
setExecutionPanelTaskId(null)
|
||
setCommsState(null)
|
||
setCommsMessage(null)
|
||
}, [boardStore, chatStore, clearPendingSessionCreate, clearPendingSessionDetailRefreshes, normalizeProjectId, sessionStore])
|
||
|
||
const beginProjectSwitch = useCallback((projectId: string): string => {
|
||
const nextProjectId = normalizeProjectId(projectId)
|
||
const switchSeq = newSwitchSeq()
|
||
userSelectedProjectRef.current = true
|
||
currentSwitchSeqRef.current = switchSeq
|
||
projectViewGenerationRef.current += 1
|
||
pendingProjectSwitchRef.current = nextProjectId
|
||
clearPendingSessionDetailRefreshes()
|
||
setStatusDetail(`Switching to ${nextProjectId}...`)
|
||
return switchSeq
|
||
}, [clearPendingSessionDetailRefreshes, newSwitchSeq, normalizeProjectId])
|
||
|
||
// Sync store refs via useLayoutEffect — runs synchronously after commit,
|
||
// BEFORE any async callbacks (WebSocket messages) can fire.
|
||
// This eliminates the race condition where handlers read null refs.
|
||
const storesReadyRef = useRef(false)
|
||
useLayoutEffect(() => {
|
||
chatStoreRef.current = chatStore
|
||
boardStoreRef.current = boardStore
|
||
sessionStoreRef.current = sessionStore
|
||
projectStoreRef.current = projectStore
|
||
kanbanCreateRef.current = (data) => {
|
||
if (!boardStore.activeBoardId || boardStore.activeBoardColumns.length === 0) return
|
||
const todoCol = boardStore.activeBoardColumns.find(c => c.name === 'Todo')
|
||
const colId = todoCol?.id ?? boardStore.activeBoardColumns[0].id
|
||
boardStore.createTask({
|
||
boardId: boardStore.activeBoardId,
|
||
columnId: colId,
|
||
title: data.title,
|
||
priority: data.priority,
|
||
assigneeIds: data.assignee_id ? [data.assignee_id] : [],
|
||
})
|
||
}
|
||
storesReadyRef.current = true
|
||
})
|
||
|
||
useEffect(() => {
|
||
swarmAgentsRef.current = swarmAgents
|
||
}, [swarmAgents])
|
||
|
||
useEffect(() => {
|
||
globalExecModeRef.current = globalExecMode
|
||
}, [globalExecMode])
|
||
|
||
// Listen for agent selection from Phaser
|
||
useEffect(() => {
|
||
const bridge = bridgeRef.current
|
||
const handler = (agentId: string) => {
|
||
setSelectedAgentId(agentId)
|
||
setUiTick(n => n + 1)
|
||
}
|
||
bridge.on('agentSelected', handler)
|
||
return () => { bridge.off('agentSelected', handler) }
|
||
}, [])
|
||
|
||
// ── Runtime-delta coalescing ────────────────────────────────────────
|
||
// assistant_delta / thinking_delta arrive at token frequency; writing each
|
||
// one straight into the stores re-renders the whole app once per token.
|
||
// Buffer them per task and flush at most every 80ms. Any non-delta event
|
||
// for the same task flushes first, so store-write ordering (e.g. clearDraft
|
||
// on turn boundaries) is preserved exactly.
|
||
const pendingDeltaFlushRef = useRef<Map<string, {
|
||
draftText: string
|
||
draftIteration?: number
|
||
draftTurnId?: string
|
||
sessionPatch: Partial<import('./types/kanban').Session>
|
||
kanbanPatch: Partial<KanbanTask>
|
||
}>>(new Map())
|
||
const deltaFlushTimerRef = useRef<number | null>(null)
|
||
|
||
const flushPendingDeltas = useCallback((onlyTaskId?: string) => {
|
||
const pending = pendingDeltaFlushRef.current
|
||
if (pending.size === 0) return
|
||
const ids = onlyTaskId ? [onlyTaskId] : Array.from(pending.keys())
|
||
for (const taskId of ids) {
|
||
const entry = pending.get(taskId)
|
||
if (!entry) continue
|
||
pending.delete(taskId)
|
||
const ss = sessionStoreRef.current
|
||
if (entry.draftText) {
|
||
ss?.appendDraft(taskId, entry.draftText, entry.draftIteration, entry.draftTurnId)
|
||
}
|
||
ss?.updateSession(taskId, entry.sessionPatch)
|
||
if (Object.keys(entry.kanbanPatch).length > 0) {
|
||
boardStoreRef.current?.updateTask(taskId, entry.kanbanPatch)
|
||
}
|
||
}
|
||
if (pending.size === 0 && deltaFlushTimerRef.current !== null) {
|
||
window.clearTimeout(deltaFlushTimerRef.current)
|
||
deltaFlushTimerRef.current = null
|
||
}
|
||
}, [])
|
||
|
||
const scheduleDeltaFlush = useCallback(() => {
|
||
if (deltaFlushTimerRef.current !== null) return
|
||
deltaFlushTimerRef.current = window.setTimeout(() => {
|
||
deltaFlushTimerRef.current = null
|
||
flushPendingDeltas()
|
||
}, 80)
|
||
}, [flushPendingDeltas])
|
||
|
||
// uiTick only feeds the office-page visual memos (cards/offices/seats).
|
||
// A trailing 300ms throttle caps their refresh cost regardless of the
|
||
// websocket event rate; the office view is cosmetic, so ≤300ms staleness
|
||
// is invisible.
|
||
const uiTickTimerRef = useRef<number | null>(null)
|
||
const bumpUiTickThrottled = useCallback(() => {
|
||
if (uiTickTimerRef.current !== null) return
|
||
uiTickTimerRef.current = window.setTimeout(() => {
|
||
uiTickTimerRef.current = null
|
||
setUiTick(n => n + 1)
|
||
}, 300)
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
const client = new VisualSocketClient(wsUrl, {
|
||
onSnapshot: (data) => {
|
||
if (!payloadMatchesCurrentSwitch(data as unknown as Record<string, unknown>)) return
|
||
setSnapshot(data)
|
||
const timeline = data.timeline.slice(-MAX_LOG_ITEMS)
|
||
setEvents(timeline)
|
||
|
||
const ids = new Set<string>()
|
||
for (const evt of timeline) ids.add(evt.event_id)
|
||
replayedEventIds.current = ids
|
||
|
||
// Push snapshot to Phaser
|
||
bridgeRef.current.pushSnapshot(data)
|
||
|
||
const agentEntries = Object.entries(data.agents ?? {})
|
||
if (agentEntries.length > 0) {
|
||
const infos = mapAgentListPayload(
|
||
agentEntries.map(([id, info]) => ({
|
||
...((info && typeof info === 'object') ? info as Record<string, unknown> : {}),
|
||
agent_id: id,
|
||
})),
|
||
swarmAgentsRef.current,
|
||
)
|
||
swarmAgentsRef.current = infos
|
||
setSwarmAgents(infos)
|
||
}
|
||
// Restore exec_mode / company_profile from snapshot (survives reconnection)
|
||
if (data.exec_mode) setGlobalExecMode(normalizeExecMode(data.exec_mode))
|
||
if (data.company_profile) setGlobalCompanyProfile(normalizeCompanyProfile(data.company_profile))
|
||
if (data.task_preferred_agent) setGlobalTaskPreferredAgent(normalizeTaskPreferredAgent(data.task_preferred_agent))
|
||
|
||
setUiTick((n) => n + 1)
|
||
},
|
||
onEvent: (evt) => {
|
||
try {
|
||
if (!payloadMatchesActiveProject(evt as unknown as Record<string, unknown>, false)) return
|
||
if (replayedEventIds.current.has(evt.event_id)) {
|
||
replayedEventIds.current.delete(evt.event_id)
|
||
return
|
||
}
|
||
setEvents((prev) => [...prev.slice(-MAX_LOG_ITEMS + 1), evt])
|
||
|
||
// Push event to Phaser
|
||
bridgeRef.current.pushEvent(evt)
|
||
|
||
// task_routed: create kanban card only if task_id is provided (#6 — no title-matching)
|
||
if (evt.type === 'task_routed' && evt.agent_id) {
|
||
if (globalExecModeRef.current === 'company' || globalExecModeRef.current === 'org') {
|
||
return
|
||
}
|
||
const taskId = evt.data?.task_id as string | undefined
|
||
if (taskId && !boardStoreRef.current?.tasks.find(t => t.id === taskId)) {
|
||
const preview = typeof evt.data?.content_preview === 'string'
|
||
? evt.data.content_preview.slice(0, 80)
|
||
: 'Task'
|
||
kanbanCreateRef.current?.({
|
||
title: preview,
|
||
priority: null,
|
||
assignee_id: evt.agent_id,
|
||
})
|
||
}
|
||
}
|
||
|
||
if (evt.type === 'task_done' && evt.agent_id) {
|
||
setLastTaskDoneAgent(evt.agent_id)
|
||
}
|
||
|
||
if ([
|
||
'turn_started',
|
||
'assistant_delta',
|
||
'thinking_delta',
|
||
'status_snapshot',
|
||
'tool_started',
|
||
'tool_progress',
|
||
'tool_completed',
|
||
'permission_requested',
|
||
'permission_resolved',
|
||
'cost_update',
|
||
'context_usage',
|
||
'context_warning',
|
||
'subagent_started',
|
||
'subagent_updated',
|
||
'subagent_completed',
|
||
'member_inbox_updated',
|
||
'compaction_applied',
|
||
'checkpoint_saved',
|
||
'turn_completed',
|
||
'turn_failed',
|
||
].includes(evt.type)) {
|
||
const data = evt.data as Record<string, unknown>
|
||
const taskId = typeof data.task_id === 'string' ? data.task_id : ''
|
||
if (taskId) {
|
||
const isDeltaEvent = evt.type === 'assistant_delta' || evt.type === 'thinking_delta'
|
||
// Store-write ordering guarantee: everything buffered for this
|
||
// task lands before a non-delta event (turn boundaries call
|
||
// clearDraft; the draft must be flushed first, not after).
|
||
if (!isDeltaEvent) flushPendingDeltas(taskId)
|
||
const ss = sessionStoreRef.current
|
||
const bs = boardStoreRef.current
|
||
const existingSession = ss?.sessions.find(session => session.taskId === taskId)
|
||
const projectionId = typeof data.work_item_projection_id === 'string'
|
||
? data.work_item_projection_id
|
||
: ''
|
||
const executionMode = typeof data.execution_mode === 'string' ? data.execution_mode : ''
|
||
const isTaskModeRuntime = executionMode === 'task_mode' || projectionId === 'task_mode_execution'
|
||
const turnId = typeof data.turn_id === 'string' && data.turn_id
|
||
? data.turn_id
|
||
: typeof data.canonical_turn_id === 'string' && data.canonical_turn_id
|
||
? data.canonical_turn_id
|
||
: typeof data.execution_turn_id === 'string' && data.execution_turn_id
|
||
? data.execution_turn_id
|
||
: undefined
|
||
const marksCompanyRuntime =
|
||
!!projectionId && projectionId !== 'task_mode_execution' && !isTaskModeRuntime
|
||
if (marksCompanyRuntime && !isDeltaEvent && existingSession?.isCompanyRuntime !== true) {
|
||
ss?.setCompanyRuntime(taskId, true)
|
||
}
|
||
|
||
const runtimePartial: Partial<import('./types/kanban').Session> = {
|
||
lastRuntimeEventType: evt.type,
|
||
...(evt.type === 'member_inbox_updated' ? {} : { updatedAt: Date.now() }),
|
||
...sessionRuntimePatchFromPayload(data),
|
||
}
|
||
const toolName = typeof data.tool_name === 'string' ? data.tool_name : undefined
|
||
const kanbanPatch: Partial<KanbanTask> = {
|
||
...kanbanRuntimePatchFromPayload(data),
|
||
// currentTool is active-only (clears between tools); displayTool
|
||
// is sticky and only updates on a real, non-empty tool name.
|
||
...(toolName !== undefined ? { currentTool: toolName || undefined } : {}),
|
||
...(toolName ? { displayTool: toolName } : {}),
|
||
}
|
||
if (isDeltaEvent) {
|
||
const pending = pendingDeltaFlushRef.current
|
||
let entry = pending.get(taskId)
|
||
const deltaText = evt.type === 'assistant_delta' && typeof data.text === 'string'
|
||
? data.text
|
||
: ''
|
||
// A turn boundary inside the buffer would corrupt the draft
|
||
// reset logic (APPEND_DRAFT resets on turnId change) — flush
|
||
// the previous turn's chunk before starting a new one.
|
||
if (
|
||
entry && deltaText && entry.draftText &&
|
||
entry.draftTurnId !== undefined && turnId !== undefined &&
|
||
entry.draftTurnId !== turnId
|
||
) {
|
||
flushPendingDeltas(taskId)
|
||
entry = undefined
|
||
}
|
||
if (!entry) {
|
||
entry = { draftText: '', sessionPatch: {}, kanbanPatch: {} }
|
||
pending.set(taskId, entry)
|
||
}
|
||
if (deltaText) {
|
||
entry.draftText += deltaText
|
||
entry.draftIteration = typeof data.iteration === 'number'
|
||
? data.iteration
|
||
: entry.draftIteration ?? existingSession?.draftIteration
|
||
if (turnId !== undefined) entry.draftTurnId = turnId
|
||
}
|
||
entry.sessionPatch = {
|
||
...entry.sessionPatch,
|
||
...runtimePartial,
|
||
...(marksCompanyRuntime ? { isCompanyRuntime: true } : {}),
|
||
}
|
||
entry.kanbanPatch = { ...entry.kanbanPatch, ...kanbanPatch }
|
||
scheduleDeltaFlush()
|
||
} else {
|
||
if (evt.type === 'turn_started' || evt.type === 'turn_completed' || evt.type === 'turn_failed' || evt.type === 'checkpoint_saved') {
|
||
ss?.clearDraft(taskId)
|
||
}
|
||
ss?.updateSession(taskId, runtimePartial)
|
||
bs?.updateTask(taskId, kanbanPatch)
|
||
}
|
||
const skipDetailRefresh = (
|
||
isTaskModeRuntime && TASK_MODE_LOW_VALUE_RUNTIME_EVENTS.has(evt.type)
|
||
) || SESSION_DETAIL_REFRESH_LOW_VALUE_RUNTIME_EVENTS.has(evt.type)
|
||
if (evt.type !== 'assistant_delta' && !skipDetailRefresh) {
|
||
scheduleSessionDetailRefresh(taskId)
|
||
}
|
||
}
|
||
}
|
||
|
||
if (evt.type === 'agent_removed' && evt.agent_id) {
|
||
const agentId = evt.agent_id
|
||
unassignAgent(agentId)
|
||
const cs = chatStoreRef.current
|
||
if (cs) {
|
||
cs.markSenderDeleted(agentId)
|
||
cs.removeParticipant(agentId)
|
||
}
|
||
}
|
||
|
||
bumpUiTickThrottled()
|
||
} catch (e) { console.error('[onEvent] Error:', e, evt) }
|
||
},
|
||
onAck: (payload) => {
|
||
try {
|
||
if (payload.ok === false) {
|
||
if (payload.action === 'create_session') {
|
||
clearPendingSessionCreate()
|
||
}
|
||
if (shouldSuppressTaskNotFound(payload)) return
|
||
if (payload.action === 'session_detail' && typeof payload.task_id === 'string') {
|
||
sessionStoreRef.current?.updateSession(payload.task_id, {
|
||
detailLoading: false,
|
||
detailError: String(payload.error ?? 'request_failed'),
|
||
})
|
||
}
|
||
setStatusDetail(String(payload.error ?? 'request_failed'))
|
||
setDeletingAgentId(null)
|
||
setConfirmDeleteId(null)
|
||
showToast(String(payload.error ?? 'Request failed'), 'error')
|
||
}
|
||
// Employee deployed to office
|
||
if (payload.ok && payload.action === 'employee_imported') {
|
||
showToast('Employee deployed to office!')
|
||
}
|
||
// Talent import success
|
||
if (payload.ok && payload.action === 'talent_imported') {
|
||
showToast(`Imported ${payload.count ?? 0} templates!`)
|
||
}
|
||
// Talent hire success
|
||
if (payload.ok && payload.action === 'talent_hired') {
|
||
setHiringTemplateId(null)
|
||
showToast(`${payload.name ?? 'Agent'} hired and added to office!`)
|
||
}
|
||
if (payload.ok && payload.action === 'architecture_reset') {
|
||
setActiveSavedOrg(null)
|
||
setSavedOrgVersionAtLoad(null)
|
||
}
|
||
if (payload.ok && payload.action === 'session_detail') {
|
||
if (!payloadMatchesActiveProject(payload, false)) return
|
||
const detailGeneration = payloadViewGeneration(payload)
|
||
if (detailGeneration !== null && detailGeneration !== projectViewGenerationRef.current) return
|
||
const detailTaskId = typeof payload.task_id === 'string' ? payload.task_id : ''
|
||
const detailMessages = Array.isArray(payload.messages)
|
||
? payload.messages.map(mapBackendMessage)
|
||
: []
|
||
const totalMessageCount = typeof payload.message_count === 'number'
|
||
? payload.message_count
|
||
: detailMessages.length
|
||
const detailLevel = payload.detail_level === 'full' ? 'full' : 'summary'
|
||
const cs = chatStoreRef.current
|
||
if (cs && detailMessages.length > 0) {
|
||
cs.mergeMessagesFromBackend(detailMessages)
|
||
}
|
||
const ss = sessionStoreRef.current
|
||
if (ss && detailTaskId) {
|
||
const existingSession = ss.sessions.find(session => session.taskId === detailTaskId)
|
||
const previousHasMore = detailLevel === 'full'
|
||
? existingSession?.fullHasMore
|
||
: existingSession?.summaryHasMore
|
||
const detailHasMore = mergeSessionDetailHasMore(
|
||
previousHasMore,
|
||
payload.has_more === true,
|
||
payload.client_history_page === true,
|
||
)
|
||
const draftTurnId = String(existingSession?.draftTurnId ?? '').trim()
|
||
const detailHasFinalForDraft = !!draftTurnId && detailMessages.some((message) => {
|
||
if (message.sender === 'user') return false
|
||
const metadata = message.metadata ?? {}
|
||
const transcriptKind = String(metadata.transcript_kind ?? metadata.kind ?? '').trim()
|
||
if (transcriptKind !== 'runtime_v2_assistant') return false
|
||
const messageTurnId = String(metadata.canonical_turn_id ?? metadata.turn_id ?? '').trim()
|
||
return messageTurnId === draftTurnId
|
||
})
|
||
if (detailHasFinalForDraft) {
|
||
ss.clearDraft(detailTaskId)
|
||
}
|
||
const rawSessionState = payload.session_state
|
||
const sessionPatch = rawSessionState && typeof rawSessionState === 'object'
|
||
? mapBackendSession(rawSessionState)
|
||
: null
|
||
ss.updateSession(detailTaskId, {
|
||
...(sessionPatch ?? {}),
|
||
...(typeof payload.handoff_context === 'string' ? { handoffContext: payload.handoff_context } : {}),
|
||
...(typeof payload.handoff_to === 'string' ? { handoffTo: payload.handoff_to } : {}),
|
||
messageCount: totalMessageCount,
|
||
detailLoaded: true,
|
||
...(detailLevel === 'full' ? { fullLoaded: !detailHasMore } : {}),
|
||
hasMore: detailHasMore,
|
||
...(detailLevel === 'full'
|
||
? { fullHasMore: detailHasMore }
|
||
: { summaryHasMore: detailHasMore }),
|
||
detailLoading: false,
|
||
detailError: undefined,
|
||
viewGeneration: detailGeneration ?? projectViewGenerationRef.current,
|
||
})
|
||
}
|
||
}
|
||
if (Array.isArray(payload.agents)) {
|
||
const nextAgents = mapAgentListPayload(payload.agents as unknown[], swarmAgentsRef.current)
|
||
swarmAgentsRef.current = nextAgents
|
||
setSwarmAgents(nextAgents)
|
||
for (const agent of nextAgents) {
|
||
bridgeRef.current.ensureAgent(agent.agent_id, agent.name, agent.office_id, agent.appearance?.palette, agent.appearance?.desk_id)
|
||
}
|
||
setUiTick((n) => n + 1)
|
||
}
|
||
if (payload.ok && (payload.agent_id || payload.deleted)) {
|
||
if (payload.deleted) {
|
||
setDeletingAgentId(null)
|
||
setConfirmDeleteId(null)
|
||
showToast('Agent removed')
|
||
}
|
||
if (payload.agent_id && !payload.deleted) {
|
||
showToast('Agent created!')
|
||
}
|
||
clientRef.current?.listAgents()
|
||
}
|
||
// Handle project list response
|
||
if (payload.ok && Array.isArray(payload.projects)) {
|
||
const ps = projectStoreRef.current
|
||
if (ps) {
|
||
const previousActiveId = getActiveProjectId()
|
||
const initialHydration = !projectsHydratedRef.current
|
||
const shouldUseBackendActive = !projectsHydratedRef.current && !userSelectedProjectRef.current
|
||
const backendActiveId = typeof payload.active_project_id === 'string'
|
||
? payload.active_project_id.trim()
|
||
: ''
|
||
const createdProjectId = payload.action === 'create_project' && typeof payload.project_id === 'string'
|
||
? payload.project_id.trim()
|
||
: ''
|
||
const activeId = shouldUseBackendActive
|
||
? normalizeProjectId(createdProjectId || backendActiveId || activeProjectIdRef.current)
|
||
: (createdProjectId ? normalizeProjectId(createdProjectId) : getActiveProjectId())
|
||
if (createdProjectId) userSelectedProjectRef.current = true
|
||
projectsHydratedRef.current = true
|
||
activeProjectIdRef.current = activeId
|
||
ps.initFromBackend(
|
||
payload.projects as { id: string; name: string }[],
|
||
activeId,
|
||
)
|
||
if (activeId !== previousActiveId || initialHydration) {
|
||
const switchSeq = newSwitchSeq()
|
||
currentSwitchSeqRef.current = switchSeq
|
||
pendingProjectSwitchRef.current = activeId
|
||
projectViewGenerationRef.current += 1
|
||
setStatusDetail(`Switching to ${activeId}...`)
|
||
clientRef.current?.switchProject(activeId, switchSeq)
|
||
}
|
||
}
|
||
}
|
||
// Handle collab_sync response
|
||
if (payload.ok && Array.isArray(payload.channels)) {
|
||
if (!payloadMatchesCurrentSwitch(payload)) return
|
||
const syncData = mapCollabSyncPayload(payload)
|
||
const syncProjectId = projectIdFromPayload(payload)
|
||
if (!syncProjectId) return
|
||
const applyingProjectSwitch = pendingProjectSwitchRef.current === syncProjectId || getActiveProjectId() !== syncProjectId
|
||
activeProjectIdRef.current = syncProjectId
|
||
pendingProjectSwitchRef.current = null
|
||
projectStoreRef.current?.setActiveProject(syncProjectId)
|
||
if (applyingProjectSwitch) {
|
||
clearPendingSessionDetailRefreshes()
|
||
setExecutionPanelTaskId(null)
|
||
setCommsState(null)
|
||
setCommsMessage(null)
|
||
}
|
||
setStatusDetail('')
|
||
const cs = chatStoreRef.current
|
||
if (cs) {
|
||
cs.initFromBackend(syncProjectId, syncData.channels, syncData.messages)
|
||
}
|
||
const bs = boardStoreRef.current
|
||
if (bs) {
|
||
bs.initFromBackend(syncProjectId, syncData.boards, syncData.columns, syncData.tasks)
|
||
}
|
||
const ss = sessionStoreRef.current
|
||
if (ss) {
|
||
ss.initFromBackend(syncProjectId, syncData.sessions)
|
||
// After sync, refresh active session detail so content is loaded
|
||
const activeId = ss.activeSessionId
|
||
if (activeId) {
|
||
scheduleSessionDetailRefresh(activeId, 'full', true)
|
||
}
|
||
}
|
||
}
|
||
} catch (e) { console.error('[onAck] Error:', e) }
|
||
},
|
||
onStatus: (next, detail) => {
|
||
setStatus(next)
|
||
setStatusDetail(detail ?? '')
|
||
if (next === 'connected') {
|
||
const projectId = getActiveProjectId()
|
||
client.listProjects()
|
||
// Re-fetch org data so OrgTab isn't stale after reconnect
|
||
client.orgInfo()
|
||
client.orgSavedList()
|
||
client.collabSync(projectId, undefined, projectViewGenerationRef.current)
|
||
}
|
||
},
|
||
onCollabMessage: (type, payload) => {
|
||
try {
|
||
if (type === 'project_index_push') {
|
||
if (!payloadMatchesCurrentSwitch(payload)) return
|
||
} else if (!payloadMatchesActiveProject(payload, false)) return
|
||
const cs = chatStoreRef.current
|
||
if (type === 'chat_new_message') {
|
||
if (cs) cs.addMessageFromBackend(mapBackendMessage(payload))
|
||
} else if (type === 'chat_channel_created') {
|
||
if (cs) cs.addChannelFromBackend(mapBackendChannel(payload))
|
||
} else if (type === 'session_runtime_control') {
|
||
const ss = sessionStoreRef.current
|
||
if (ss) {
|
||
const taskIds = Array.isArray(payload.task_ids)
|
||
? payload.task_ids.map(String).filter(Boolean)
|
||
: []
|
||
const patch: Partial<import('./types/kanban').Session> = {
|
||
runtimeControlState: String(payload.runtime_control_state ?? payload.runtimeControlState ?? 'idle') as any,
|
||
canStop: Boolean(payload.can_stop ?? payload.canStop),
|
||
canResume: Boolean(payload.can_resume ?? payload.canResume),
|
||
resumeParentTaskId: String(payload.resume_parent_task_id ?? payload.resumeParentTaskId ?? ''),
|
||
resumeParentSessionId: String(payload.resume_parent_session_id ?? payload.resumeParentSessionId ?? ''),
|
||
pendingRuntimeCheckpointId: String(payload.pending_runtime_checkpoint_id ?? payload.pendingRuntimeCheckpointId ?? ''),
|
||
stopIntentId: String(payload.stop_intent_id ?? payload.stopIntentId ?? ''),
|
||
}
|
||
for (const taskId of taskIds) {
|
||
ss.updateSession(taskId, patch)
|
||
}
|
||
}
|
||
} else if (type === 'board_task_status_changed') {
|
||
const taskId = String(payload.task_id ?? '')
|
||
const columnId = String(payload.column_id ?? '')
|
||
const statusStr = String(payload.status ?? '')
|
||
const isTerminal = ['done', 'failed', 'cancelled'].includes(statusStr)
|
||
if (taskId && columnId) {
|
||
const bs = boardStoreRef.current
|
||
if (bs) {
|
||
const task = bs.tasks.find(t => t.id === taskId)
|
||
if (task) {
|
||
const partial: Partial<import('./types/kanban').KanbanTask> = {}
|
||
if (task.columnId !== columnId) {
|
||
bs.moveTask(taskId, columnId, 0)
|
||
}
|
||
if (statusStr === 'running') {
|
||
if (!task.agentStatus || task.agentStatus === 'idle') {
|
||
partial.agentStatus = 'reflecting'
|
||
}
|
||
} else if (task.agentStatus || task.currentTool || task.displayTool) {
|
||
partial.agentStatus = undefined
|
||
partial.currentTool = undefined
|
||
partial.displayTool = undefined
|
||
}
|
||
if (Object.keys(partial).length > 0) {
|
||
bs.updateTask(taskId, partial)
|
||
}
|
||
}
|
||
}
|
||
const ss = sessionStoreRef.current
|
||
if (ss) {
|
||
const session = ss.sessions.find(s => s.taskId === taskId)
|
||
const sessionPatch: Partial<import('./types/kanban').Session> = {
|
||
columnId,
|
||
status: statusStr || columnId,
|
||
}
|
||
if (statusStr === 'running') {
|
||
if (!session?.agentStatus || session.agentStatus === 'idle') {
|
||
sessionPatch.agentStatus = 'reflecting'
|
||
}
|
||
} else if (session?.agentStatus || session?.currentTool || session?.displayTool || isTerminal) {
|
||
sessionPatch.agentStatus = undefined
|
||
sessionPatch.currentTool = undefined
|
||
sessionPatch.displayTool = undefined
|
||
}
|
||
Object.assign(
|
||
sessionPatch,
|
||
companyRuntimeControlPatchForBoardStatus(session, statusStr),
|
||
)
|
||
ss.updateSession(taskId, {
|
||
...sessionPatch,
|
||
})
|
||
}
|
||
}
|
||
} else if (type === 'execution_mode_resolved') {
|
||
const mode = String(payload.mode ?? '')
|
||
const profile = String(payload.profile ?? '')
|
||
// Update UI mode selector to reflect what engine actually decided (#13)
|
||
if (mode) {
|
||
const normalizedMode = normalizeExecMode(mode)
|
||
setGlobalExecMode(normalizedMode)
|
||
setGlobalCompanyProfile(
|
||
normalizedMode === 'org'
|
||
? 'custom'
|
||
: normalizedMode === 'company'
|
||
? normalizeCompanyProfile(profile)
|
||
: 'corporate',
|
||
)
|
||
} else if (profile) {
|
||
setGlobalCompanyProfile(normalizeCompanyProfile(profile))
|
||
}
|
||
} else if (type === 'project_run_updated' || type === 'seat_digest_updated' || type === 'work_item_batch_updated') {
|
||
clientRef.current?.collabSync(getActiveProjectId(), undefined, projectViewGenerationRef.current)
|
||
} else if (type === 'collab_sync_push' || type === 'project_index_push') {
|
||
const syncScope = String((payload as Record<string, unknown>).sync_scope ?? (payload as Record<string, unknown>).syncScope ?? '').toLowerCase()
|
||
const isProjectIndexPush = type === 'project_index_push' || syncScope === 'index'
|
||
if (!payloadMatchesCurrentSwitch(payload)) return
|
||
const syncData = mapCollabSyncPayload(payload)
|
||
const syncProjectId = projectIdFromPayload(payload)
|
||
if (!syncProjectId) return
|
||
const applyingProjectSwitch = pendingProjectSwitchRef.current === syncProjectId || getActiveProjectId() !== syncProjectId
|
||
activeProjectIdRef.current = syncProjectId
|
||
pendingProjectSwitchRef.current = null
|
||
projectStoreRef.current?.setActiveProject(syncProjectId)
|
||
if (applyingProjectSwitch) {
|
||
clearPendingSessionDetailRefreshes()
|
||
setExecutionPanelTaskId(null)
|
||
setCommsState(null)
|
||
setCommsMessage(null)
|
||
}
|
||
setStatusDetail('')
|
||
if (isProjectIndexPush) {
|
||
const ss2 = sessionStoreRef.current
|
||
if (ss2) {
|
||
ss2.initFromBackend(syncProjectId, syncData.sessions, {
|
||
preserveExistingWhenIncomingPartial: true,
|
||
preserveActiveWhenMissing: true,
|
||
})
|
||
}
|
||
clientRef.current?.collabSync(syncProjectId, undefined, projectViewGenerationRef.current)
|
||
return
|
||
}
|
||
const cs2 = chatStoreRef.current
|
||
if (cs2) {
|
||
cs2.initFromBackend(syncProjectId, syncData.channels, syncData.messages)
|
||
}
|
||
const bs2 = boardStoreRef.current
|
||
if (bs2) {
|
||
bs2.initFromBackend(syncProjectId, syncData.boards, syncData.columns, syncData.tasks)
|
||
}
|
||
const ss2 = sessionStoreRef.current
|
||
if (ss2) {
|
||
ss2.initFromBackend(syncProjectId, syncData.sessions)
|
||
// Re-sync active session detail after full state push
|
||
const activeId = ss2.activeSessionId
|
||
if (activeId) {
|
||
scheduleSessionDetailRefresh(activeId, 'full', true)
|
||
}
|
||
}
|
||
}
|
||
} catch (e) { console.error('[onCollabMessage] Error:', e) }
|
||
},
|
||
onAgentRuntimeUpdate: (payload) => {
|
||
if (!payloadMatchesActiveProject(payload as unknown as Record<string, unknown>, false)) return
|
||
if (payload.agent_id) {
|
||
setSwarmAgents((prev) => {
|
||
const next = prev.map((agent) => (
|
||
agent.agent_id === payload.agent_id
|
||
? {
|
||
...agent,
|
||
status: payload.status,
|
||
runtime_status: payload.status,
|
||
current_tool: payload.current_tool ?? undefined,
|
||
current_task_id: payload.task_id ?? undefined,
|
||
}
|
||
: agent
|
||
))
|
||
swarmAgentsRef.current = next
|
||
return next
|
||
})
|
||
}
|
||
const bs = boardStoreRef.current
|
||
if (!bs) return
|
||
// Only update the specific task the agent is working on (#2)
|
||
if (payload.task_id) {
|
||
const rawPayload = payload as unknown as Record<string, unknown>
|
||
const boardRuntimePatch = kanbanRuntimePatchFromPayload(rawPayload)
|
||
const sessionRuntimePatch = sessionRuntimePatchFromPayload(rawPayload)
|
||
if (hasOwnPayloadField(rawPayload, 'current_tool')) {
|
||
const currentTool = typeof rawPayload.current_tool === 'string' && rawPayload.current_tool.trim()
|
||
? rawPayload.current_tool
|
||
: undefined
|
||
boardRuntimePatch.currentTool = currentTool
|
||
sessionRuntimePatch.currentTool = currentTool
|
||
if (currentTool) {
|
||
boardRuntimePatch.displayTool = currentTool
|
||
sessionRuntimePatch.displayTool = currentTool
|
||
}
|
||
}
|
||
if (hasOwnPayloadField(rawPayload, 'display_tool')) {
|
||
const displayTool = typeof rawPayload.display_tool === 'string' && rawPayload.display_tool.trim()
|
||
? rawPayload.display_tool
|
||
: undefined
|
||
if (displayTool) {
|
||
boardRuntimePatch.displayTool = displayTool
|
||
sessionRuntimePatch.displayTool = displayTool
|
||
}
|
||
// An empty display_tool arriving between tools keeps the sticky last
|
||
// command; only the terminal-status clear below resets it. This is
|
||
// what stops the header tool-pill from flickering once per tool call.
|
||
}
|
||
if (runtimeStatusClearsDisplayTool(payload.status)) {
|
||
boardRuntimePatch.displayTool = undefined
|
||
sessionRuntimePatch.displayTool = undefined
|
||
}
|
||
if (hasOwnPayloadField(rawPayload, 'tool_elapsed_ms')) {
|
||
const toolElapsedMs = typeof rawPayload.tool_elapsed_ms === 'number' ? rawPayload.tool_elapsed_ms : undefined
|
||
boardRuntimePatch.toolElapsedMs = toolElapsedMs
|
||
sessionRuntimePatch.toolElapsedMs = toolElapsedMs
|
||
}
|
||
if (hasOwnPayloadField(rawPayload, 'last_tool_summary')) {
|
||
const lastToolSummary = typeof rawPayload.last_tool_summary === 'string' ? rawPayload.last_tool_summary : undefined
|
||
boardRuntimePatch.lastToolSummary = lastToolSummary
|
||
sessionRuntimePatch.lastToolSummary = lastToolSummary
|
||
}
|
||
if (typeof payload.iteration === 'number') boardRuntimePatch.iterationCount = payload.iteration
|
||
bs.updateTask(payload.task_id, {
|
||
agentStatus: payload.status,
|
||
...boardRuntimePatch,
|
||
})
|
||
// Also update session sidebar status
|
||
const ss = sessionStoreRef.current
|
||
if (ss) {
|
||
ss.updateSession(payload.task_id, {
|
||
agentStatus: payload.status,
|
||
...sessionRuntimePatch,
|
||
updatedAt: Date.now(),
|
||
})
|
||
}
|
||
scheduleSessionDetailRefresh(payload.task_id)
|
||
}
|
||
},
|
||
onWorkerNotification: (payload) => {
|
||
if (!payloadMatchesActiveProject(payload as unknown as Record<string, unknown>, false)) return
|
||
const data = payload as Record<string, unknown>
|
||
const taskId = typeof payload.task_id === 'string' ? payload.task_id : ''
|
||
if (!taskId) return
|
||
const notification = data as import('./types/kanban').WorkerNotification
|
||
sessionStoreRef.current?.updateSession(taskId, {
|
||
...sessionRuntimePatchFromPayload(data),
|
||
latestNotification: notification,
|
||
updatedAt: Date.now(),
|
||
})
|
||
boardStoreRef.current?.updateTask(taskId, {
|
||
...kanbanRuntimePatchFromPayload(data),
|
||
latestNotification: notification,
|
||
})
|
||
scheduleSessionDetailRefresh(taskId)
|
||
},
|
||
onKanbanViewData: (payload) => {
|
||
if (!payloadMatchesActiveProject(payload as unknown as Record<string, unknown>, false)) return
|
||
const bs = boardStoreRef.current
|
||
if (!bs) return
|
||
bs.initFromBackend(
|
||
projectIdFromPayload(payload as unknown as Record<string, unknown>) || getActiveProjectId(),
|
||
(payload.boards ?? []).map(mapBackendBoard),
|
||
(payload.columns ?? []).map(mapBackendColumn),
|
||
(payload.tasks ?? []).map(mapBackendTask),
|
||
)
|
||
},
|
||
onSessionProgress: (payload) => {
|
||
if (!payloadMatchesActiveProject(payload as unknown as Record<string, unknown>, false)) return
|
||
const ss = sessionStoreRef.current
|
||
if (!ss || !payload.task_id || !payload.entry) return
|
||
ss.appendProgress(payload.task_id, {
|
||
type: payload.entry.type as any,
|
||
summary: payload.entry.summary,
|
||
detail: payload.entry.detail,
|
||
timestamp: payload.entry.timestamp * 1000,
|
||
turnId: typeof payload.entry.turn_id === 'string'
|
||
? payload.entry.turn_id
|
||
: typeof payload.entry.turnId === 'string'
|
||
? payload.entry.turnId
|
||
: undefined,
|
||
itemId: typeof payload.entry.item_id === 'string'
|
||
? payload.entry.item_id
|
||
: typeof payload.entry.itemId === 'string'
|
||
? payload.entry.itemId
|
||
: undefined,
|
||
streamId: typeof payload.entry.stream_id === 'string'
|
||
? payload.entry.stream_id
|
||
: typeof payload.entry.streamId === 'string'
|
||
? payload.entry.streamId
|
||
: undefined,
|
||
toolCallId: typeof payload.entry.tool_call_id === 'string'
|
||
? payload.entry.tool_call_id
|
||
: typeof payload.entry.toolCallId === 'string'
|
||
? payload.entry.toolCallId
|
||
: undefined,
|
||
permissionGroupKey: typeof payload.entry.permission_group_key === 'string'
|
||
? payload.entry.permission_group_key
|
||
: typeof payload.entry.permissionGroupKey === 'string'
|
||
? payload.entry.permissionGroupKey
|
||
: undefined,
|
||
seq: typeof payload.entry.seq === 'number' ? payload.entry.seq : undefined,
|
||
executionMode: typeof payload.entry.execution_mode === 'string'
|
||
? payload.entry.execution_mode
|
||
: typeof payload.entry.executionMode === 'string'
|
||
? payload.entry.executionMode
|
||
: undefined,
|
||
})
|
||
if (payload.entry.type === 'tool_call') {
|
||
const toolLabel = String(payload.entry.summary ?? '').trim()
|
||
ss.updateSession(payload.task_id, {
|
||
...(toolLabel ? { currentTool: toolLabel, displayTool: toolLabel } : {}),
|
||
updatedAt: payload.entry.timestamp * 1000,
|
||
})
|
||
}
|
||
if (payload.entry.type !== 'thinking' && payload.entry.type !== 'verification') {
|
||
scheduleSessionDetailRefresh(payload.task_id)
|
||
}
|
||
},
|
||
onBoardEvent: (payload) => {
|
||
if (!payloadMatchesActiveProject(payload, false)) return
|
||
const bs = boardStoreRef.current
|
||
if (!bs) return
|
||
const taskId = String(payload.task_id ?? '')
|
||
if (!taskId) return
|
||
const ss = sessionStoreRef.current
|
||
if (ss) {
|
||
const session = ss.sessions.find(s => s.taskId === taskId)
|
||
if (session?.mode === 'child') return
|
||
}
|
||
const assigneeIds = Array.isArray(payload.assignee_ids) ? payload.assignee_ids.map(String) : []
|
||
const workItemIdentity = workItemIdentityPatchFromPayload(payload)
|
||
const existing = bs.tasks.find(t => t.id === taskId)
|
||
if (existing) {
|
||
const partial: Partial<import('./types/kanban').KanbanTask> = {}
|
||
if (payload.title) partial.title = String(payload.title)
|
||
if (payload.display_id) partial.displayId = String(payload.display_id)
|
||
if (assigneeIds.length > 0) partial.assigneeIds = assigneeIds
|
||
Object.assign(partial, workItemIdentity)
|
||
if (Object.keys(partial).length > 0) {
|
||
bs.updateTask(taskId, partial)
|
||
}
|
||
return
|
||
}
|
||
const boardId = String(payload.board_id ?? bs.activeBoardId ?? 'default')
|
||
// Find the real Todo column ID for this board
|
||
const boardCols = bs.columns.filter(c => c.boardId === boardId)
|
||
const todoCol = boardCols.find(c => c.name === 'Todo')
|
||
const columnId = todoCol?.id ?? boardCols[0]?.id ?? ''
|
||
if (!columnId) return // no columns exist yet — skip
|
||
bs.createTask({
|
||
boardId,
|
||
columnId,
|
||
title: String(payload.title ?? 'Untitled'),
|
||
taskId,
|
||
displayId: payload.display_id ? String(payload.display_id) : undefined,
|
||
assigneeIds,
|
||
})
|
||
if (Object.keys(workItemIdentity).length > 0) {
|
||
bs.updateTask(taskId, workItemIdentity)
|
||
}
|
||
},
|
||
onSessionCreated: (payload) => {
|
||
try {
|
||
if (!payloadMatchesActiveProject(payload as unknown as Record<string, unknown>, false)) return
|
||
const ss = sessionStoreRef.current
|
||
if (!ss) return
|
||
const taskId = String(payload.task_id ?? '')
|
||
if (!taskId) return
|
||
const eventProjectId = projectIdFromPayload(payload as unknown as Record<string, unknown>)
|
||
if (!eventProjectId) return
|
||
const existing = taskId ? ss.sessions.find(s => s.taskId === taskId) : undefined
|
||
const workItemIdentity = workItemIdentityPatchFromPayload(payload)
|
||
const normalizedSessionExecMode = normalizeExecMode(payload.exec_mode ?? existing?.execMode)
|
||
const payloadCompanyProfile = companyProfileForExecMode(
|
||
normalizedSessionExecMode,
|
||
payload.company_profile ?? existing?.companyProfile,
|
||
)
|
||
const payloadOrgId = orgIdForExecMode(
|
||
normalizedSessionExecMode,
|
||
payload.org_id ?? payload.organization_id ?? existing?.orgId,
|
||
)
|
||
if (normalizedSessionExecMode === 'org' && payloadOrgId) setActiveSavedOrg(payloadOrgId)
|
||
ss.createSession({
|
||
projectId: eventProjectId,
|
||
taskId,
|
||
channelId: payload.channel_id,
|
||
sessionId: payload.session_id,
|
||
parentSessionId: payload.parent_session_id,
|
||
originTaskId: payload.origin_task_id ?? existing?.originTaskId ?? taskId,
|
||
mode: payload.parent_session_id ? 'child' : 'primary',
|
||
execMode: normalizedSessionExecMode,
|
||
companyProfile: payloadCompanyProfile,
|
||
orgId: payloadOrgId,
|
||
preferredAgent: normalizeTaskPreferredAgent(payload.preferred_agent ?? existing?.preferredAgent),
|
||
title: payload.title,
|
||
status: payload.status,
|
||
columnId: existing?.columnId ?? 'todo',
|
||
assigneeIds: Array.isArray(payload.assignee_ids)
|
||
? payload.assignee_ids.map(String)
|
||
: (existing?.assigneeIds ?? []),
|
||
priority: existing?.priority ?? null,
|
||
tags: existing?.tags ?? [],
|
||
progressLog: existing?.progressLog ?? [],
|
||
createdAt: typeof payload.created_at === 'number'
|
||
? payload.created_at * 1000
|
||
: (existing?.createdAt ?? Date.now()),
|
||
updatedAt: Date.now(),
|
||
messageCount: 0,
|
||
...workItemIdentity,
|
||
})
|
||
// Ensure a kanban card exists for this session (1 session = 1 card)
|
||
const bs = boardStoreRef.current
|
||
const execMode = normalizedSessionExecMode
|
||
if (bs && execMode === 'task' && !payload.parent_session_id && taskId && !bs.tasks.find(t => t.id === taskId) && bs.activeBoardId) {
|
||
const boardCols = bs.columns.filter(c => c.boardId === bs.activeBoardId)
|
||
const todoCol = boardCols.find(c => c.name === 'Todo')
|
||
if (todoCol) {
|
||
bs.createTask({
|
||
boardId: bs.activeBoardId,
|
||
columnId: todoCol.id,
|
||
title: payload.title,
|
||
taskId,
|
||
assigneeIds: existing?.assigneeIds ?? [],
|
||
})
|
||
}
|
||
}
|
||
// Only auto-select if the user explicitly created this session.
|
||
if (pendingSessionCreateRef.current) {
|
||
if (pendingSessionCreateProjectIdRef.current !== eventProjectId) return
|
||
clearPendingSessionCreate()
|
||
ss.setActiveSession(payload.task_id)
|
||
// Force a detail refresh so content loads immediately for the new active session
|
||
scheduleSessionDetailRefresh(payload.task_id, 'full', true)
|
||
}
|
||
} catch (e) { console.error('[onSessionCreated] Error:', e) }
|
||
},
|
||
onSessionUpdated: (payload) => {
|
||
try {
|
||
if (!payloadMatchesActiveProject(payload as unknown as Record<string, unknown>, false)) return
|
||
const ss = sessionStoreRef.current
|
||
if (!ss || !payload.task_id) return
|
||
const nextExecMode = normalizeExecMode(payload.exec_mode ?? ss.sessions.find(s => s.taskId === payload.task_id)?.execMode)
|
||
ss.updateSession(payload.task_id, {
|
||
...(payload.exec_mode ? { execMode: nextExecMode } : {}),
|
||
...(payload.exec_mode || payload.company_profile ? { companyProfile: companyProfileForExecMode(nextExecMode, payload.company_profile) } : {}),
|
||
...('org_id' in payload || 'organization_id' in payload ? { orgId: orgIdForExecMode(nextExecMode, payload.org_id ?? payload.organization_id) } : {}),
|
||
...(payload.preferred_agent ? { preferredAgent: normalizeTaskPreferredAgent(payload.preferred_agent) } : {}),
|
||
...(payload.selected_execution_agent ? { selectedExecutionAgent: normalizeTaskPreferredAgent(payload.selected_execution_agent) } : {}),
|
||
})
|
||
if ((payload.exec_mode === 'org' || payload.exec_mode === 'custom') && (payload.org_id || payload.organization_id)) {
|
||
setActiveSavedOrg(String(payload.org_id ?? payload.organization_id))
|
||
}
|
||
} catch (e) { console.error('[onSessionUpdated] Error:', e) }
|
||
},
|
||
onSessionMessage: (payload) => {
|
||
try {
|
||
if (!payloadMatchesActiveProject(payload, false)) return
|
||
const cs = chatStoreRef.current
|
||
if (!cs) return
|
||
const mapped = mapBackendMessage(payload)
|
||
console.debug('[onSessionMessage]', mapped.sender, mapped.channelId, mapped.content?.slice(0, 60))
|
||
cs.addMessageFromBackend(mapped)
|
||
const taskId = mapped.channelId.startsWith('session:') ? mapped.channelId.slice('session:'.length) : ''
|
||
if (taskId && mapped.sender !== 'user') {
|
||
sessionStoreRef.current?.clearDraft(taskId)
|
||
// Force refresh — session messages are critical content that must sync
|
||
scheduleSessionDetailRefresh(taskId, 'full', true)
|
||
}
|
||
} catch (e) { console.error('[onSessionMessage] Error:', e) }
|
||
},
|
||
onSessionTitleUpdated: (payload) => {
|
||
try {
|
||
if (!payloadMatchesActiveProject(payload as unknown as Record<string, unknown>, false)) return
|
||
const ss = sessionStoreRef.current
|
||
if (ss) ss.updateSession(payload.task_id, { title: payload.title })
|
||
// Also update the matching kanban board's name so the title shown
|
||
// on top of the kanban stays in sync with the sidebar. In company
|
||
// mode the board id equals the primary session's task_id (or
|
||
// origin_task_id for child sessions).
|
||
const bs = boardStoreRef.current
|
||
if (bs) {
|
||
const session = ss?.sessions.find(s => s.taskId === payload.task_id)
|
||
const boardId = session?.originTaskId ?? payload.task_id
|
||
const board = bs.boards.find(b => b.id === boardId)
|
||
if (board && board.name !== payload.title) {
|
||
bs.updateBoardName(boardId, payload.title)
|
||
}
|
||
}
|
||
} catch (e) { console.error('[onSessionTitleUpdated] Error:', e) }
|
||
},
|
||
onSessionDeleted: (payload) => {
|
||
try {
|
||
if (!payloadMatchesActiveProject(payload as unknown as Record<string, unknown>, false)) return
|
||
const ss = sessionStoreRef.current
|
||
if (!ss) return
|
||
if (ss.activeSessionId === payload.task_id) {
|
||
ss.setActiveSession(null)
|
||
}
|
||
ss.deleteSession(payload.task_id)
|
||
// Remove kanban card entirely — delete means delete
|
||
const bs = boardStoreRef.current
|
||
if (bs) {
|
||
bs.deleteTask(payload.task_id)
|
||
}
|
||
// Clean up chat messages + channel for this session
|
||
const cs = chatStoreRef.current
|
||
if (cs) {
|
||
cs.removeSessionData(payload.task_id)
|
||
}
|
||
} catch (e) { console.error('[onSessionDeleted] Error:', e) }
|
||
},
|
||
onProjectSwitched: (payload) => {
|
||
if (!payloadMatchesCurrentSwitch(payload as unknown as Record<string, unknown>)) return
|
||
const projectId = typeof payload.project_id === 'string' ? payload.project_id.trim() : ''
|
||
if (!projectId) return
|
||
pendingProjectSwitchRef.current = projectId
|
||
setStatusDetail(`Switching to ${projectId}...`)
|
||
// project_index_push seeds the project view; collab_sync hydrates full runtime state.
|
||
},
|
||
onProjectDeleted: (payload) => {
|
||
const ps = projectStoreRef.current
|
||
if (ps) {
|
||
ps.removeProject(payload.project_id)
|
||
}
|
||
// If backend switched to default, collab_sync_push + project_switched will follow
|
||
},
|
||
onOrgInfo: (payload) => {
|
||
const normalized = normalizeOrgInfoPayload(payload)
|
||
setOrgInfoData(normalized)
|
||
// Capture versionAtLoad: the first org_version we see after a
|
||
// saved-org Load/SaveAs becomes the dirty-detection baseline.
|
||
setSavedOrgVersionAtLoad(prev =>
|
||
prev === null ? (normalized?.org_version ?? 0) : prev,
|
||
)
|
||
if (payload?.project_run?.execution_model === 'multi_team_org' || (Array.isArray(payload?.work_items) && payload.work_items.length > 0)) {
|
||
clientRef.current?.collabSync(getActiveProjectId(), undefined, projectViewGenerationRef.current)
|
||
}
|
||
},
|
||
onRecoveryStatus: (payload) => {
|
||
if (!payloadMatchesActiveProject(payload as unknown as Record<string, unknown>, false)) return
|
||
setRecoveryStatus(payload)
|
||
},
|
||
onCommsState: (payload) => {
|
||
if (!payloadMatchesActiveProject(payload as unknown as Record<string, unknown>, false)) return
|
||
setCommsState(payload)
|
||
},
|
||
onCommsMessage: (payload) => {
|
||
if (!payloadMatchesActiveProject(payload as unknown as Record<string, unknown>, true)) return
|
||
setCommsMessage(payload)
|
||
},
|
||
onRecoveryResult: (payload) => {
|
||
if (!payloadMatchesActiveProject(payload as unknown as Record<string, unknown>, false)) return
|
||
if (payload?.status === 'completed' || payload?.status === 'cancelled') {
|
||
// Trigger a re-scan
|
||
clientRef.current?.recoveryAction(getActiveProjectId(), 'scan')
|
||
}
|
||
},
|
||
onTalentList: (payload) => {
|
||
setTalentTemplates(payload.templates ?? [])
|
||
if (payload.talent_dir) setDefaultTalentDir(payload.talent_dir)
|
||
},
|
||
onEmployeeDetail: (payload) => {
|
||
setEmployeeDetail(payload)
|
||
},
|
||
onReorgList: (payload) => {
|
||
setReorgProposals(payload.proposals ?? [])
|
||
},
|
||
onOrgConfigExport: (payload) => {
|
||
setConfigExportYaml(payload.yaml ?? '')
|
||
},
|
||
onOrgConfigImport: (payload) => {
|
||
// Fires only on manual YAML import via ConfigImportExportPanel.
|
||
// Saved-org Load uses the dedicated onOrgSavedLoad handler, which
|
||
// calls _apply_org_config directly on the backend — no spurious
|
||
// "Dry run OK" banner side-effect.
|
||
if (payload.ok) {
|
||
setConfigImportPreview(payload.preview ?? null)
|
||
setConfigImportError(null)
|
||
if (!payload.dry_run) {
|
||
setActiveSavedOrg(null)
|
||
setSavedOrgVersionAtLoad(null)
|
||
}
|
||
} else {
|
||
setConfigImportError(payload.error ?? 'Import failed')
|
||
setConfigImportPreview(null)
|
||
}
|
||
},
|
||
onOrgSavedList: (payload) => {
|
||
setSavedOrgsList(payload.orgs ?? [])
|
||
if ('active_name' in payload) {
|
||
setActiveSavedOrg(payload.active_name ?? null)
|
||
}
|
||
},
|
||
onOrgSavedSaveAs: (payload) => {
|
||
if (payload.ok) {
|
||
clientRef.current?.orgSavedList()
|
||
setActiveSavedOrg(payload.name)
|
||
// Overwriting (or freshly naming) the active org resets the
|
||
// dirty baseline — the next onOrgInfo captures versionAtLoad.
|
||
setSavedOrgVersionAtLoad(null)
|
||
setOrgToast({ kind: 'ok', text: `Saved "${payload.name}"` })
|
||
} else {
|
||
setOrgToast({ kind: 'error', text: `Save failed: ${payload.error ?? 'unknown'}` })
|
||
}
|
||
},
|
||
onOrgSavedCreate: (payload) => {
|
||
setOrgCreatePending(false)
|
||
setOrgCreateResult({ ...payload, nonce: Date.now() })
|
||
if (payload.ok) {
|
||
const orgId = payload.organization_id || payload.name
|
||
setActiveSavedOrg(orgId)
|
||
setGlobalExecMode('org')
|
||
setGlobalCompanyProfile('custom')
|
||
clientRef.current?.orgSavedList()
|
||
clientRef.current?.orgInfo()
|
||
setSavedOrgVersionAtLoad(null)
|
||
setOrgToast({ kind: 'ok', text: `Created "${payload.organization_name || orgId}"` })
|
||
} else {
|
||
setOrgToast({ kind: 'error', text: `Create failed: ${payload.error ?? 'unknown'}` })
|
||
}
|
||
},
|
||
onOrgSavedLoad: (payload) => {
|
||
if (payload.ok) {
|
||
setActiveSavedOrg(payload.name)
|
||
setGlobalExecMode('org')
|
||
setGlobalCompanyProfile('custom')
|
||
clientRef.current?.orgSavedList()
|
||
// versionAtLoad is captured by the next onOrgInfo — see that handler.
|
||
setSavedOrgVersionAtLoad(null)
|
||
setOrgToast({ kind: 'ok', text: `Loaded "${payload.name}"` })
|
||
} else {
|
||
setOrgToast({ kind: 'error', text: `Load failed: ${payload.error ?? 'unknown'}` })
|
||
}
|
||
},
|
||
onOrgSavedDelete: (payload) => {
|
||
if (payload.ok) {
|
||
clientRef.current?.orgSavedList()
|
||
// Clear the active indicator when the deleted name matches.
|
||
// (Closure over state is stale, so use functional update.)
|
||
setActiveSavedOrg(prev => {
|
||
if (prev === payload.name) {
|
||
setSavedOrgVersionAtLoad(null)
|
||
return null
|
||
}
|
||
return prev
|
||
})
|
||
setOrgToast({ kind: 'ok', text: `Deleted "${payload.name}"` })
|
||
} else {
|
||
setOrgToast({ kind: 'error', text: `Delete failed: ${payload.error ?? 'unknown'}` })
|
||
}
|
||
},
|
||
onMarketBrowse: (payload) => {
|
||
setMarketPresets((payload as any).presets ?? [])
|
||
},
|
||
onMarketPreview: (payload) => {
|
||
setMarketPreviewData(payload as any)
|
||
},
|
||
onChildSessionCreated: (payload) => {
|
||
if (!payloadMatchesActiveProject(payload as unknown as Record<string, unknown>, false)) return
|
||
const ss = sessionStoreRef.current
|
||
if (!ss) return
|
||
const workItemIdentity = workItemIdentityPatchFromPayload(payload as Record<string, unknown>)
|
||
const childProjectId = projectIdFromPayload(payload as unknown as Record<string, unknown>)
|
||
if (!childProjectId) return
|
||
// child_session_created adds a child session to the sidebar
|
||
// The backend also broadcasts session_created which handles kanban card creation
|
||
ss.createSession({
|
||
projectId: childProjectId,
|
||
taskId: payload.task_id,
|
||
channelId: (payload as any).channel_id ?? `session:${payload.task_id}`,
|
||
sessionId: payload.session_id,
|
||
parentSessionId: payload.parent_session_id,
|
||
originTaskId: payload.origin_task_id ?? payload.task_id,
|
||
mode: 'child',
|
||
orgId: String((payload as any).org_id ?? (payload as any).organization_id ?? '').trim() || undefined,
|
||
title: payload.title,
|
||
status: 'pending',
|
||
columnId: 'todo',
|
||
assigneeIds: payload.agent_id ? [payload.agent_id] : [],
|
||
priority: null,
|
||
tags: [],
|
||
createdAt: Date.now(),
|
||
updatedAt: Date.now(),
|
||
messageCount: 0,
|
||
progressLog: [],
|
||
...workItemIdentity,
|
||
})
|
||
// Mark parent session as company runtime (Company mode child sessions)
|
||
if (payload.parent_session_id) {
|
||
const parent = ss.sessions.find(s =>
|
||
s.sessionId === payload.parent_session_id || s.taskId === payload.parent_session_id,
|
||
)
|
||
if (parent && !parent.isCompanyRuntime) {
|
||
ss.setCompanyRuntime(parent.taskId, true)
|
||
}
|
||
}
|
||
},
|
||
onWorkItemProgress: (payload) => {
|
||
if (!payloadMatchesActiveProject(payload as unknown as Record<string, unknown>, false)) return
|
||
const ss = sessionStoreRef.current
|
||
if (!ss || !payload.task_id || !payload.entry) return
|
||
const entryProjectionId = typeof payload.entry.work_item_projection_id === 'string'
|
||
? payload.entry.work_item_projection_id
|
||
: ''
|
||
const projectionId = entryProjectionId && entryProjectionId !== 'company_runtime'
|
||
? entryProjectionId
|
||
: undefined
|
||
const runtimeTaskId = typeof payload.entry.runtime_task_id === 'string' && payload.entry.runtime_task_id
|
||
? payload.entry.runtime_task_id
|
||
: typeof payload.runtime_task_id === 'string' && payload.runtime_task_id
|
||
? payload.runtime_task_id
|
||
: typeof payload.entry.execution_turn_id === 'string' && payload.entry.execution_turn_id
|
||
? payload.entry.execution_turn_id
|
||
: typeof payload.execution_turn_id === 'string' && payload.execution_turn_id
|
||
? payload.execution_turn_id
|
||
: undefined
|
||
const executionTurnId = typeof payload.entry.execution_turn_id === 'string' && payload.entry.execution_turn_id
|
||
? payload.entry.execution_turn_id
|
||
: typeof payload.execution_turn_id === 'string' && payload.execution_turn_id
|
||
? payload.execution_turn_id
|
||
: runtimeTaskId
|
||
ss.updateSession(payload.task_id, {
|
||
isCompanyRuntime: true,
|
||
...(projectionId ? { workItemProjectionId: projectionId } : {}),
|
||
})
|
||
const bs = boardStoreRef.current
|
||
if (bs && projectionId) {
|
||
bs.updateTask(payload.task_id, { workItemProjectionId: projectionId })
|
||
}
|
||
ss.appendWorkItemProgress(payload.task_id, {
|
||
timestamp: payload.entry.timestamp * 1000,
|
||
type: payload.entry.type as any,
|
||
workItemProjectionId: projectionId,
|
||
workItemTurnType: typeof payload.entry.work_item_turn_type === 'string' ? payload.entry.work_item_turn_type : undefined,
|
||
workItemProjectionTitle: typeof payload.entry.work_item_projection_title === 'string' ? payload.entry.work_item_projection_title : undefined,
|
||
runtimeTaskId,
|
||
executionTurnId,
|
||
roleName: payload.entry.role_name,
|
||
detail: payload.entry.detail,
|
||
})
|
||
},
|
||
})
|
||
clientRef.current = client
|
||
client.connect()
|
||
return () => {
|
||
client.disconnect()
|
||
for (const tid of timersRef.current) clearTimeout(tid)
|
||
timersRef.current.clear()
|
||
for (const tid of pendingSessionDetailRefreshRef.current.values()) clearTimeout(tid)
|
||
pendingSessionDetailRefreshRef.current.clear()
|
||
if (deltaFlushTimerRef.current !== null) {
|
||
window.clearTimeout(deltaFlushTimerRef.current)
|
||
deltaFlushTimerRef.current = null
|
||
}
|
||
pendingDeltaFlushRef.current.clear()
|
||
if (uiTickTimerRef.current !== null) {
|
||
window.clearTimeout(uiTickTimerRef.current)
|
||
uiTickTimerRef.current = null
|
||
}
|
||
}
|
||
}, [wsUrl])
|
||
|
||
useEffect(() => {
|
||
const refreshProjectState = () => {
|
||
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return
|
||
const now = Date.now()
|
||
if (now - lastProjectIndexRefreshRef.current < 1_500) return
|
||
lastProjectIndexRefreshRef.current = now
|
||
clientRef.current?.collabSync(getActiveProjectId(), undefined, projectViewGenerationRef.current)
|
||
}
|
||
const handleVisibilityChange = () => {
|
||
if (document.visibilityState === 'visible') refreshProjectState()
|
||
}
|
||
window.addEventListener('focus', refreshProjectState)
|
||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||
return () => {
|
||
window.removeEventListener('focus', refreshProjectState)
|
||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||
}
|
||
}, [getActiveProjectId])
|
||
|
||
// Auto-clear the org-toast after 3s.
|
||
useEffect(() => {
|
||
if (!orgToast) return
|
||
const t = setTimeout(() => setOrgToast(null), 3000)
|
||
return () => clearTimeout(t)
|
||
}, [orgToast])
|
||
|
||
// Stable client-call refs for OrgTab → TeamView → OrgVersionSwitcher.
|
||
// useCallback so downstream effects don't churn on every App re-render.
|
||
const handleSavedOrgsList = useCallback(() => {
|
||
clientRef.current?.orgSavedList()
|
||
}, [])
|
||
const handleSavedOrgSaveAs = useCallback((name: string, overwrite: boolean) => {
|
||
clientRef.current?.orgSavedSaveAs(name, overwrite)
|
||
}, [])
|
||
const handleSavedOrgCreate = useCallback((organizationName: string, members: OrgCreateMemberInput[]) => {
|
||
setOrgCreatePending(true)
|
||
setOrgCreateResult(null)
|
||
clientRef.current?.orgSavedCreate(organizationName, members)
|
||
}, [])
|
||
const handleSavedOrgLoad = useCallback((name: string) => {
|
||
clientRef.current?.orgSavedLoad(name)
|
||
}, [])
|
||
const handleSavedOrgDelete = useCallback((name: string) => {
|
||
clientRef.current?.orgSavedDelete(name)
|
||
}, [])
|
||
const handleSelectCorporateOrg = useCallback(() => {
|
||
setGlobalExecMode('company')
|
||
setGlobalCompanyProfile('corporate')
|
||
setSavedOrgVersionAtLoad(null)
|
||
clientRef.current?.setExecutionMode('company', 'corporate', globalTaskPreferredAgent)
|
||
clientRef.current?.orgInfo()
|
||
}, [globalTaskPreferredAgent])
|
||
|
||
useEffect(() => {
|
||
if (!lastTaskDoneAgent) return
|
||
const agentId = lastTaskDoneAgent
|
||
// Card movement is driven by board_task_status_changed events (Phase 1).
|
||
// This effect only handles the celebration animation.
|
||
bridgeRef.current.setAgentBubble(agentId, 'Done!')
|
||
const tid = setTimeout(() => { timersRef.current.delete(tid); bridgeRef.current.setAgentBubble(agentId, null) }, 3000)
|
||
timersRef.current.add(tid)
|
||
setLastTaskDoneAgent(null)
|
||
}, [lastTaskDoneAgent]) // eslint-disable-line react-hooks/exhaustive-deps
|
||
|
||
const handleTaskAssigned = useCallback(
|
||
(taskId: string, agentIds: string[], taskTitle: string) => {
|
||
const task = boardStore.tasks.find(t => t.id === taskId)
|
||
for (const agentId of agentIds) {
|
||
bridgeRef.current.sendToSeat(agentId)
|
||
bridgeRef.current.setAgentActive(agentId, true)
|
||
bridgeRef.current.setAgentBubble(agentId, `Task: ${taskTitle.slice(0, 22)}`)
|
||
clientRef.current?.assignTaskToAgent(getActiveProjectId(), taskId, agentId, taskTitle)
|
||
}
|
||
if (task) {
|
||
const names = agentIds.map(id => swarmAgents.find(a => a.agent_id === id)?.name ?? id)
|
||
notifyTaskAssigned(chatStore, task, names)
|
||
}
|
||
setUiTick(n => n + 1)
|
||
},
|
||
[boardStore.tasks, chatStore, swarmAgents, getActiveProjectId]
|
||
)
|
||
|
||
|
||
const metrics = useMemo(() => {
|
||
const totalAgents = snapshot ? Object.keys(snapshot.agents).length : 0
|
||
const totalSkills = snapshot?.skills.total ?? 0
|
||
return { totalAgents, totalSkills }
|
||
}, [snapshot])
|
||
|
||
const cards = useMemo(() => {
|
||
const all = bridgeRef.current.getCharacterCards()
|
||
const visible = showSubagents ? all : all.filter((c) => !c.isSubagent)
|
||
return visible.slice().sort((a, b) => a.displayName.localeCompare(b.displayName))
|
||
}, [showSubagents, uiTick])
|
||
|
||
const offices = useMemo(() => getOffices(), [uiTick])
|
||
const officeMap = useMemo(() => {
|
||
const m: Record<string, string> = {}
|
||
for (const c of cards) { if (c.officeId) m[c.id] = c.officeId }
|
||
return m
|
||
}, [cards])
|
||
|
||
// Board initialization is driven entirely by collab_sync from backend (#7)
|
||
// No local ensureDefaultBoards — avoids column ID mismatch with backend IDs
|
||
|
||
const [editingOfficeName, setEditingOfficeName] = useState<string | null>(null)
|
||
const [officeNameDraft, setOfficeNameDraft] = useState('')
|
||
|
||
const handleRenameOffice = (officeId: string) => {
|
||
if (officeNameDraft.trim()) {
|
||
bridgeRef.current.renameOffice(officeId, officeNameDraft.trim())
|
||
setUiTick(t => t + 1)
|
||
}
|
||
setEditingOfficeName(null)
|
||
}
|
||
|
||
const handleAssignAgent = (officeId: string, agentId: string) => {
|
||
bridgeRef.current.assignAgentToOffice(agentId, officeId)
|
||
// Sync office assignment to backend
|
||
clientRef.current?.moveAgent(agentId, officeId)
|
||
setUiTick(t => t + 1)
|
||
}
|
||
|
||
const handleChangeSeat = (agentId: string, seatId: string) => {
|
||
bridgeRef.current.changeAgentSeat(agentId, seatId)
|
||
setUiTick(t => t + 1)
|
||
}
|
||
|
||
const selectedCard = cards.find((c) => c.id === selectedAgentId) ?? null
|
||
|
||
const selectedAgentSeats = useMemo(() => {
|
||
if (!selectedCard) return []
|
||
return bridgeRef.current.getSeatsForOffice(selectedCard.officeId)
|
||
}, [selectedCard?.officeId, uiTick]) // eslint-disable-line react-hooks/exhaustive-deps
|
||
|
||
const evolutionPhases = useMemo(() => {
|
||
const recent = events.slice(-40)
|
||
return {
|
||
trace: recent.some((e) => e.type === 'tool_start' || e.type === 'tool_done'),
|
||
reflect: recent.some((e) => e.type === 'reflect_start' || e.type === 'reflect_done'),
|
||
synthesize: recent.some((e) => e.type === 'skill_synthesized'),
|
||
}
|
||
}, [events])
|
||
|
||
const eventTypes = useMemo(() => {
|
||
const uniq = Array.from(new Set(events.map((evt) => evt.type)))
|
||
return ['all', ...uniq]
|
||
}, [events])
|
||
|
||
const filteredEvents = useMemo(() => {
|
||
const list = eventTypeFilter === 'all' ? events : events.filter((evt) => evt.type === eventTypeFilter)
|
||
return list.slice().reverse()
|
||
}, [eventTypeFilter, events])
|
||
|
||
|
||
const applyWsUrl = () => {
|
||
const next = wsUrlInput.trim()
|
||
if (!next || next === wsUrl) return
|
||
setWsUrl(next)
|
||
}
|
||
|
||
const selectAgent = useCallback((agentId: string) => {
|
||
setSelectedAgentId(agentId)
|
||
setUiTick((n) => n + 1)
|
||
}, [])
|
||
|
||
const handleSessionModeChange = useCallback((taskId: string, mode: string, profile?: string, orgId?: string) => {
|
||
const existingSession = sessionStore.sessions.find(session => session.taskId === taskId)
|
||
const normalizedMode = normalizeExecMode(mode)
|
||
const currentProfile = normalizeCompanyProfile(existingSession?.companyProfile ?? globalCompanyProfile)
|
||
const nextProfile = normalizedMode === 'org'
|
||
? 'custom'
|
||
: normalizedMode === 'company'
|
||
? 'corporate'
|
||
: 'corporate'
|
||
const currentOrgId = String(existingSession?.orgId ?? activeSavedOrg ?? '').trim()
|
||
const nextOrgId = orgIdForExecMode(normalizedMode, orgId ?? existingSession?.orgId ?? activeSavedOrg)
|
||
const currentSessionMode = normalizeExecMode(existingSession?.execMode)
|
||
const currentGlobalProfile = normalizeCompanyProfile(globalCompanyProfile)
|
||
|
||
if (
|
||
currentSessionMode === normalizedMode
|
||
&& currentProfile === nextProfile
|
||
&& currentOrgId === String(nextOrgId ?? '').trim()
|
||
&& globalExecMode === normalizedMode
|
||
&& currentGlobalProfile === nextProfile
|
||
) {
|
||
return
|
||
}
|
||
|
||
sessionStore.updateSession(taskId, {
|
||
execMode: normalizedMode,
|
||
companyProfile: nextProfile,
|
||
orgId: nextOrgId,
|
||
preferredAgent: existingSession?.preferredAgent ?? globalTaskPreferredAgent,
|
||
})
|
||
setGlobalExecMode(normalizedMode)
|
||
setGlobalCompanyProfile(nextProfile)
|
||
if (normalizedMode === 'org' && nextOrgId) setActiveSavedOrg(nextOrgId)
|
||
|
||
const nextPreferredAgent = existingSession?.preferredAgent ?? globalTaskPreferredAgent
|
||
const runtimeProfile = normalizedMode === 'task' ? undefined : nextProfile
|
||
clientRef.current?.sessionUpdateConfig(getActiveProjectId(), taskId, normalizedMode, runtimeProfile, nextPreferredAgent, nextOrgId)
|
||
clientRef.current?.setExecutionMode(normalizedMode, runtimeProfile, nextPreferredAgent, nextOrgId)
|
||
}, [sessionStore, globalExecMode, globalCompanyProfile, globalTaskPreferredAgent, activeSavedOrg, getActiveProjectId])
|
||
|
||
const handleSessionTaskAgentChange = useCallback((taskId: string, preferredAgent: TaskPreferredAgent) => {
|
||
const existingSession = sessionStore.sessions.find(session => session.taskId === taskId)
|
||
const normalizedPreferredAgent = normalizeTaskPreferredAgent(preferredAgent)
|
||
const normalizedMode = normalizeExecMode(existingSession?.execMode)
|
||
const nextProfile = normalizedMode === 'org'
|
||
? 'custom'
|
||
: normalizedMode === 'company'
|
||
? 'corporate'
|
||
: 'corporate'
|
||
|
||
sessionStore.updateSession(taskId, {
|
||
preferredAgent: normalizedPreferredAgent,
|
||
selectedExecutionAgent: normalizedPreferredAgent,
|
||
})
|
||
setGlobalTaskPreferredAgent(normalizedPreferredAgent)
|
||
|
||
const runtimeProfile = normalizedMode === 'task' ? undefined : nextProfile
|
||
const orgId = orgIdForExecMode(normalizedMode, existingSession?.orgId ?? activeSavedOrg)
|
||
clientRef.current?.sessionUpdateConfig(getActiveProjectId(), taskId, normalizedMode, runtimeProfile, normalizedPreferredAgent, orgId)
|
||
clientRef.current?.setExecutionMode(normalizedMode, runtimeProfile, normalizedPreferredAgent, orgId)
|
||
}, [sessionStore, globalCompanyProfile, activeSavedOrg, getActiveProjectId])
|
||
|
||
// Triggered from a locked-mode chip's "Continue in a new chat" popover.
|
||
// We do NOT mutate the existing chat or its task — instead we spin up a
|
||
// fresh session in the requested mode under the same project. The existing
|
||
// pendingSessionCreateRef flow then auto-focuses the new chat as soon as
|
||
// the server emits `session_created`.
|
||
const handleContinueInNewChat = useCallback((
|
||
mode: 'task' | 'company' | 'org' | 'custom',
|
||
profile?: 'corporate' | 'custom',
|
||
orgId?: string,
|
||
) => {
|
||
if (pendingProjectSwitchRef.current || pendingSessionCreateRef.current) return
|
||
const projectId = getActiveProjectId()
|
||
beginPendingSessionCreate(projectId)
|
||
|
||
const normalizedMode = normalizeExecMode(mode)
|
||
// Resolve a sensible profile per mode so the backend gets the right
|
||
// company configuration even if the chip didn't carry one.
|
||
const resolvedProfile: 'corporate' | 'custom' | undefined =
|
||
normalizedMode === 'org'
|
||
? 'custom'
|
||
: normalizedMode === 'company'
|
||
? 'corporate'
|
||
: undefined
|
||
|
||
clientRef.current?.createSession(
|
||
projectId,
|
||
undefined,
|
||
normalizedMode,
|
||
resolvedProfile,
|
||
globalTaskPreferredAgent,
|
||
orgIdForExecMode(normalizedMode, orgId ?? activeSavedOrg),
|
||
)
|
||
setActivePage('workspace')
|
||
}, [getActiveProjectId, beginPendingSessionCreate, globalTaskPreferredAgent, activeSavedOrg])
|
||
|
||
const markRuntimeControlForTask = useCallback((
|
||
taskId: string,
|
||
patch: Partial<import('./types/kanban').Session>,
|
||
) => {
|
||
const session = sessionStore.sessions.find(s => s.taskId === taskId)
|
||
const parentSessionId = session?.resumeParentSessionId ?? session?.parentSessionId ?? session?.sessionId
|
||
const parentTaskId = session?.resumeParentTaskId
|
||
?? (parentSessionId ? sessionStore.sessions.find(s => s.sessionId === parentSessionId && !s.parentSessionId)?.taskId : undefined)
|
||
?? taskId
|
||
for (const candidate of sessionStore.sessions) {
|
||
if (
|
||
candidate.taskId === taskId
|
||
|| candidate.taskId === parentTaskId
|
||
|| (!!parentSessionId && (candidate.parentSessionId === parentSessionId || candidate.sessionId === parentSessionId))
|
||
) {
|
||
sessionStore.updateSession(candidate.taskId, {
|
||
...patch,
|
||
resumeParentTaskId: parentTaskId,
|
||
resumeParentSessionId: parentSessionId,
|
||
})
|
||
}
|
||
}
|
||
}, [sessionStore])
|
||
|
||
const handleSessionStop = useCallback((taskId: string) => {
|
||
const session = sessionStore.sessions.find(s => s.taskId === taskId)
|
||
const isCompanyRuntime = session?.execMode === 'company'
|
||
|| session?.execMode === 'org'
|
||
|| session?.execMode === 'custom'
|
||
|| !!session?.isCompanyRuntime
|
||
|| !!session?.parentSessionId
|
||
|| !!session?.companyProfile
|
||
if (isCompanyRuntime) {
|
||
markRuntimeControlForTask(taskId, {
|
||
runtimeControlState: 'suspending',
|
||
canStop: false,
|
||
canResume: false,
|
||
})
|
||
}
|
||
clientRef.current?.sessionStop(getActiveProjectId(), taskId)
|
||
}, [sessionStore.sessions, markRuntimeControlForTask, getActiveProjectId])
|
||
|
||
const handleSessionResume = useCallback((taskId: string) => {
|
||
const session = sessionStore.sessions.find(s => s.taskId === taskId)
|
||
const isCompanyRuntime = session?.execMode === 'company'
|
||
|| session?.execMode === 'org'
|
||
|| session?.execMode === 'custom'
|
||
|| !!session?.isCompanyRuntime
|
||
|| !!session?.parentSessionId
|
||
|| !!session?.companyProfile
|
||
if (isCompanyRuntime) {
|
||
markRuntimeControlForTask(taskId, {
|
||
runtimeControlState: 'resuming',
|
||
canStop: false,
|
||
canResume: false,
|
||
})
|
||
}
|
||
clientRef.current?.sessionResume(getActiveProjectId(), taskId)
|
||
}, [sessionStore.sessions, markRuntimeControlForTask, getActiveProjectId])
|
||
|
||
const handleGlobalModeChange = useCallback((mode: 'task' | 'company' | 'org' | 'custom', profile?: string, orgId?: string) => {
|
||
const normalizedMode = normalizeExecMode(mode)
|
||
const nextProfile = normalizedMode === 'org'
|
||
? 'custom'
|
||
: normalizedMode === 'company'
|
||
? 'corporate'
|
||
: 'corporate'
|
||
const nextOrgId = orgIdForExecMode(normalizedMode, orgId ?? activeSavedOrg)
|
||
setGlobalExecMode(normalizedMode)
|
||
setGlobalCompanyProfile(nextProfile)
|
||
if (nextOrgId) setActiveSavedOrg(nextOrgId)
|
||
clientRef.current?.setExecutionMode(
|
||
normalizedMode,
|
||
normalizedMode === 'task' ? undefined : nextProfile,
|
||
globalTaskPreferredAgent,
|
||
nextOrgId,
|
||
)
|
||
}, [globalCompanyProfile, globalTaskPreferredAgent, activeSavedOrg])
|
||
|
||
// Whether the current mode allows agent creation/editing
|
||
const isOrgMode = globalExecMode === 'org'
|
||
const globalModeLabel = globalExecMode === 'task'
|
||
? 'task'
|
||
: globalExecMode === 'org'
|
||
? `company/${activeSavedOrg ?? 'org'}`
|
||
: `company/${globalCompanyProfile}`
|
||
|
||
|
||
return (
|
||
<div className={`app-shell theme-${theme}`}>
|
||
{orgToast && (
|
||
<div className={`org-toast org-toast--${orgToast.kind}`} role="status" aria-live="polite">
|
||
{orgToast.text}
|
||
</div>
|
||
)}
|
||
{/* Topbar */}
|
||
<header className="topbar">
|
||
<div className="topbar-left">
|
||
<span className="logo-text">Open<span className="logo-accent">OPC</span></span>
|
||
<div className={`conn-dot ${statusClass(status)}`} title={`${status}${statusDetail ? ` — ${statusDetail}` : ''}\n${wsUrl}`} />
|
||
<ProjectSelector
|
||
projects={projectStore.projects}
|
||
activeId={projectStore.activeProjectId}
|
||
onSelect={(id) => {
|
||
const switchSeq = beginProjectSwitch(id)
|
||
clientRef.current?.switchProject(id, switchSeq)
|
||
}}
|
||
onCreate={(id) => {
|
||
clientRef.current?.createProject(id)
|
||
}}
|
||
onDelete={(id) => clientRef.current?.deleteProject(id)}
|
||
/>
|
||
</div>
|
||
<div className="topbar-center">
|
||
<div className="page-nav">
|
||
<button className={`page-nav-btn${activePage === 'workspace' ? ' active' : ''}`} onClick={() => setActivePage('workspace')}>
|
||
Workspace
|
||
{(() => {
|
||
const total = chatStore.channels.reduce((sum, ch) => sum + chatStore.getUnreadCount(ch.id), 0)
|
||
return total > 0 ? <span className="nav-unread-badge">{total > 99 ? '99+' : total}</span> : null
|
||
})()}
|
||
</button>
|
||
<button className={`page-nav-btn${activePage === 'office' ? ' active' : ''}`} onClick={() => setActivePage('office')}>Office</button>
|
||
<button className={`page-nav-btn${activePage === 'org' ? ' active' : ''}`} onClick={() => setActivePage('org')}>Org</button>
|
||
</div>
|
||
<div className="stat-chips">
|
||
<span className="stat-chip"><b>{metrics.totalAgents}</b> agents</span>
|
||
<span className="stat-chip"><b>{metrics.totalSkills}</b> skills</span>
|
||
<span className="stat-chip"><b>{boardStore.getOpenTaskCount()}</b> tasks</span>
|
||
</div>
|
||
</div>
|
||
<div className="topbar-right">
|
||
<select
|
||
className="theme-select"
|
||
value={outdoorOverride}
|
||
title="Outdoor lighting override"
|
||
onChange={(e) => {
|
||
const v = e.target.value as 'auto' | 'day' | 'night'
|
||
setOutdoorOverride(v)
|
||
try {
|
||
if (v === 'auto') {
|
||
localStorage.removeItem('opc_outdoor_override')
|
||
localStorage.removeItem('opc_outdoor_day')
|
||
localStorage.removeItem('opc_outdoor_night')
|
||
} else {
|
||
localStorage.setItem('opc_outdoor_override', v)
|
||
localStorage.removeItem('opc_outdoor_day')
|
||
localStorage.removeItem('opc_outdoor_night')
|
||
}
|
||
} catch { /* private mode */ }
|
||
bridgeRef.current.syncOutdoorLighting()
|
||
}}
|
||
>
|
||
<option value="auto">Outdoor auto</option>
|
||
<option value="day">Outdoor day</option>
|
||
<option value="night">Outdoor night</option>
|
||
</select>
|
||
<select className="theme-select" value={theme} onChange={(e) => setTheme(e.target.value as ThemeName)}>
|
||
<option value="midnight">Midnight</option>
|
||
<option value="neon">Neon</option>
|
||
<option value="paper">Paper</option>
|
||
<option value="retro">Retro</option>
|
||
<option value="terminal">Terminal</option>
|
||
<option value="cozy">Cozy</option>
|
||
<option value="openopc">OpenOPC</option>
|
||
</select>
|
||
<button className={`icon-btn ${showDevTools ? 'active' : ''}`} onClick={() => setShowDevTools((v) => !v)} title="Developer Tools">
|
||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M5.5 2L2 5.5 5.5 9" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/><path d="M10.5 7L14 10.5 10.5 14" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
{/* Workspace Page (unified Chat + Kanban) */}
|
||
{activePage === 'workspace' && (
|
||
<WorkspacePage
|
||
boardStore={boardStore}
|
||
chatStore={chatStore}
|
||
sessionStore={sessionStore}
|
||
agents={swarmAgents}
|
||
officeMap={officeMap}
|
||
execMode={globalExecMode}
|
||
companyProfile={globalCompanyProfile}
|
||
taskPreferredAgent={globalTaskPreferredAgent}
|
||
projectId={projectStore.activeProjectId}
|
||
orgInfoData={orgInfoData}
|
||
onNavigateToOrg={() => setActivePage('org')}
|
||
savedOrgsList={savedOrgsList}
|
||
activeSavedOrg={activeSavedOrg}
|
||
onSavedOrgsList={handleSavedOrgsList}
|
||
onSavedOrgLoad={handleSavedOrgLoad}
|
||
recoveryStatus={recoveryStatus}
|
||
onRecoveryResume={(id) => clientRef.current?.recoveryAction(getActiveProjectId(), 'resume', id)}
|
||
onRecoveryCancel={(id) => clientRef.current?.recoveryAction(getActiveProjectId(), 'cancel', id)}
|
||
commsState={commsState}
|
||
commsMessage={commsMessage}
|
||
onCommsRefresh={(opts) => {
|
||
const { project_id: _ignoredProjectId, ...scopedOpts } = opts ?? {}
|
||
clientRef.current?.commsState(getActiveProjectId(), scopedOpts)
|
||
}}
|
||
onCommsReadMessage={(path) => clientRef.current?.commsReadMessage(getActiveProjectId(), path)}
|
||
onRunTask={(taskId, title, desc, mode, profile) => {
|
||
clientRef.current?.send({ type: 'run_task', project_id: getActiveProjectId(), task_id: taskId, title, description: desc, mode, profile })
|
||
}}
|
||
onCreateTask={(title, boardId, columnId, taskId) => {
|
||
clientRef.current?.send({ type: 'kanban_create_task', project_id: getActiveProjectId(), title, board_id: boardId, column_id: columnId, task_id: taskId })
|
||
}}
|
||
onMoveTask={(taskId, columnId) => {
|
||
clientRef.current?.send({ type: 'kanban_move_task', project_id: getActiveProjectId(), task_id: taskId, column_id: columnId })
|
||
}}
|
||
onCreateSession={() => {
|
||
if (pendingProjectSwitchRef.current || pendingSessionCreateRef.current) return
|
||
const projectId = getActiveProjectId()
|
||
beginPendingSessionCreate(projectId)
|
||
clientRef.current?.createSession(
|
||
projectId,
|
||
undefined,
|
||
globalExecMode,
|
||
companyProfileForExecMode(globalExecMode, globalCompanyProfile),
|
||
globalTaskPreferredAgent,
|
||
orgIdForExecMode(globalExecMode, activeSavedOrg),
|
||
)
|
||
}}
|
||
onSessionSend={(taskId, content, attachments, metadata) => clientRef.current?.sessionSend(getActiveProjectId(), taskId, content, attachments, metadata)}
|
||
onSecretarySend={(content) => clientRef.current?.secretarySend(getActiveProjectId(), content)}
|
||
onDeleteSession={(taskId) => clientRef.current?.deleteSession(getActiveProjectId(), taskId)}
|
||
onTitleChange={(taskId, title) => clientRef.current?.sessionUpdateTitle(getActiveProjectId(), taskId, title)}
|
||
onSessionConfigChange={handleSessionModeChange}
|
||
onSessionTaskAgentChange={handleSessionTaskAgentChange}
|
||
onContinueInNewChat={handleContinueInNewChat}
|
||
onSessionStop={handleSessionStop}
|
||
onSessionResume={handleSessionResume}
|
||
onSessionComplete={(taskId) => clientRef.current?.sessionComplete(getActiveProjectId(), taskId)}
|
||
onLoadSessionDetail={(taskId, opts) => {
|
||
const client = clientRef.current
|
||
if (!client) return
|
||
return client.sessionDetail(
|
||
getActiveProjectId(),
|
||
taskId,
|
||
{
|
||
...opts,
|
||
include: opts?.detailLevel === 'full'
|
||
? ['messages', 'session_state', 'progress', 'work_items', 'runtime_context']
|
||
: ['messages', 'session_state'],
|
||
viewGeneration: projectViewGenerationRef.current,
|
||
},
|
||
).then((payload) => {
|
||
if (payload.ok === false) {
|
||
throw new Error(String(payload.error ?? 'session_detail failed'))
|
||
}
|
||
})
|
||
}}
|
||
onOpenExecutionPanel={(taskId) => setExecutionPanelTaskId(taskId)}
|
||
onCollabSync={() => clientRef.current?.collabSync(getActiveProjectId(), undefined, projectViewGenerationRef.current)}
|
||
/>
|
||
)}
|
||
|
||
{/* Org Page */}
|
||
{activePage === 'org' && (
|
||
<div className="org-page">
|
||
<OrgTab
|
||
data={orgInfoData}
|
||
sessionRecruitmentByRole={sessionRecruitmentByRole}
|
||
talents={talentTemplates}
|
||
employeeDetail={employeeDetail}
|
||
reorgProposals={reorgProposals}
|
||
isCustomMode={isOrgMode}
|
||
onRequestData={() => clientRef.current?.orgInfo()}
|
||
onRequestTalents={() => clientRef.current?.talentList()}
|
||
onRequestEmployeeDetail={(id) => clientRef.current?.employeeDetail(id)}
|
||
onHireTalent={(tid, rid) => {
|
||
setHiringTemplateId(tid)
|
||
clientRef.current?.talentHire(tid, rid, undefined, orgInfoData?.organization_id || activeSavedOrg || undefined)
|
||
}}
|
||
hiringTemplateId={hiringTemplateId}
|
||
onImportEmployee={(empId) => clientRef.current?.importEmployeeAsAgent(empId)}
|
||
onRequestReorgList={() => clientRef.current?.reorgList()}
|
||
onReorgDecide={(pid, approved, notes) => clientRef.current?.reorgDecide(pid, approved, notes)}
|
||
onMarketExport={(data) => clientRef.current?.marketExport(data)}
|
||
onMarketInstall={(path, strategy) => clientRef.current?.marketInstall(path, strategy)}
|
||
onMarketUninstall={(pkgId) => clientRef.current?.marketUninstall(pkgId)}
|
||
marketPresets={marketPresets}
|
||
marketPreviewData={marketPreviewData}
|
||
onMarketBrowse={() => clientRef.current?.marketBrowse()}
|
||
onMarketPreview={(id) => clientRef.current?.marketPreview(id)}
|
||
onMarketApplyPreset={(id, strategy) => clientRef.current?.marketApplyPreset(id, strategy)}
|
||
onMarketClearPreview={() => setMarketPreviewData(null)}
|
||
onAddRole={(rid, name, resp, rt, icon) => clientRef.current?.addRole(rid, name, resp, rt, icon)}
|
||
onBulkAddRoles={(roles) => clientRef.current?.bulkAddRoles(roles)}
|
||
onUpdateRole={(rid, updates) => clientRef.current?.updateRole(rid, updates)}
|
||
onDeleteRole={(rid) => clientRef.current?.deleteRole(rid)}
|
||
onUpdateOrgStrategy={(data) => clientRef.current?.updateOrgStrategy(data)}
|
||
onUpdateRuntimePolicy={(policy) => clientRef.current?.updateRuntimePolicy(policy)}
|
||
onResetArchitecture={() => clientRef.current?.resetArchitecture()}
|
||
onConfigExport={() => clientRef.current?.orgConfigExport()}
|
||
onConfigImport={(yaml, dryRun) => clientRef.current?.orgConfigImport(yaml, dryRun)}
|
||
configExportYaml={configExportYaml}
|
||
configImportPreview={configImportPreview}
|
||
configImportError={configImportError}
|
||
onSavedOrgsList={handleSavedOrgsList}
|
||
onSavedOrgSaveAs={handleSavedOrgSaveAs}
|
||
onSavedOrgCreate={handleSavedOrgCreate}
|
||
onSavedOrgLoad={handleSavedOrgLoad}
|
||
onSavedOrgDelete={handleSavedOrgDelete}
|
||
savedOrgsList={savedOrgsList}
|
||
activeSavedOrg={activeSavedOrg}
|
||
activeSavedOrgVersionAtLoad={savedOrgVersionAtLoad}
|
||
orgCreatePending={orgCreatePending}
|
||
orgCreateResult={orgCreateResult}
|
||
onSelectCorporate={handleSelectCorporateOrg}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{activePage === 'mapEditor' && (
|
||
<div className="editor-page">
|
||
<CollisionEditor bridge={bridgeRef.current} />
|
||
</div>
|
||
)}
|
||
|
||
{/* Main Grid */}
|
||
<main className={`main-grid${activePage !== 'office' ? ' hidden' : ''}${sidebarCollapsed ? ' sidebar-collapsed' : ''}`}>
|
||
{/* Phaser Game Canvas */}
|
||
<section className="canvas-wrap">
|
||
<PhaserGame bridge={bridgeRef.current} active={activePage === 'office'} />
|
||
<button className="canvas-float-btn" onClick={() => setShowSubagents((v) => !v)} title={showSubagents ? 'Hide sub-agents' : 'Show sub-agents'}>
|
||
{showSubagents ? '👥' : '👤'}
|
||
</button>
|
||
<button
|
||
className="sidebar-collapse-btn"
|
||
onClick={toggleSidebar}
|
||
title={sidebarCollapsed ? 'Show side panel' : 'Hide side panel'}
|
||
aria-label={sidebarCollapsed ? 'Show side panel' : 'Hide side panel'}
|
||
>
|
||
<span className="collapse-glyph">{sidebarCollapsed ? '❮' : '❯'}</span>
|
||
</button>
|
||
</section>
|
||
|
||
{/* Sidebar */}
|
||
<aside className="sidebar">
|
||
<div className="sidebar-body">
|
||
{/* Team Panel */}
|
||
<div className="team-panel">
|
||
{/* Mode info — team building is in Org tab */}
|
||
<div className="mode-info-bar">
|
||
<span className="mode-badge">{globalExecMode === 'company' ? `${globalExecMode}/${globalCompanyProfile}` : globalModeLabel}</span>
|
||
{isOrgMode ? (
|
||
<span className="mode-hint">Manage your team in the <b>Org</b> tab</span>
|
||
) : (
|
||
<span className="mode-hint">Switch to <b>Org</b> mode to create or manage agents</span>
|
||
)}
|
||
</div>
|
||
|
||
<div className="section-label">Offices <span className="count-badge">{offices.length}</span></div>
|
||
<div className="office-cards">
|
||
{offices.map((office) => {
|
||
const deskCount = getOfficeDeskSeats(office.id).length
|
||
const assignedCards = cards.filter(c => c.officeId === office.id)
|
||
const otherAgents = cards.filter(c => c.officeId !== office.id && !c.isSubagent)
|
||
return (
|
||
<div key={office.id} className="office-card" onClick={() => bridgeRef.current.panToOffice(office.id)}>
|
||
<div className="office-card-header">
|
||
{editingOfficeName === office.id ? (
|
||
<input
|
||
className="office-name-input"
|
||
value={officeNameDraft}
|
||
onChange={e => setOfficeNameDraft(e.target.value)}
|
||
onBlur={() => handleRenameOffice(office.id)}
|
||
onKeyDown={e => { if (e.key === 'Enter') handleRenameOffice(office.id); if (e.key === 'Escape') setEditingOfficeName(null) }}
|
||
autoFocus
|
||
onClick={e => e.stopPropagation()}
|
||
/>
|
||
) : (
|
||
<>
|
||
<span className="office-name">{office.name}</span>
|
||
<button className="office-edit-btn" title="Rename" onClick={(e) => { e.stopPropagation(); setEditingOfficeName(office.id); setOfficeNameDraft(office.name) }}>✎</button>
|
||
</>
|
||
)}
|
||
<span className="office-capacity">{assignedCards.length}/{deskCount}</span>
|
||
</div>
|
||
<div className="office-agents">
|
||
{assignedCards.map(c => (
|
||
<span key={c.id} className="office-agent-chip" title={`${c.displayName} — ${c.seatId ?? 'no seat'}`} onClick={(e) => { e.stopPropagation(); selectAgent(c.id) }}>
|
||
{c.displayName.slice(0, 8)}
|
||
</span>
|
||
))}
|
||
{isOrgMode && assignedCards.length < deskCount && otherAgents.length > 0 && (
|
||
<select
|
||
className="assign-dropdown"
|
||
value=""
|
||
onClick={e => e.stopPropagation()}
|
||
onChange={e => { if (e.target.value) handleAssignAgent(office.id, e.target.value) }}
|
||
>
|
||
<option value="">+ Move here</option>
|
||
{otherAgents.map(a => (
|
||
<option key={a.id} value={a.id}>{a.displayName} ({offices.find(o => o.id === (cards.find(cc => cc.id === a.id)?.officeId))?.name ?? '?'})</option>
|
||
))}
|
||
</select>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
<div className="section-label">Active Agents <span className="count-badge">{swarmAgents.length}</span></div>
|
||
<div className="agent-list">
|
||
{swarmAgents.map((agent) => (
|
||
<div key={agent.agent_id} className={`agent-row ${selectedAgentId === agent.agent_id ? 'selected' : ''}`}>
|
||
<button className="agent-row-main" onClick={() => selectAgent(agent.agent_id)}>
|
||
<span className={`dot ${agent.status}`} />
|
||
<div className="agent-info">
|
||
<span className="agent-name">{agent.name}</span>
|
||
<span className="agent-spec">{agent.specialties.slice(0, 2).join(' · ') || 'general'}</span>
|
||
</div>
|
||
</button>
|
||
{isOrgMode && (
|
||
deletingAgentId === agent.agent_id
|
||
? <span className="agent-del" style={{ pointerEvents: 'none' }}><span className="spinner-inline" /></span>
|
||
: confirmDeleteId === agent.agent_id
|
||
? <span className="del-confirm">
|
||
<span className="del-confirm-label">Delete?</span>
|
||
<button className="del-confirm-yes" onClick={() => { setDeletingAgentId(agent.agent_id); setConfirmDeleteId(null); clientRef.current?.deleteAgent(agent.agent_id) }}>Yes</button>
|
||
<button className="del-confirm-no" onClick={() => setConfirmDeleteId(null)}>No</button>
|
||
</span>
|
||
: <button className="agent-del" title={`Remove ${agent.name}`} onClick={() => setConfirmDeleteId(agent.agent_id)}>×</button>
|
||
)}
|
||
</div>
|
||
))}
|
||
{swarmAgents.length === 0 && (
|
||
<div className="empty-state">No agents yet — click a template above to spawn one.</div>
|
||
)}
|
||
</div>
|
||
|
||
{selectedCard && (
|
||
<div className="agent-detail">
|
||
<div className="agent-detail-name">{selectedCard.displayName}</div>
|
||
<div className="agent-detail-row"><span className="detail-label">State</span><span className="detail-value">{selectedCard.state}</span></div>
|
||
<div className="agent-detail-row"><span className="detail-label">Tool</span><span className="detail-value">{selectedCard.currentTool ?? '—'}</span></div>
|
||
<div className="agent-detail-row"><span className="detail-label">Task</span><span className="detail-value">{selectedCard.taskSummary ?? '—'}</span></div>
|
||
<div className="agent-detail-row">
|
||
<span className="detail-label">Office</span>
|
||
<select
|
||
className="detail-select"
|
||
value={selectedCard.officeId}
|
||
onChange={e => { handleAssignAgent(e.target.value, selectedCard.id) }}
|
||
disabled={!isOrgMode}
|
||
>
|
||
{offices.map(o => <option key={o.id} value={o.id}>{o.name}</option>)}
|
||
</select>
|
||
</div>
|
||
<div className="agent-detail-row">
|
||
<span className="detail-label">Seat</span>
|
||
<select
|
||
className="detail-select"
|
||
value={selectedCard.seatId ?? ''}
|
||
onChange={e => { if (e.target.value) handleChangeSeat(selectedCard.id, e.target.value) }}
|
||
disabled={!isOrgMode}
|
||
>
|
||
<option value="">—</option>
|
||
{selectedAgentSeats.map(s => {
|
||
const label = s.id.replace(/^office-\d+-/, '').replace('-', ' ').replace(/\b\w/g, ch => ch.toUpperCase())
|
||
const taken = s.assigned && s.assignedTo !== selectedCard.id
|
||
return (
|
||
<option key={s.id} value={s.id} disabled={taken}>
|
||
{label}{taken ? ` (${s.assignedTo})` : s.assignedTo === selectedCard.id ? ' ✓' : ''}
|
||
</option>
|
||
)
|
||
})}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{cards.length > swarmAgents.length && (
|
||
<>
|
||
<div className="section-label">
|
||
Characters
|
||
<button className="inline-btn" onClick={() => setShowSubagents((v) => !v)}>{showSubagents ? 'hide sub' : 'show sub'}</button>
|
||
</div>
|
||
<div className="agent-list">
|
||
{cards.filter((c) => !swarmAgents.some((a) => a.agent_id === c.id)).map((card) => (
|
||
<button key={card.id} className={`agent-row-simple ${selectedAgentId === card.id ? 'selected' : ''}`} onClick={() => selectAgent(card.id)}>
|
||
{card.isSubagent && <span className="sub-badge">SUB</span>}
|
||
<span className="agent-name">{card.displayName}</span>
|
||
<span className="agent-spec">{card.state}{card.currentTool ? ` · ${card.currentTool}` : ''}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
</main>
|
||
|
||
{/* Developer Tools Overlay */}
|
||
{showDevTools && (
|
||
<div className="dev-overlay">
|
||
<div className="dev-header">
|
||
<span className="dev-title">Developer Tools</span>
|
||
<button className="icon-btn" onClick={() => setShowDevTools(false)}>✕</button>
|
||
</div>
|
||
<div className="dev-group">
|
||
<div className="dev-label">Connection</div>
|
||
<div className="input-row">
|
||
<input value={wsUrlInput} onChange={(e) => setWsUrlInput(e.target.value)} placeholder="ws://..." />
|
||
<button className="send-btn" onClick={applyWsUrl}>↩</button>
|
||
</div>
|
||
</div>
|
||
<div className="dev-group">
|
||
<div className="dev-label">Evolution Pipeline</div>
|
||
<div className="evo-pipeline">
|
||
{(['Trace', 'Reflect', 'Synthesize', 'Practice', 'Lifecycle'] as const).map((phase, i) => {
|
||
const key = phase.toLowerCase() as keyof typeof evolutionPhases
|
||
const active = key in evolutionPhases ? evolutionPhases[key as 'trace' | 'reflect' | 'synthesize'] : false
|
||
return (
|
||
<div key={phase} className="evo-phase-group">
|
||
{i > 0 && <div className="evo-connector" />}
|
||
<div className={`evo-node ${active ? 'active' : ''}`}>
|
||
<div className="evo-dot" />
|
||
<span className="evo-label">{phase}</span>
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
<div className="list">
|
||
{(snapshot?.skills.recent ?? []).slice(-6).reverse().map((item, idx) => (
|
||
<div className="list-row" key={`${item.skill_name}-${item.timestamp}-${idx}`}>
|
||
<span>{item.skill_name}</span>
|
||
<span className="muted mono">{item.version}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="dev-group">
|
||
<div className="dev-label">
|
||
Events
|
||
<select className="inline-select" value={eventTypeFilter} onChange={(e) => setEventTypeFilter(e.target.value)}>
|
||
{eventTypes.map((type) => <option key={type} value={type}>{type}</option>)}
|
||
</select>
|
||
</div>
|
||
<div className="event-log">
|
||
{filteredEvents.slice(0, 30).map((evt) => (
|
||
<div key={evt.event_id} className="log-row">
|
||
<span className="log-time">{new Date(evt.timestamp * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}</span>
|
||
<span className="log-type">{evt.type}</span>
|
||
<span className="log-agent">{evt.agent_id}</span>
|
||
<span className="log-data">{truncateJson(evt.data)}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
{Object.keys(snapshot?.channels ?? {}).length > 0 && (
|
||
<div className="dev-group">
|
||
<div className="dev-label">Channels</div>
|
||
{Object.entries(snapshot?.channels ?? {}).map(([name, info]) => (
|
||
<div className="list-row" key={name}>
|
||
<span>{name}</span>
|
||
<span className="muted">{String((info as { last_type?: string }).last_type ?? 'idle')}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
{toastMessage && <div className={toastType === 'error' ? 'toast-error' : 'toast-success'}>{toastMessage}</div>}
|
||
{/* ── Global Execution Panel (accessible from any page) ── */}
|
||
<MaybeExecutionPanel
|
||
taskId={executionPanelTaskId}
|
||
sessions={sessionStore.sessions}
|
||
agents={swarmAgents}
|
||
onClose={() => setExecutionPanelTaskId(null)}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|