1320 lines
60 KiB
TypeScript
1320 lines
60 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
import type { RoleWorkItemSummary, Session, TaskPreferredAgent } from '../types/kanban'
|
|
import type { ChatMessage, CheckpointReplyMetadata, OutgoingAttachmentPayload } from '../types/chat'
|
|
import type { AgentInfo, OrgInfoPayload, SavedOrgSummary } from '../types/visual'
|
|
import type { CommsStatePayload, CommsMessagePayload } from '../lib/wsClient'
|
|
import { CommsPanel } from './CommsPanel'
|
|
import { ProjectCockpit } from './ProjectCockpit'
|
|
import { PRIORITY_META, type TaskPriority } from '../types/kanban'
|
|
import { TaskHeaderBar } from '../chat/TaskHeaderBar'
|
|
import { MarkdownBody, MessageList } from '../chat/MessageList'
|
|
import { MessageComposer } from '../chat/MessageComposer'
|
|
import { analyzeCheckpointMessages, checkpointReplyMetadataForComposer } from '../chat/checkpointUtils'
|
|
import { AgentWorkPanel } from '../chat/AgentWorkPanel'
|
|
import { WorkItemProgressCard } from '../chat/WorkItemProgressCard'
|
|
import { IconTimeline, IconHandoff, IconTool } from '../chat/SvgIcons'
|
|
import { isSessionWorking } from '../lib/sessionRuntime'
|
|
import { getWorkItemRoleLabel } from '../lib/workItemIdentity'
|
|
import { getLinkedRuntimeTaskId } from '../lib/workItemRuntimeIds'
|
|
import { TaskDetailView } from './TaskDetailView'
|
|
import {
|
|
getConversationPeerSessions,
|
|
getConversationHeaderSession,
|
|
getConversationSessionView,
|
|
getConversationMessageCount,
|
|
getWorkItemRoleSessions,
|
|
mergeConversationProgressLog,
|
|
projectSessionConversation,
|
|
} from '../lib/workItemSessions'
|
|
|
|
type ActiveView =
|
|
| { kind: 'session'; taskId: string }
|
|
| { kind: 'task-detail'; taskId: string }
|
|
| { kind: 'activity' }
|
|
| { kind: 'secretary' }
|
|
| { kind: 'child-detail' }
|
|
|
|
interface ContextPanelProps {
|
|
panelState: 'collapsed' | 'open' | 'maximized'
|
|
width: number
|
|
onResizeMouseDown: (e: React.MouseEvent) => void
|
|
isResizing: boolean
|
|
|
|
activeView: ActiveView
|
|
activeSession: Session | null
|
|
activeTask?: import('../types/kanban').KanbanTask | null
|
|
linkedTaskSession?: Session | null
|
|
linkedTaskSessionMessages?: ChatMessage[]
|
|
childDetailSession: Session | null
|
|
|
|
messages: ChatMessage[]
|
|
childDetailMessages: ChatMessage[]
|
|
allSessions: Session[]
|
|
openSessions: Session[]
|
|
openSessionMessages: Record<string, ChatMessage[]>
|
|
openSessionChildren: Record<string, Session[]>
|
|
agents: AgentInfo[]
|
|
childSessions: Session[]
|
|
execMode?: string
|
|
taskPreferredAgent: TaskPreferredAgent
|
|
savedOrgsList?: SavedOrgSummary[] | null
|
|
activeSavedOrg?: string | null
|
|
onSavedOrgsList?: () => void
|
|
onSavedOrgLoad?: (name: string) => void
|
|
canShowAgentsTab?: boolean
|
|
|
|
channelId: string
|
|
channelName: string
|
|
secretaryChannelId: string
|
|
unreadCounts?: Record<string, number>
|
|
multiSessionView?: boolean
|
|
|
|
panelTab: 'chat' | 'agents' | 'info' | 'comms' | 'team'
|
|
onPanelTabChange: (tab: 'chat' | 'agents' | 'info' | 'comms' | 'team') => void
|
|
|
|
commsState?: CommsStatePayload | null
|
|
commsMessage?: CommsMessagePayload | null
|
|
onCommsRefresh?: () => void
|
|
onCommsReadMessage?: (path: string) => void
|
|
orgInfoData?: OrgInfoPayload | null
|
|
canShowTeamTab?: boolean
|
|
onTeamStopRun?: () => void
|
|
|
|
onTitleChange: (taskId: string, title: string) => void
|
|
onSessionConfigChange?: (taskId: string, execMode: string, companyProfile?: string, orgId?: string) => void
|
|
onSessionTaskAgentChange?: (taskId: string, preferredAgent: TaskPreferredAgent) => void
|
|
/**
|
|
* User asked to "continue this conversation in a different mode" from the
|
|
* locked-mode chip popover. We expect the host to spin up a fresh chat in
|
|
* the requested mode (inside the same project).
|
|
*/
|
|
onContinueInNewChat?: (mode: 'task' | 'company' | 'org' | 'custom', companyProfile?: 'corporate' | 'custom', orgId?: string) => void
|
|
onStop?: () => void
|
|
onComplete?: () => void
|
|
onResume?: () => void
|
|
onResumeTask?: (taskId: string) => void
|
|
onStopTask?: (taskId: string) => void
|
|
onCompleteTask?: (taskId: string) => void
|
|
onLocateOnBoard?: (taskId: string) => void
|
|
onBackToParent?: () => void
|
|
onCloseTaskDetail?: () => void
|
|
onOpenChildDetail?: (taskId: string) => void
|
|
onOpenExecutionPanel?: (taskId: string) => void
|
|
onSelectSessionTab?: (taskId: string) => void
|
|
onCloseSessionTab?: (taskId: string) => void
|
|
onToggleMultiSessionView?: () => void
|
|
onCollapse: () => void
|
|
onExpand: () => void
|
|
onMaximize: () => void
|
|
|
|
onComposerSend: (content: string, attachments?: OutgoingAttachmentPayload[]) => void
|
|
onMessageSend: (content: string, taskId?: string, metadata?: CheckpointReplyMetadata) => void
|
|
onSessionSend?: (
|
|
taskId: string,
|
|
content: string,
|
|
attachments?: OutgoingAttachmentPayload[],
|
|
metadata?: CheckpointReplyMetadata,
|
|
) => void
|
|
onWorkItemClick: (executionTurnId: string) => void
|
|
onWorkItemOpenSession?: (executionTurnId: string) => void
|
|
onMarkRead: () => void
|
|
onSessionMarkRead?: (taskId: string) => void
|
|
onLoadSessionHistory?: (
|
|
taskId: string,
|
|
oldestMessage?: ChatMessage,
|
|
detailLevel?: 'summary' | 'full',
|
|
) => Promise<void> | void
|
|
isSessionHistoryLoading?: (taskId: string) => boolean
|
|
}
|
|
|
|
function relativeTime(ts: number): string {
|
|
const diff = Date.now() - ts
|
|
if (diff < 60_000) return 'just now'
|
|
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m`
|
|
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h`
|
|
return `${Math.floor(diff / 86_400_000)}d`
|
|
}
|
|
|
|
function sessionRuntimeLabel(session: Session, activeChildCount: number): string | null {
|
|
if (activeChildCount > 1) return `${activeChildCount} agents working`
|
|
const displayTool = session.displayTool || session.currentTool
|
|
if (displayTool) return `Running ${displayTool}`
|
|
if (session.agentStatus === 'reflecting') return 'Thinking...'
|
|
if (session.status === 'running') return 'Running'
|
|
return null
|
|
}
|
|
|
|
function activeRoleWorkItemCount(roleWorkItems?: Record<string, RoleWorkItemSummary>): number | undefined {
|
|
if (!roleWorkItems || Object.keys(roleWorkItems).length === 0) return undefined
|
|
return Object.values(roleWorkItems).filter(summary => summary.aggregatedStatus === 'active').length
|
|
}
|
|
|
|
function distinctWorkingRoleCount(sessions: Session[]): number {
|
|
const roles = new Set(
|
|
sessions
|
|
.filter(isSessionWorking)
|
|
.map(s => String(s.workItemRoleId ?? s.assigneeIds[0] ?? s.taskId).trim())
|
|
.filter(Boolean),
|
|
)
|
|
return roles.size
|
|
}
|
|
|
|
function activeAgentCountFor(
|
|
roleWorkItems: Record<string, RoleWorkItemSummary> | undefined,
|
|
sessions: Session[],
|
|
): number | undefined {
|
|
const roleCount = activeRoleWorkItemCount(roleWorkItems)
|
|
if (roleCount !== undefined) return roleCount || undefined
|
|
return distinctWorkingRoleCount(sessions) || undefined
|
|
}
|
|
|
|
function sessionModeLabel(session: Session): string {
|
|
const execMode = composerExecModeForSession(session)
|
|
if (execMode === 'org' || execMode === 'custom') return `company/${session.orgId ?? 'org'}`
|
|
if (execMode === 'company') return `company/${session.companyProfile ?? 'corporate'}`
|
|
return execMode
|
|
}
|
|
|
|
function normalizePanelExecMode(value?: string): 'task' | 'company' | 'org' {
|
|
const normalized = String(value ?? '').trim().toLowerCase()
|
|
if (normalized === 'company') return 'company'
|
|
if (normalized === 'org' || normalized === 'custom') return 'org'
|
|
return 'task'
|
|
}
|
|
|
|
function hasCompanyRuntimeIdentity(session: Session): boolean {
|
|
return !!(
|
|
session.isCompanyRuntime
|
|
|| session.parentSessionId
|
|
|| session.workItemProjectionId
|
|
|| session.roleWorkItems
|
|
|| session.executorRoleWorkItems
|
|
)
|
|
}
|
|
|
|
function isCompanyRuntimeSession(
|
|
session: Session | null | undefined,
|
|
relatedSessionCount = 0,
|
|
): boolean {
|
|
if (!session) return false
|
|
const mode = String(session.execMode ?? '').trim().toLowerCase()
|
|
return relatedSessionCount > 0
|
|
|| hasCompanyRuntimeIdentity(session)
|
|
|| mode === 'company'
|
|
|| mode === 'org'
|
|
|| mode === 'custom'
|
|
}
|
|
|
|
export function sessionHasMoreForDetail(
|
|
session: Session,
|
|
detailLevel: 'summary' | 'full',
|
|
): boolean | undefined {
|
|
const scoped = detailLevel === 'full' ? session.fullHasMore : session.summaryHasMore
|
|
if (scoped !== undefined) return scoped
|
|
// Old snapshots only expose the unscoped value. Once either scoped cursor
|
|
// has been observed, the generic field may describe the other policy.
|
|
if (session.summaryHasMore === undefined && session.fullHasMore === undefined) {
|
|
return session.hasMore
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
export function conversationHasOlderHistory(
|
|
sessions: Session[],
|
|
displayedMessageCount: number,
|
|
detailLevel: 'summary' | 'full',
|
|
allowMessageCountFallback = true,
|
|
): boolean {
|
|
const pagination = sessions.map(session => sessionHasMoreForDetail(session, detailLevel))
|
|
if (pagination.some(hasMore => hasMore === true)) return true
|
|
if (sessions.length !== 1 || pagination[0] === false) return false
|
|
return allowMessageCountFallback && sessions[0].messageCount > displayedMessageCount
|
|
}
|
|
|
|
function hasCustomRuntimeIdentity(session: Session): boolean {
|
|
const rawMode = String(session.execMode ?? '').trim().toLowerCase()
|
|
const normalizedMode = normalizePanelExecMode(session.execMode)
|
|
const profile = String(session.companyProfile ?? '').trim().toLowerCase()
|
|
if (normalizedMode === 'company') return false
|
|
if (normalizedMode === 'org') return true
|
|
if (rawMode) return false
|
|
return profile === 'custom' || !!session.orgId
|
|
}
|
|
|
|
function isSessionConfigLocked(session: Session | undefined, visibleMessageCount = 0): boolean {
|
|
if (!session) return visibleMessageCount > 0
|
|
const messageCount = Math.max(session.messageCount ?? 0, visibleMessageCount)
|
|
if (messageCount > 0) return true
|
|
|
|
const status = String(session.status ?? '').trim().toLowerCase()
|
|
if (status && status !== 'pending') return true
|
|
|
|
if (
|
|
session.parentSessionId
|
|
|| session.workItemProjectionId
|
|
|| session.workItemRoleId
|
|
|| session.workItemTurnType
|
|
|| session.pendingRuntimeCheckpointId
|
|
) {
|
|
return true
|
|
}
|
|
|
|
if (session.runtimeControlState && session.runtimeControlState !== 'idle') return true
|
|
if ((session.progressLog?.length ?? 0) > 0 || (session.workItemLog?.length ?? 0) > 0) return true
|
|
if (session.roleWorkItems && Object.keys(session.roleWorkItems).length > 0) return true
|
|
if (session.executorRoleWorkItems && Object.keys(session.executorRoleWorkItems).length > 0) return true
|
|
return false
|
|
}
|
|
|
|
export function composerExecModeForSession(session: Session, fallbackExecMode?: string): string {
|
|
const rawMode = String(session.execMode ?? '').trim().toLowerCase()
|
|
const hasExplicitMode = rawMode.length > 0
|
|
const normalized = normalizePanelExecMode(session.execMode)
|
|
if (hasCustomRuntimeIdentity(session)) {
|
|
return 'org'
|
|
}
|
|
if (!hasExplicitMode && normalized === 'task' && hasCompanyRuntimeIdentity(session)) {
|
|
return 'company'
|
|
}
|
|
return session.execMode ?? fallbackExecMode ?? 'task'
|
|
}
|
|
|
|
function composerTaskAgentForSession(
|
|
session: Session,
|
|
locked: boolean,
|
|
fallbackAgent: TaskPreferredAgent,
|
|
): TaskPreferredAgent {
|
|
if (locked) {
|
|
if (session.selectedExecutionAgent && session.selectedExecutionAgent !== 'native') {
|
|
return session.selectedExecutionAgent
|
|
}
|
|
return session.preferredAgent ?? session.selectedExecutionAgent ?? fallbackAgent
|
|
}
|
|
return session.preferredAgent ?? session.selectedExecutionAgent ?? fallbackAgent
|
|
}
|
|
|
|
function sessionDetailLevel(
|
|
session: Session | null | undefined,
|
|
options?: { childDetail?: boolean },
|
|
): 'summary' | 'full' {
|
|
const childDetail = !!options?.childDetail
|
|
if (!session) return 'summary'
|
|
if (childDetail) return 'full'
|
|
return session.execMode === 'company' || session.execMode === 'org' || session.execMode === 'custom' ? 'summary' : 'full'
|
|
}
|
|
|
|
const HUMAN_REVIEW_STATUSES = new Set([
|
|
'awaiting_human',
|
|
'awaiting_manager_review',
|
|
'awaiting_review',
|
|
'awaiting_peer',
|
|
])
|
|
|
|
function canShowContinue(session: Session): boolean {
|
|
const status = String(session.status ?? '').trim()
|
|
const fallback = session.runtimeControlState === 'suspended'
|
|
|| (!HUMAN_REVIEW_STATUSES.has(status) && status !== 'running' && status !== 'done')
|
|
return Boolean(session.canResume ?? fallback)
|
|
}
|
|
|
|
function InfoTabView({
|
|
task,
|
|
agents,
|
|
roleLabel,
|
|
}: {
|
|
task: Session
|
|
agents: AgentInfo[]
|
|
roleLabel: string | null
|
|
}) {
|
|
const [showDev, setShowDev] = useState(false)
|
|
const priorityKey = task.priority as TaskPriority | undefined
|
|
const priorityMeta = priorityKey && (task.priority as string) in PRIORITY_META
|
|
? PRIORITY_META[priorityKey]
|
|
: null
|
|
const assigneeNames = task.assigneeIds
|
|
.map(id => agents.find(a => a.agent_id === id)?.name ?? id)
|
|
.filter(Boolean)
|
|
const createdAt = new Date(task.createdAt)
|
|
const employeeLabel = task.employeeAssignment?.name
|
|
? `${task.employeeAssignment.name}${task.employeeAssignment.category ? ` · ${task.employeeAssignment.category}` : ''}`
|
|
: null
|
|
// Field-level "fact" rendering helper — uniform spacing + treatment.
|
|
const Fact = ({ label, value }: { label: string; value: React.ReactNode }) => (
|
|
<div className="ctx-info-fact">
|
|
<span className="ctx-info-fact-label">{label}</span>
|
|
<span className="ctx-info-fact-value">{value}</span>
|
|
</div>
|
|
)
|
|
return (
|
|
<div className="ctx-info-v2">
|
|
<section className="ctx-info-section">
|
|
<h4 className="ctx-info-section-title">Status</h4>
|
|
<div className="ctx-info-card">
|
|
<div className="ctx-info-status-row">
|
|
<span className={`ctx-info-status-pill status-${task.status}`}>
|
|
<span className="ctx-info-status-dot" />
|
|
{task.status}
|
|
</span>
|
|
{priorityMeta && (
|
|
<span className="ctx-info-priority-pill" style={{ color: priorityMeta.color }}>
|
|
<span aria-hidden="true">{priorityMeta.symbol}</span>
|
|
{priorityMeta.label}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{task.tags && task.tags.length > 0 && (
|
|
<div className="ctx-info-chips">
|
|
{task.tags.map(tag => (
|
|
<span key={tag} className="ctx-info-chip">{tag}</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</section>
|
|
|
|
<section className="ctx-info-section">
|
|
<h4 className="ctx-info-section-title">People & agent</h4>
|
|
<div className="ctx-info-card">
|
|
{assigneeNames.length > 0 && (
|
|
<Fact
|
|
label="Assignees"
|
|
value={
|
|
<div className="ctx-info-chips">
|
|
{assigneeNames.map(name => (
|
|
<span key={name} className="ctx-info-chip">{name}</span>
|
|
))}
|
|
</div>
|
|
}
|
|
/>
|
|
)}
|
|
{roleLabel && <Fact label="Role" value={roleLabel} />}
|
|
{employeeLabel && <Fact label="Employee" value={employeeLabel} />}
|
|
{task.selectedExecutionAgent && (
|
|
<Fact label="Execution agent" value={
|
|
<span className="ctx-info-mono">{task.selectedExecutionAgent}</span>
|
|
} />
|
|
)}
|
|
</div>
|
|
</section>
|
|
|
|
<section className="ctx-info-section">
|
|
<h4 className="ctx-info-section-title">Timing</h4>
|
|
<div className="ctx-info-card">
|
|
<Fact
|
|
label="Created"
|
|
value={
|
|
<span>
|
|
{createdAt.toLocaleString(undefined, {
|
|
year: 'numeric', month: 'short', day: 'numeric',
|
|
hour: '2-digit', minute: '2-digit',
|
|
})}
|
|
</span>
|
|
}
|
|
/>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="ctx-info-section ctx-info-dev-section">
|
|
<button
|
|
className={`ctx-info-dev-toggle${showDev ? ' expanded' : ''}`}
|
|
type="button"
|
|
onClick={() => setShowDev(v => !v)}
|
|
aria-expanded={showDev}
|
|
>
|
|
<svg width="11" height="11" viewBox="0 0 16 16" aria-hidden="true">
|
|
<path
|
|
d={showDev ? 'M3 6l5 5 5-5' : 'M6 3l5 5-5 5'}
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="1.6"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
/>
|
|
</svg>
|
|
<span>Developer details</span>
|
|
</button>
|
|
{showDev && (
|
|
<div className="ctx-info-card ctx-info-dev-card">
|
|
{task.workItemProjectionId && (
|
|
<Fact label="Projection" value={
|
|
<span className="ctx-info-mono ctx-info-mono-break">{task.workItemProjectionId}</span>
|
|
} />
|
|
)}
|
|
<Fact label="Task ID" value={
|
|
<span className="ctx-info-mono ctx-info-mono-break">{task.taskId}</span>
|
|
} />
|
|
<Fact label="Channel" value={
|
|
<span className="ctx-info-mono ctx-info-mono-break">{task.channelId}</span>
|
|
} />
|
|
{task.sessionId && (
|
|
<Fact label="Session" value={
|
|
<span className="ctx-info-mono ctx-info-mono-break">{task.sessionId}</span>
|
|
} />
|
|
)}
|
|
</div>
|
|
)}
|
|
</section>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function ContextPanel({
|
|
panelState,
|
|
width,
|
|
onResizeMouseDown,
|
|
isResizing,
|
|
activeView,
|
|
activeSession,
|
|
activeTask,
|
|
linkedTaskSession: linkedTaskSessionProp,
|
|
linkedTaskSessionMessages,
|
|
childDetailSession,
|
|
messages,
|
|
childDetailMessages,
|
|
allSessions,
|
|
openSessions,
|
|
openSessionMessages,
|
|
openSessionChildren,
|
|
agents,
|
|
childSessions,
|
|
execMode,
|
|
taskPreferredAgent,
|
|
savedOrgsList,
|
|
activeSavedOrg,
|
|
onSavedOrgsList,
|
|
onSavedOrgLoad,
|
|
canShowAgentsTab = false,
|
|
channelId,
|
|
channelName,
|
|
secretaryChannelId,
|
|
unreadCounts,
|
|
multiSessionView = false,
|
|
panelTab,
|
|
onPanelTabChange,
|
|
commsState,
|
|
commsMessage,
|
|
onCommsRefresh,
|
|
onCommsReadMessage,
|
|
orgInfoData,
|
|
canShowTeamTab = false,
|
|
onTeamStopRun,
|
|
onTitleChange,
|
|
onSessionConfigChange,
|
|
onSessionTaskAgentChange,
|
|
onContinueInNewChat,
|
|
onStop,
|
|
onComplete,
|
|
onResume,
|
|
onResumeTask,
|
|
onStopTask,
|
|
onCompleteTask,
|
|
onLocateOnBoard,
|
|
onBackToParent,
|
|
onCloseTaskDetail,
|
|
onOpenChildDetail,
|
|
onOpenExecutionPanel,
|
|
onSelectSessionTab,
|
|
onCloseSessionTab,
|
|
onToggleMultiSessionView,
|
|
onCollapse,
|
|
onExpand,
|
|
onMaximize,
|
|
onComposerSend,
|
|
onMessageSend,
|
|
onSessionSend,
|
|
onWorkItemClick,
|
|
onWorkItemOpenSession,
|
|
onMarkRead,
|
|
onSessionMarkRead,
|
|
onLoadSessionHistory,
|
|
isSessionHistoryLoading,
|
|
}: ContextPanelProps) {
|
|
|
|
const isSecretary = activeView.kind === 'secretary'
|
|
const isActivity = activeView.kind === 'activity'
|
|
const isChildDetail = activeView.kind === 'child-detail'
|
|
const isTaskDetail = activeView.kind === 'task-detail'
|
|
|
|
const isCompanyRuntime = isCompanyRuntimeSession(activeSession, childSessions.length)
|
|
const showTabs = activeView.kind === 'session' && activeSession
|
|
const canSend = isSecretary ? true : !!activeSession
|
|
const showSessionStrip = !isChildDetail && openSessions.length > 0
|
|
const canShowMultiSessionView = openSessions.length > 1
|
|
const showMultiSessionGrid = !!(multiSessionView && activeView.kind === 'session' && activeSession && canShowMultiSessionView && panelTab === 'chat')
|
|
const childDetailScrollRef = useRef<HTMLDivElement | null>(null)
|
|
const activeConversation = useMemo(() => {
|
|
const conversationPeers = getConversationPeerSessions(activeSession, allSessions)
|
|
return projectSessionConversation(activeSession, [...conversationPeers, ...childSessions])
|
|
}, [activeSession, childSessions, allSessions])
|
|
const activeDisplaySession = activeConversation.displaySession ?? activeSession
|
|
const activeDetailMode = sessionDetailLevel(activeDisplaySession)
|
|
const activeConversationSession = useMemo(() => {
|
|
return getConversationSessionView(activeSession, activeConversation.runtimeSession, activeConversation.timelineSessions)
|
|
}, [activeSession, activeConversation.runtimeSession, activeConversation.timelineSessions])
|
|
const activeHeaderSession = useMemo(() => {
|
|
return getConversationHeaderSession(activeSession, activeConversation.runtimeSession, activeConversation.timelineSessions)
|
|
}, [activeSession, activeConversation.runtimeSession, activeConversation.timelineSessions])
|
|
const activeConversationProgress = useMemo(() => {
|
|
return mergeConversationProgressLog(activeConversation.timelineSessions)
|
|
}, [activeConversation.timelineSessions])
|
|
const activeWorkItemLog = useMemo(() => (
|
|
activeConversationSession?.workItemLog ?? activeSession?.workItemLog ?? []
|
|
), [activeConversationSession?.workItemLog, activeSession?.workItemLog])
|
|
const activeWorkItemRoleSessions = useMemo(() => (
|
|
getWorkItemRoleSessions(activeConversationSession ?? activeSession, allSessions)
|
|
), [activeConversationSession, activeSession, allSessions])
|
|
const activeRoleWorkItems = useMemo(() => (
|
|
activeConversationSession?.roleWorkItems ?? activeSession?.roleWorkItems
|
|
), [activeConversationSession?.roleWorkItems, activeSession?.roleWorkItems])
|
|
const activeExecutorRoleWorkItems = useMemo(() => (
|
|
activeConversationSession?.executorRoleWorkItems ?? activeSession?.executorRoleWorkItems
|
|
), [
|
|
activeConversationSession?.executorRoleWorkItems,
|
|
activeSession?.executorRoleWorkItems,
|
|
])
|
|
const hasRoleWorkItems = !!(
|
|
(activeRoleWorkItems && Object.keys(activeRoleWorkItems).length > 0)
|
|
|| (activeExecutorRoleWorkItems && Object.keys(activeExecutorRoleWorkItems).length > 0)
|
|
)
|
|
const visibleAgentSessions = activeWorkItemRoleSessions.length > 0
|
|
? activeWorkItemRoleSessions
|
|
: childSessions
|
|
const activeConversationMessageCount = useMemo(() => {
|
|
return getConversationMessageCount(activeConversation.timelineSessions)
|
|
}, [activeConversation.timelineSessions])
|
|
const activeConversationLoading = useMemo(() => (
|
|
activeConversation.timelineSessions.some((session) => isSessionHistoryLoading?.(session.taskId) ?? false)
|
|
), [activeConversation.timelineSessions, isSessionHistoryLoading])
|
|
const resolveConversationHistoryTarget = useCallback((oldestMessage?: ChatMessage) => {
|
|
if (oldestMessage) {
|
|
const matched = activeConversation.timelineSessions.find(
|
|
(session) => session.channelId === oldestMessage.channelId,
|
|
)
|
|
if (matched && sessionHasMoreForDetail(matched, activeDetailMode) !== false) return matched
|
|
}
|
|
const knownTarget = activeConversation.timelineSessions.find(
|
|
session => sessionHasMoreForDetail(session, activeDetailMode) === true,
|
|
)
|
|
if (knownTarget) return knownTarget
|
|
return activeDisplaySession ?? activeSession
|
|
}, [activeConversation.timelineSessions, activeDetailMode, activeDisplaySession, activeSession])
|
|
|
|
// Child detail: find the agent for this session
|
|
const childDetailAgent = useMemo(() => {
|
|
if (!childDetailSession) return undefined
|
|
const id = childDetailSession.assigneeIds[0]
|
|
return id ? agents.find(a => a.agent_id === id) : undefined
|
|
}, [childDetailSession, agents])
|
|
// Use prop-provided linked session (computed + lazy-loaded by WorkspacePage)
|
|
// with a local fallback for backwards compat
|
|
const linkedTaskSession = linkedTaskSessionProp ?? (() => {
|
|
const linkedRuntimeTaskId = getLinkedRuntimeTaskId(activeTask)
|
|
if (!linkedRuntimeTaskId) return null
|
|
return allSessions.find(session =>
|
|
session.taskId === linkedRuntimeTaskId
|
|
|| session.runtimeTaskId === linkedRuntimeTaskId
|
|
|| session.executionTurnId === linkedRuntimeTaskId
|
|
) ?? null
|
|
})()
|
|
|
|
useEffect(() => {
|
|
if (!isChildDetail || !childDetailSession) return
|
|
childDetailScrollRef.current?.scrollTo({ top: 0, behavior: 'auto' })
|
|
}, [isChildDetail, childDetailSession?.taskId])
|
|
|
|
// Task for Info tab
|
|
const taskForInfo = activeSession
|
|
|
|
// Collapsed state: render a thin strip
|
|
if (panelState === 'collapsed') {
|
|
return (
|
|
<div
|
|
className="ctx-collapse-strip"
|
|
onClick={() => onExpand()}
|
|
title="Open panel"
|
|
>
|
|
◀
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div
|
|
className={`ctx-resize-handle${isResizing ? ' active' : ''}`}
|
|
onMouseDown={onResizeMouseDown}
|
|
/>
|
|
<div
|
|
className={`ctx-panel${panelState === 'maximized' ? ' maximized' : ''}`}
|
|
style={panelState !== 'maximized' ? { width } : undefined}
|
|
>
|
|
{/* Task detail view */}
|
|
{isTaskDetail && activeTask ? (
|
|
<TaskDetailView
|
|
task={activeTask}
|
|
linkedSession={linkedTaskSession}
|
|
linkedSessionMessages={linkedTaskSessionMessages}
|
|
agents={agents}
|
|
onBack={onCloseTaskDetail ?? onCollapse}
|
|
onOpenLinkedSession={onSelectSessionTab}
|
|
onOpenExecutionPanel={onOpenExecutionPanel}
|
|
/>
|
|
) : isChildDetail && childDetailSession ? (
|
|
/* Child detail view */
|
|
<div className="ctx-child-detail" ref={childDetailScrollRef}>
|
|
<div className="ctx-child-topbar">
|
|
<button className="ctx-back-btn" onClick={onBackToParent}>
|
|
← Back to parent
|
|
</button>
|
|
{(childDetailSession.canStop ?? childDetailSession.status === 'running') && childDetailSession.runtimeControlState !== 'suspending' && onStopTask && (
|
|
<button
|
|
className="ctx-child-stop-btn"
|
|
onClick={() => onStopTask(childDetailSession.taskId)}
|
|
title="Stop this agent"
|
|
>
|
|
Stop
|
|
</button>
|
|
)}
|
|
{childDetailSession.runtimeControlState === 'suspending' && (
|
|
<button className="ctx-child-stop-btn" disabled title="Stopping this agent">
|
|
Stopping...
|
|
</button>
|
|
)}
|
|
{canShowContinue(childDetailSession) && onResumeTask && (
|
|
<button
|
|
className="ctx-child-resume-btn"
|
|
onClick={() => onResumeTask(childDetailSession.taskId)}
|
|
title="Continue this agent's runtime"
|
|
>
|
|
Continue
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div className="ctx-child-header">
|
|
{childDetailAgent && (
|
|
<div className="ctx-child-avatar">
|
|
{childDetailAgent.name.charAt(0).toUpperCase()}
|
|
</div>
|
|
)}
|
|
<div className="ctx-child-meta">
|
|
<span className="ctx-child-name">
|
|
{childDetailAgent?.name ?? childDetailSession.title}
|
|
</span>
|
|
{childDetailSession.workItemRoleName && (
|
|
<span className="ctx-child-work-item">
|
|
{childDetailSession.workItemRoleName}
|
|
</span>
|
|
)}
|
|
{!childDetailSession.workItemRoleName && childDetailSession.workItemProjectionId && (
|
|
<span className="ctx-child-work-item">
|
|
{childDetailSession.workItemProjectionId.replace(/_/g, ' ')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{childDetailSession.handoffContext && (
|
|
<div className="exec-section ctx-child-markdown-section">
|
|
<div className="exec-section-header">
|
|
<IconHandoff />
|
|
<span>Received From</span>
|
|
</div>
|
|
<div className="msg-content-agent-card ctx-child-markdown-card">
|
|
<MarkdownBody content={childDetailSession.handoffContext} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="ctx-child-transcript">
|
|
<div className="ctx-child-transcript-header">
|
|
<IconTimeline />
|
|
<span>Transcript</span>
|
|
</div>
|
|
<MessageList
|
|
key={childDetailSession.channelId}
|
|
messages={childDetailMessages}
|
|
channelName={childDetailSession.title}
|
|
viewKind="session"
|
|
detailMode="full"
|
|
agentStatus={childDetailSession.agentStatus}
|
|
currentTool={childDetailSession.currentTool}
|
|
toolElapsedMs={childDetailSession.toolElapsedMs}
|
|
lastToolSummary={childDetailSession.lastToolSummary}
|
|
progressLog={childDetailSession.progressLog}
|
|
draftAssistantText={childDetailSession.draftAssistantText}
|
|
draftUpdatedAt={childDetailSession.draftUpdatedAt}
|
|
draftIteration={childDetailSession.draftIteration}
|
|
draftTurnId={childDetailSession.draftTurnId}
|
|
onMarkRead={onMarkRead}
|
|
hasOlderHistory={
|
|
// A scoped backend cursor remains actionable during live
|
|
// work. Only the racy messageCount fallback is suppressed.
|
|
conversationHasOlderHistory(
|
|
[childDetailSession],
|
|
childDetailMessages.length,
|
|
'full',
|
|
!isSessionWorking(childDetailSession),
|
|
)
|
|
}
|
|
totalMessageCount={childDetailSession.messageCount}
|
|
onLoadOlderHistory={(oldestMessage) => onLoadSessionHistory?.(childDetailSession.taskId, oldestMessage, 'full')}
|
|
loadingOlderHistory={isSessionHistoryLoading?.(childDetailSession.taskId) ?? false}
|
|
scrollPolicy="initial-bottom"
|
|
scrollScope={childDetailSession.channelId}
|
|
showRuntimeProgress
|
|
renderUserMarkdown
|
|
/>
|
|
</div>
|
|
|
|
{childDetailSession.artifacts && childDetailSession.artifacts.length > 0 && (
|
|
<div className="ctx-child-artifacts">
|
|
<div className="ctx-child-section-header">
|
|
<IconTool />
|
|
<span>Artifacts</span>
|
|
</div>
|
|
<ul className="ctx-child-artifact-list">
|
|
{childDetailSession.artifacts.map((a, i) => (
|
|
<li key={i}>{a}</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
|
|
{childDetailSession.handoffTo && (
|
|
<div className="ctx-child-markdown-section ctx-child-passed-to">
|
|
<div className="ctx-child-section-header">
|
|
<IconHandoff />
|
|
<span>Passed To</span>
|
|
</div>
|
|
<div className="msg-content-agent-card ctx-child-markdown-card">
|
|
<MarkdownBody content={childDetailSession.handoffTo} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<>
|
|
{showSessionStrip && (
|
|
<div className="ctx-session-strip">
|
|
<div className="ctx-session-strip-scroll">
|
|
{openSessions.map((session) => {
|
|
const unreadCount = unreadCounts?.[session.channelId] ?? 0
|
|
const isActiveTab = activeSession?.taskId === session.taskId
|
|
return (
|
|
<div
|
|
key={session.taskId}
|
|
className={`ctx-session-chip${isActiveTab ? ' active' : ''}`}
|
|
>
|
|
<button
|
|
className="ctx-session-chip-main"
|
|
onClick={() => onSelectSessionTab?.(session.taskId)}
|
|
title={session.title}
|
|
>
|
|
<span className={`ctx-session-chip-status status-${session.status}`} />
|
|
<span className="ctx-session-chip-title">{session.title}</span>
|
|
{unreadCount > 0 && (
|
|
<span className="ctx-session-chip-unread">
|
|
{unreadCount > 99 ? '99+' : unreadCount}
|
|
</span>
|
|
)}
|
|
</button>
|
|
<button
|
|
className="ctx-session-chip-close"
|
|
onClick={() => onCloseSessionTab?.(session.taskId)}
|
|
title="Close tab"
|
|
>
|
|
×
|
|
</button>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
{canShowMultiSessionView && (
|
|
<button
|
|
className={`ctx-session-view-btn${multiSessionView ? ' active' : ''}`}
|
|
onClick={onToggleMultiSessionView}
|
|
>
|
|
{multiSessionView ? 'Single View' : 'Multi View'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Header with tabs or title */}
|
|
<div className="ctx-header">
|
|
{showMultiSessionGrid ? (
|
|
<span className="ctx-header-title">{isCompanyRuntime ? 'Open Runtime Sessions' : 'Open Sessions'}</span>
|
|
) : showTabs ? (
|
|
<div className="ctx-header-tabs" role="tablist" aria-label="Session view">
|
|
<button
|
|
role="tab"
|
|
aria-selected={panelTab === 'chat'}
|
|
className={`ctx-tab${panelTab === 'chat' ? ' active' : ''}`}
|
|
onClick={() => onPanelTabChange('chat')}
|
|
>
|
|
Chat
|
|
</button>
|
|
{canShowAgentsTab && (
|
|
<button
|
|
role="tab"
|
|
aria-selected={panelTab === 'agents'}
|
|
className={`ctx-tab${panelTab === 'agents' ? ' active' : ''}`}
|
|
onClick={() => onPanelTabChange('agents')}
|
|
>
|
|
Agents
|
|
</button>
|
|
)}
|
|
<button
|
|
role="tab"
|
|
aria-selected={panelTab === 'info'}
|
|
className={`ctx-tab${panelTab === 'info' ? ' active' : ''}`}
|
|
onClick={() => onPanelTabChange('info')}
|
|
>
|
|
Info
|
|
</button>
|
|
{onCommsRefresh && (
|
|
<button
|
|
role="tab"
|
|
aria-selected={panelTab === 'comms'}
|
|
className={`ctx-tab${panelTab === 'comms' ? ' active' : ''}`}
|
|
onClick={() => onPanelTabChange('comms')}
|
|
>
|
|
Comms
|
|
</button>
|
|
)}
|
|
{canShowTeamTab && (
|
|
<button
|
|
role="tab"
|
|
aria-selected={panelTab === 'team'}
|
|
className={`ctx-tab${panelTab === 'team' ? ' active' : ''}`}
|
|
onClick={() => onPanelTabChange('team')}
|
|
>
|
|
Team
|
|
</button>
|
|
)}
|
|
</div>
|
|
) : isSecretary ? (
|
|
<span style={{ fontSize: 13, fontWeight: 600, padding: '0 4px' }}>Secretary</span>
|
|
) : (
|
|
<span style={{ fontSize: 13, fontWeight: 600, padding: '0 4px' }}>Activity</span>
|
|
)}
|
|
|
|
<div className="ctx-header-actions">
|
|
{panelState === 'open' && (
|
|
<button onClick={onMaximize} title="Maximize">
|
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="2" y="2" width="10" height="10" rx="1.5" stroke="currentColor" strokeWidth="1.3" /></svg>
|
|
</button>
|
|
)}
|
|
{panelState === 'maximized' && (
|
|
<button onClick={onExpand} title="Restore">
|
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="1" y="3" width="8" height="8" rx="1.5" stroke="currentColor" strokeWidth="1.3" /><path d="M5 3V1.5A.5.5 0 015.5 1H12.5a.5.5 0 01.5.5V8.5a.5.5 0 01-.5.5H11" stroke="currentColor" strokeWidth="1.3" /></svg>
|
|
</button>
|
|
)}
|
|
<button onClick={onCollapse} title="Close panel">
|
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><path d="M3 3l8 8M11 3l-8 8" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" /></svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Body */}
|
|
<div className="ctx-body">
|
|
{/* No session selected */}
|
|
{!activeSession && !isSecretary && !isActivity && (
|
|
<div className="ctx-empty">
|
|
<span className="ctx-empty-icon">📋</span>
|
|
<span>{isCompanyRuntime ? 'Select a Work Item to see details' : 'Select a task to see details'}</span>
|
|
</div>
|
|
)}
|
|
|
|
{showMultiSessionGrid && (
|
|
<div className="ctx-multi-grid">
|
|
{openSessions.map((session) => {
|
|
const sessionMessages = openSessionMessages[session.taskId] ?? []
|
|
const sessionChildren = openSessionChildren[session.taskId] ?? []
|
|
const sessionPeers = getConversationPeerSessions(session, allSessions)
|
|
const sessionConversation = projectSessionConversation(session, [...sessionPeers, ...sessionChildren])
|
|
const sessionConversationSession = getConversationSessionView(
|
|
session,
|
|
sessionConversation.runtimeSession,
|
|
sessionConversation.timelineSessions,
|
|
)
|
|
const sessionWorkItemRoleSessions = getWorkItemRoleSessions(
|
|
sessionConversationSession ?? session,
|
|
allSessions,
|
|
)
|
|
const sessionRoleWorkItems = sessionConversationSession?.roleWorkItems ?? session.roleWorkItems
|
|
const activeChildCount = activeAgentCountFor(sessionRoleWorkItems, sessionChildren) ?? 0
|
|
const assigneeNames = session.assigneeIds
|
|
.map(id => agents.find(agent => agent.agent_id === id)?.name ?? id)
|
|
.filter(Boolean)
|
|
const runtimeLabel = sessionRuntimeLabel(sessionConversationSession ?? session, activeChildCount)
|
|
const sessionIsCompanyRuntime = isCompanyRuntimeSession(session, sessionChildren.length)
|
|
const sessionDisplaySession = sessionConversation.displaySession ?? session
|
|
const sessionProgressLog = mergeConversationProgressLog(sessionConversation.timelineSessions)
|
|
const sessionMessageCount = getConversationMessageCount(sessionConversation.timelineSessions)
|
|
const sessionLockedMode = isSessionConfigLocked(
|
|
sessionConversationSession ?? sessionDisplaySession,
|
|
Math.max(sessionMessageCount, sessionMessages.length),
|
|
)
|
|
const sessionHistoryLoading = sessionConversation.timelineSessions.some(
|
|
(timelineSession) => isSessionHistoryLoading?.(timelineSession.taskId) ?? false,
|
|
)
|
|
|
|
return (
|
|
<section
|
|
key={session.taskId}
|
|
className={`ctx-multi-card${activeSession?.taskId === session.taskId ? ' focused' : ''}`}
|
|
>
|
|
<div className="ctx-multi-card-header">
|
|
<button
|
|
className="ctx-multi-card-main"
|
|
onClick={() => onSelectSessionTab?.(session.taskId)}
|
|
title="Focus session"
|
|
>
|
|
<span className={`ctx-multi-card-status status-${session.status}`} />
|
|
<span className="ctx-multi-card-title">{session.title}</span>
|
|
</button>
|
|
<div className="ctx-multi-card-actions">
|
|
{((sessionConversationSession ?? session).canStop ?? (sessionConversationSession ?? session).status === 'running') && (sessionConversationSession ?? session).runtimeControlState !== 'suspending' && onStopTask && (
|
|
<button onClick={() => onStopTask(sessionConversation.runtimeSession?.taskId ?? session.taskId)}>Stop</button>
|
|
)}
|
|
{(sessionConversationSession ?? session).runtimeControlState === 'suspending' && (
|
|
<button disabled>Stopping...</button>
|
|
)}
|
|
{canShowContinue(sessionConversationSession ?? session) && onResumeTask && (
|
|
<button onClick={() => onResumeTask(session.taskId)}>Continue</button>
|
|
)}
|
|
{(sessionConversationSession ?? session).status !== 'done' && (sessionConversationSession ?? session).status !== 'cancelled' && onCompleteTask && (
|
|
<button onClick={() => onCompleteTask(session.taskId)}>Done</button>
|
|
)}
|
|
<button onClick={() => onSelectSessionTab?.(session.taskId)}>Focus</button>
|
|
<button onClick={() => onCloseSessionTab?.(session.taskId)} title="Close tab">
|
|
×
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="ctx-multi-card-meta">
|
|
<span>{sessionModeLabel(session)}</span>
|
|
<span>{session.status}</span>
|
|
{runtimeLabel && <span>{runtimeLabel}</span>}
|
|
{assigneeNames.length > 0 && <span>{assigneeNames.join(', ')}</span>}
|
|
<span>{relativeTime(session.updatedAt)}</span>
|
|
</div>
|
|
<div className="ctx-multi-card-body">
|
|
<MessageList
|
|
key={sessionDisplaySession?.channelId ?? session.channelId}
|
|
messages={sessionMessages}
|
|
channelName={sessionDisplaySession?.title ?? session.title}
|
|
viewKind="session"
|
|
detailMode={sessionDetailLevel(sessionDisplaySession)}
|
|
agentStatus={sessionIsCompanyRuntime ? undefined : (sessionConversationSession?.agentStatus ?? sessionDisplaySession?.agentStatus)}
|
|
currentTool={sessionIsCompanyRuntime ? undefined : (sessionConversationSession?.currentTool ?? sessionDisplaySession?.currentTool)}
|
|
toolElapsedMs={sessionIsCompanyRuntime ? undefined : (sessionConversationSession?.toolElapsedMs ?? sessionDisplaySession?.toolElapsedMs)}
|
|
lastToolSummary={sessionIsCompanyRuntime ? undefined : (sessionConversationSession?.lastToolSummary ?? sessionDisplaySession?.lastToolSummary)}
|
|
progressLog={sessionIsCompanyRuntime ? undefined : sessionProgressLog}
|
|
draftAssistantText={sessionIsCompanyRuntime ? undefined : (sessionConversationSession?.draftAssistantText ?? sessionDisplaySession?.draftAssistantText)}
|
|
draftUpdatedAt={sessionIsCompanyRuntime ? undefined : (sessionConversationSession?.draftUpdatedAt ?? sessionDisplaySession?.draftUpdatedAt)}
|
|
draftIteration={sessionIsCompanyRuntime ? undefined : (sessionConversationSession?.draftIteration ?? sessionDisplaySession?.draftIteration)}
|
|
draftTurnId={sessionIsCompanyRuntime ? undefined : (sessionConversationSession?.draftTurnId ?? sessionDisplaySession?.draftTurnId)}
|
|
isCompanyRuntime={sessionIsCompanyRuntime}
|
|
workItemLog={sessionConversationSession?.workItemLog ?? session.workItemLog}
|
|
childSessions={sessionWorkItemRoleSessions}
|
|
showWorkItemRuntimeCard={!sessionIsCompanyRuntime}
|
|
onSend={(content, _taskId, metadata) => onSessionSend?.(session.taskId, content, undefined, metadata)}
|
|
onWorkItemClick={onWorkItemClick}
|
|
onWorkItemOpenSession={onWorkItemOpenSession}
|
|
onMarkRead={() => onSessionMarkRead?.(session.taskId)}
|
|
scrollScope={session.channelId}
|
|
hasOlderHistory={
|
|
// Keep known cursors available during live work;
|
|
// suppress only the count-based fallback.
|
|
conversationHasOlderHistory(
|
|
sessionConversation.timelineSessions,
|
|
sessionMessages.length,
|
|
sessionDetailLevel(sessionDisplaySession ?? session),
|
|
!sessionConversation.timelineSessions.some(isSessionWorking),
|
|
)
|
|
}
|
|
totalMessageCount={sessionMessageCount}
|
|
onLoadOlderHistory={(oldestMessage) => {
|
|
const detailLevel = sessionDetailLevel(sessionDisplaySession ?? session)
|
|
const matchedSession = sessionConversation.timelineSessions.find(
|
|
(timelineSession) => timelineSession.channelId === oldestMessage?.channelId,
|
|
)
|
|
const targetSession = (
|
|
matchedSession
|
|
&& sessionHasMoreForDetail(matchedSession, detailLevel) !== false
|
|
? matchedSession
|
|
: undefined
|
|
) ?? sessionConversation.timelineSessions.find(
|
|
timelineSession => sessionHasMoreForDetail(timelineSession, detailLevel) === true,
|
|
) ?? sessionDisplaySession ?? session
|
|
return onLoadSessionHistory?.(
|
|
targetSession.taskId,
|
|
oldestMessage,
|
|
detailLevel,
|
|
)
|
|
}}
|
|
loadingOlderHistory={sessionHistoryLoading}
|
|
showRuntimeProgress={sessionDetailLevel(sessionDisplaySession) === 'full'}
|
|
/>
|
|
</div>
|
|
<MessageComposer
|
|
disabled={false}
|
|
channelId={sessionConversationSession?.channelId ?? session.channelId}
|
|
execMode={composerExecModeForSession(session, execMode)}
|
|
companyProfile={session.companyProfile}
|
|
taskPreferredAgent={composerTaskAgentForSession(session, sessionLockedMode, taskPreferredAgent)}
|
|
agentStatus={sessionConversationSession?.agentStatus ?? sessionDisplaySession?.agentStatus}
|
|
currentTool={sessionConversationSession?.currentTool ?? sessionDisplaySession?.currentTool}
|
|
displayTool={sessionConversationSession?.displayTool ?? sessionDisplaySession?.displayTool}
|
|
activeAgentCount={activeChildCount || undefined}
|
|
runtimeControlState={(sessionConversationSession ?? sessionDisplaySession)?.runtimeControlState}
|
|
canStop={(sessionConversationSession ?? sessionDisplaySession)?.canStop}
|
|
savedOrgs={savedOrgsList ?? null}
|
|
activeSavedOrg={activeSavedOrg ?? null}
|
|
selectedOrgId={session.orgId ?? activeSavedOrg ?? null}
|
|
lockedMode={sessionLockedMode}
|
|
autoFocus={false}
|
|
contextTokens={sessionConversationSession?.contextTokens ?? sessionDisplaySession?.contextTokens}
|
|
contextWindow={sessionConversationSession?.contextWindow ?? sessionDisplaySession?.contextWindow}
|
|
contextRemainingPct={sessionConversationSession?.contextRemainingPct ?? sessionDisplaySession?.contextRemainingPct ?? 100}
|
|
onSend={(content, attachments) => onSessionSend?.(
|
|
session.taskId,
|
|
content,
|
|
attachments,
|
|
checkpointReplyMetadataForComposer(
|
|
analyzeCheckpointMessages(sessionMessages).latestPendingReplyMetadata,
|
|
),
|
|
)}
|
|
onModeChange={(mode, profile, orgId) => onSessionConfigChange?.(session.taskId, mode, profile, orgId)}
|
|
onTaskAgentChange={(preferredAgent) => onSessionTaskAgentChange?.(session.taskId, preferredAgent)}
|
|
onContinueInNewChat={onContinueInNewChat}
|
|
onSavedOrgsRefresh={onSavedOrgsList}
|
|
onSavedOrgLoad={onSavedOrgLoad}
|
|
onStop={() => onStopTask?.(sessionConversation.runtimeSession?.taskId ?? session.taskId)}
|
|
/>
|
|
</section>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* Activity view */}
|
|
{isActivity && (
|
|
<>
|
|
<div style={{ padding: '8px 12px', fontSize: 12, color: 'var(--text-secondary)', borderBottom: '1px solid var(--border)' }}>
|
|
Recent activity across all sessions
|
|
</div>
|
|
<MessageList
|
|
key={channelId}
|
|
messages={messages}
|
|
channelName="Activity"
|
|
viewKind="activity"
|
|
detailMode="summary"
|
|
onSend={onMessageSend}
|
|
onMarkRead={onMarkRead}
|
|
scrollScope={channelId}
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
{/* Secretary view */}
|
|
{isSecretary && (
|
|
<>
|
|
<MessageList
|
|
key={secretaryChannelId}
|
|
messages={messages}
|
|
channelName="Secretary"
|
|
viewKind="secretary"
|
|
detailMode="summary"
|
|
onSend={onMessageSend}
|
|
onMarkRead={onMarkRead}
|
|
scrollScope={secretaryChannelId}
|
|
/>
|
|
<MessageComposer
|
|
disabled={false}
|
|
channelId={secretaryChannelId}
|
|
placeholder="Talk to Secretary about policies, rules, preferences..."
|
|
onSend={onComposerSend}
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
{/* Session view — Chat tab */}
|
|
{activeSession && !isSecretary && !isActivity && !showMultiSessionGrid && panelTab === 'chat' && (
|
|
<>
|
|
<TaskHeaderBar
|
|
session={activeHeaderSession ?? activeSession}
|
|
agents={agents}
|
|
onTitleChange={onTitleChange}
|
|
onViewOnBoard={onLocateOnBoard ? () => onLocateOnBoard(activeSession.taskId) : undefined}
|
|
onStop={onStop}
|
|
onComplete={(activeHeaderSession ?? activeSession).status !== 'done' && (activeHeaderSession ?? activeSession).status !== 'cancelled' ? onComplete : undefined}
|
|
onResume={onResume}
|
|
/>
|
|
{isCompanyRuntime && (
|
|
<div className="ctx-work-item-progress">
|
|
<WorkItemProgressCard
|
|
workItemLog={activeWorkItemLog}
|
|
roleWorkItems={activeRoleWorkItems}
|
|
executorRoleWorkItems={activeExecutorRoleWorkItems}
|
|
childSessions={activeWorkItemRoleSessions}
|
|
isCompanyRuntime={isCompanyRuntime}
|
|
onWorkItemClick={onWorkItemClick}
|
|
/>
|
|
</div>
|
|
)}
|
|
<MessageList
|
|
key={channelId}
|
|
messages={messages}
|
|
channelName={channelName}
|
|
viewKind="session"
|
|
detailMode={activeDetailMode}
|
|
agentStatus={isCompanyRuntime ? undefined : (activeConversationSession?.agentStatus ?? activeDisplaySession?.agentStatus)}
|
|
currentTool={isCompanyRuntime ? undefined : (activeConversationSession?.currentTool ?? activeDisplaySession?.currentTool)}
|
|
toolElapsedMs={isCompanyRuntime ? undefined : (activeConversationSession?.toolElapsedMs ?? activeDisplaySession?.toolElapsedMs)}
|
|
lastToolSummary={isCompanyRuntime ? undefined : (activeConversationSession?.lastToolSummary ?? activeDisplaySession?.lastToolSummary)}
|
|
progressLog={isCompanyRuntime ? undefined : activeConversationProgress}
|
|
draftAssistantText={isCompanyRuntime ? undefined : (activeConversationSession?.draftAssistantText ?? activeDisplaySession?.draftAssistantText)}
|
|
draftUpdatedAt={isCompanyRuntime ? undefined : (activeConversationSession?.draftUpdatedAt ?? activeDisplaySession?.draftUpdatedAt)}
|
|
draftIteration={isCompanyRuntime ? undefined : (activeConversationSession?.draftIteration ?? activeDisplaySession?.draftIteration)}
|
|
draftTurnId={isCompanyRuntime ? undefined : (activeConversationSession?.draftTurnId ?? activeDisplaySession?.draftTurnId)}
|
|
isCompanyRuntime={isCompanyRuntime}
|
|
workItemLog={activeWorkItemLog}
|
|
roleWorkItems={activeRoleWorkItems}
|
|
executorRoleWorkItems={activeExecutorRoleWorkItems}
|
|
childSessions={activeWorkItemRoleSessions}
|
|
onSend={onMessageSend}
|
|
onWorkItemClick={onWorkItemClick}
|
|
onWorkItemOpenSession={onWorkItemOpenSession}
|
|
onMarkRead={onMarkRead}
|
|
scrollScope={channelId}
|
|
hasOlderHistory={
|
|
// Keep known cursors available during live work;
|
|
// suppress only the count-based fallback.
|
|
conversationHasOlderHistory(
|
|
activeConversation.timelineSessions,
|
|
messages.length,
|
|
activeDetailMode,
|
|
!activeConversation.timelineSessions.some(isSessionWorking),
|
|
)
|
|
}
|
|
totalMessageCount={activeConversationMessageCount}
|
|
onLoadOlderHistory={(oldestMessage) => {
|
|
const targetSession = resolveConversationHistoryTarget(oldestMessage)
|
|
if (!targetSession) return
|
|
return onLoadSessionHistory?.(
|
|
targetSession.taskId,
|
|
oldestMessage,
|
|
activeDetailMode,
|
|
)
|
|
}}
|
|
loadingOlderHistory={activeConversationLoading}
|
|
showWorkItemRuntimeCard={false}
|
|
showRuntimeProgress={activeDetailMode === 'full'}
|
|
/>
|
|
<MessageComposer
|
|
disabled={!canSend}
|
|
channelId={activeConversationSession?.channelId ?? channelId}
|
|
execMode={composerExecModeForSession(activeSession, execMode)}
|
|
companyProfile={activeSession.companyProfile}
|
|
taskPreferredAgent={composerTaskAgentForSession(
|
|
activeSession,
|
|
isSessionConfigLocked(
|
|
activeConversationSession ?? activeDisplaySession ?? activeSession,
|
|
Math.max(activeConversationMessageCount, messages.length),
|
|
),
|
|
taskPreferredAgent,
|
|
)}
|
|
agentStatus={activeConversationSession?.agentStatus ?? activeDisplaySession?.agentStatus}
|
|
currentTool={activeConversationSession?.currentTool ?? activeDisplaySession?.currentTool}
|
|
displayTool={activeConversationSession?.displayTool ?? activeDisplaySession?.displayTool}
|
|
runtimeControlState={(activeConversationSession ?? activeDisplaySession)?.runtimeControlState}
|
|
canStop={(activeConversationSession ?? activeDisplaySession)?.canStop}
|
|
savedOrgs={savedOrgsList ?? null}
|
|
activeSavedOrg={activeSavedOrg ?? null}
|
|
selectedOrgId={activeSession.orgId ?? activeSavedOrg ?? null}
|
|
lockedMode={isSessionConfigLocked(
|
|
activeConversationSession ?? activeDisplaySession ?? activeSession,
|
|
Math.max(activeConversationMessageCount, messages.length),
|
|
)}
|
|
activeAgentCount={activeAgentCountFor(activeRoleWorkItems, visibleAgentSessions)}
|
|
contextTokens={activeConversationSession?.contextTokens ?? activeDisplaySession?.contextTokens}
|
|
contextWindow={activeConversationSession?.contextWindow ?? activeDisplaySession?.contextWindow}
|
|
contextRemainingPct={activeConversationSession?.contextRemainingPct ?? activeDisplaySession?.contextRemainingPct ?? 100}
|
|
onSend={onComposerSend}
|
|
onModeChange={(mode, profile, orgId) => onSessionConfigChange?.(activeSession.taskId, mode, profile, orgId)}
|
|
onTaskAgentChange={(preferredAgent) => onSessionTaskAgentChange?.(activeSession.taskId, preferredAgent)}
|
|
onContinueInNewChat={onContinueInNewChat}
|
|
onSavedOrgsRefresh={onSavedOrgsList}
|
|
onSavedOrgLoad={onSavedOrgLoad}
|
|
onStop={onStop}
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
{/* Session view — Agents tab */}
|
|
{activeSession && !isSecretary && !showMultiSessionGrid && panelTab === 'agents' && canShowAgentsTab && (
|
|
(visibleAgentSessions.length > 0 || hasRoleWorkItems) ? (
|
|
<AgentWorkPanel
|
|
sessions={visibleAgentSessions}
|
|
roleWorkItems={activeRoleWorkItems}
|
|
isCompanyRuntime={isCompanyRuntime}
|
|
agents={agents}
|
|
onOpenChildDetail={onOpenChildDetail}
|
|
onOpenExecutionPanel={onOpenExecutionPanel}
|
|
/>
|
|
) : (
|
|
<div className="ctx-empty">
|
|
<span className="ctx-empty-icon">🧠</span>
|
|
<span>No child agents have started for this session yet</span>
|
|
</div>
|
|
)
|
|
)}
|
|
|
|
{/* Session view — Info tab.
|
|
Reorganised into semantic cards (Overview / Identity /
|
|
Timing) instead of a flat key-value list. Internal-only
|
|
debug fields (projection id, channel id) are tucked into
|
|
a collapsed "Developer details" section so they don't
|
|
dominate the user-facing summary. */}
|
|
{activeSession && !isSecretary && !showMultiSessionGrid && panelTab === 'info' && taskForInfo && (
|
|
<InfoTabView
|
|
task={taskForInfo}
|
|
agents={agents}
|
|
roleLabel={getWorkItemRoleLabel(taskForInfo)}
|
|
/>
|
|
)}
|
|
|
|
{/* Comms tab */}
|
|
{panelTab === 'comms' && onCommsRefresh && onCommsReadMessage && (
|
|
<div style={{ flex: 1, overflow: 'auto', minHeight: 0 }}>
|
|
<CommsPanel
|
|
state={commsState ?? null}
|
|
message={commsMessage ?? null}
|
|
onRefresh={onCommsRefresh}
|
|
onReadMessage={onCommsReadMessage}
|
|
embedded
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{panelTab === 'team' && canShowTeamTab && (
|
|
<div style={{ flex: 1, overflow: 'auto', minHeight: 0 }}>
|
|
<ProjectCockpit
|
|
orgInfoData={orgInfoData ?? null}
|
|
commsState={commsState ?? null}
|
|
onStopRun={onTeamStopRun}
|
|
embedded
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</>
|
|
)
|
|
}
|