fix: unify company runtime recovery lifecycle
This commit is contained in:
@@ -77,7 +77,6 @@ interface ContextPanelProps {
|
||||
onCommsRefresh?: () => void
|
||||
onCommsReadMessage?: (path: string) => void
|
||||
orgInfoData?: OrgInfoPayload | null
|
||||
recoveryStatus?: Record<string, unknown> | null
|
||||
canShowTeamTab?: boolean
|
||||
onTeamStopRun?: () => void
|
||||
|
||||
@@ -497,7 +496,6 @@ export function ContextPanel({
|
||||
onCommsRefresh,
|
||||
onCommsReadMessage,
|
||||
orgInfoData,
|
||||
recoveryStatus,
|
||||
canShowTeamTab = false,
|
||||
onTeamStopRun,
|
||||
onTitleChange,
|
||||
@@ -1306,7 +1304,6 @@ export function ContextPanel({
|
||||
<div style={{ flex: 1, overflow: 'auto', minHeight: 0 }}>
|
||||
<ProjectCockpit
|
||||
orgInfoData={orgInfoData ?? null}
|
||||
recoveryStatus={recoveryStatus ?? null}
|
||||
commsState={commsState ?? null}
|
||||
onStopRun={onTeamStopRun}
|
||||
embedded
|
||||
|
||||
@@ -48,7 +48,6 @@ interface TeamCardInfo {
|
||||
|
||||
interface ProjectCockpitProps {
|
||||
orgInfoData?: OrgInfoPayload | null
|
||||
recoveryStatus?: Record<string, unknown> | null
|
||||
commsState?: CommsStatePayload | null
|
||||
onStopRun?: () => void
|
||||
embedded?: boolean
|
||||
@@ -56,7 +55,6 @@ interface ProjectCockpitProps {
|
||||
|
||||
export function ProjectCockpit({
|
||||
orgInfoData,
|
||||
recoveryStatus,
|
||||
commsState,
|
||||
onStopRun,
|
||||
embedded = false,
|
||||
@@ -78,7 +76,6 @@ export function ProjectCockpit({
|
||||
count + asRecordList(asRecord(digest.manager_digest).notification_backlog).length
|
||||
), 0)
|
||||
const unreadCount = actionableCount + protocolCount + notificationCount
|
||||
const interrupted = Array.isArray(recoveryStatus?.interrupted) ? recoveryStatus.interrupted.length : 0
|
||||
|
||||
const communicationItems = [
|
||||
{ label: 'Actionable', value: actionableCount },
|
||||
@@ -198,7 +195,7 @@ export function ProjectCockpit({
|
||||
<span>Seats {runtimeView.runtimeSeats.length}</span>
|
||||
<span>Approvals {pendingDecisionCount}</span>
|
||||
<span>Unread {unreadCount}</span>
|
||||
<span>Recovery {interrupted > 0 ? `${interrupted} interrupted` : summarizeText(asRecord(projectRun?.recovery_pointer).status, 'clean')}</span>
|
||||
<span>Run state {summarizeText(asRecord(projectRun?.recovery_pointer).status, 'clean')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
export interface RecoverableWorkItem {
|
||||
work_item_projection_id: string
|
||||
title: string
|
||||
task_id: string
|
||||
status: string
|
||||
interrupted: boolean
|
||||
previous_status: string
|
||||
}
|
||||
|
||||
export interface InterruptedWorkItemRuntime {
|
||||
parent_session_id: string
|
||||
parent_task_id: string
|
||||
project_id: string
|
||||
title: string
|
||||
profile: string
|
||||
interrupted_at: string
|
||||
work_items: RecoverableWorkItem[]
|
||||
}
|
||||
|
||||
export interface RecoveryStatusPayload {
|
||||
interrupted: InterruptedWorkItemRuntime[]
|
||||
active_recoveries: string[]
|
||||
scanned_at: number
|
||||
}
|
||||
|
||||
interface WorkItemRecoveryPanelProps {
|
||||
data: RecoveryStatusPayload
|
||||
onResume: (parentTaskId: string) => void
|
||||
onCancel: (parentTaskId: string) => void
|
||||
}
|
||||
|
||||
const STATUS_ICON: Record<string, string> = {
|
||||
done: '\u2713',
|
||||
failed: '\u2717',
|
||||
pending: '\u25CB',
|
||||
blocked: '\u25A0',
|
||||
cancelled: '\u2014',
|
||||
running: '\u25B6',
|
||||
}
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
done: 'var(--green, #27ae60)',
|
||||
failed: 'var(--red, #e74c3c)',
|
||||
pending: 'var(--text-secondary, #888)',
|
||||
blocked: 'var(--yellow, #f39c12)',
|
||||
cancelled: 'var(--text-dim, #555)',
|
||||
running: 'var(--accent, #3498db)',
|
||||
}
|
||||
|
||||
export function WorkItemRecoveryPanel({ data, onResume, onCancel }: WorkItemRecoveryPanelProps) {
|
||||
const [dismissed, setDismissed] = useState<Set<string>>(new Set())
|
||||
|
||||
if (!data.interrupted.length && !data.active_recoveries.length) return null
|
||||
|
||||
const visible = data.interrupted.filter(w => !dismissed.has(w.parent_task_id))
|
||||
if (!visible.length && !data.active_recoveries.length) return null
|
||||
|
||||
return (
|
||||
<div className="wfr-panel">
|
||||
{visible.map(wf => {
|
||||
const isRecovering = data.active_recoveries.includes(wf.parent_task_id)
|
||||
const doneCount = wf.work_items.filter(item => item.status === 'done').length
|
||||
const failedCount = wf.work_items.filter(item => item.interrupted || item.status === 'failed').length
|
||||
|
||||
return (
|
||||
<div key={wf.parent_task_id} className="wfr-card">
|
||||
<div className="wfr-header">
|
||||
<span className="wfr-icon">⚠</span>
|
||||
<div className="wfr-header-text">
|
||||
<span className="wfr-title">Interrupted: {wf.title}</span>
|
||||
<span className="wfr-subtitle">
|
||||
{doneCount}/{wf.work_items.length} work items done, {failedCount} interrupted
|
||||
{wf.profile && <> · {wf.profile}</>}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="wfr-work-items">
|
||||
{wf.work_items.map(item => (
|
||||
<div key={item.work_item_projection_id} className={`wfr-work-item wfr-work-item--${item.status}`}>
|
||||
<span className="wfr-work-item-icon" style={{ color: STATUS_COLOR[item.status] || STATUS_COLOR.pending }}>
|
||||
{STATUS_ICON[item.status] || STATUS_ICON.pending}
|
||||
</span>
|
||||
<span className="wfr-work-item-title">{item.title}</span>
|
||||
{item.interrupted && <span className="wfr-work-item-badge">interrupted</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="wfr-actions">
|
||||
{isRecovering ? (
|
||||
<span className="wfr-recovering">
|
||||
<span className="spinner-inline" /> Recovering...
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<button className="wfr-btn wfr-btn--resume" onClick={() => onResume(wf.parent_task_id)}>
|
||||
Resume
|
||||
</button>
|
||||
<button className="wfr-btn wfr-btn--cancel" onClick={() => onCancel(wf.parent_task_id)}>
|
||||
Cancel
|
||||
</button>
|
||||
<button className="wfr-btn wfr-btn--dismiss" onClick={() => setDismissed(prev => new Set(prev).add(wf.parent_task_id))}>
|
||||
Dismiss
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { AgentInfo, OrgInfoPayload, SavedOrgSummary } from '../types/visual'
|
||||
import { WorkItemRecoveryPanel } from './WorkItemRecoveryPanel'
|
||||
import type { ChatMessage, CheckpointReplyMetadata, OutgoingAttachmentPayload } from '../types/chat'
|
||||
import type { KanbanTask, Session, TaskPreferredAgent } from '../types/kanban'
|
||||
import type { BoardStoreState } from '../kanban/BoardStore'
|
||||
@@ -253,7 +252,7 @@ interface WorkspacePageProps {
|
||||
*/
|
||||
onContinueInNewChat?: (mode: 'task' | 'company' | 'org' | 'custom', companyProfile?: 'corporate' | 'custom', orgId?: string) => void
|
||||
onSessionStop?: (taskId: string) => void
|
||||
onSessionResume?: (taskId: string) => void
|
||||
onSessionResume?: (taskId: string, runtimeSessionId?: string, checkpointId?: string) => void
|
||||
onSessionComplete?: (taskId: string) => void
|
||||
onLoadSessionDetail?: (
|
||||
taskId: string,
|
||||
@@ -263,9 +262,6 @@ interface WorkspacePageProps {
|
||||
onCollabSync?: () => void
|
||||
orgInfoData?: OrgInfoPayload | null
|
||||
onNavigateToOrg?: () => void
|
||||
recoveryStatus?: any
|
||||
onRecoveryResume?: (parentTaskId: string) => void
|
||||
onRecoveryCancel?: (parentTaskId: string) => void
|
||||
commsState?: import('../lib/wsClient').CommsStatePayload | null
|
||||
commsMessage?: import('../lib/wsClient').CommsMessagePayload | null
|
||||
onCommsRefresh?: (opts?: { task_id?: string; session_id?: string; project_id?: string }) => void
|
||||
@@ -305,9 +301,6 @@ export function WorkspacePage({
|
||||
onCollabSync,
|
||||
orgInfoData,
|
||||
onNavigateToOrg,
|
||||
recoveryStatus,
|
||||
onRecoveryResume,
|
||||
onRecoveryCancel,
|
||||
commsState,
|
||||
commsMessage,
|
||||
onCommsRefresh,
|
||||
@@ -988,13 +981,21 @@ export function WorkspacePage({
|
||||
|
||||
const handleResume = useCallback(() => {
|
||||
const targetSession = activeConversation.runtimeSession ?? activeConversation.displaySession ?? activeSession
|
||||
const targetTaskId = targetSession?.resumeParentTaskId ?? targetSession?.taskId ?? activeSessionId
|
||||
if (targetTaskId) onSessionResume?.(targetTaskId)
|
||||
const uiTaskId = activeSessionId ?? targetSession?.taskId
|
||||
const runtimeSessionId = targetSession?.resumeParentSessionId
|
||||
?? targetSession?.parentSessionId
|
||||
?? targetSession?.sessionId
|
||||
if (uiTaskId) {
|
||||
onSessionResume?.(uiTaskId, runtimeSessionId, targetSession?.pendingRuntimeCheckpointId)
|
||||
}
|
||||
}, [activeConversation.runtimeSession, activeConversation.displaySession, activeSession, activeSessionId, onSessionResume])
|
||||
|
||||
const handleResumeTask = useCallback((taskId: string) => {
|
||||
const session = sessions.find(s => s.taskId === taskId)
|
||||
onSessionResume?.(session?.resumeParentTaskId ?? taskId)
|
||||
const runtimeSessionId = session?.resumeParentSessionId
|
||||
?? session?.parentSessionId
|
||||
?? session?.sessionId
|
||||
onSessionResume?.(taskId, runtimeSessionId, session?.pendingRuntimeCheckpointId)
|
||||
}, [sessions, onSessionResume])
|
||||
|
||||
const handleCompleteTask = useCallback((taskId: string) => {
|
||||
@@ -1034,8 +1035,11 @@ export function WorkspacePage({
|
||||
}
|
||||
const targetTaskId = activeSessionId
|
||||
if (!targetTaskId) return
|
||||
const checkpointReplyId = String(latestPendingCheckpointReply?.response_to_checkpoint_id ?? '').trim()
|
||||
const runtimeSession = activeConversation.runtimeSession ?? activeConversation.displaySession ?? activeSession
|
||||
const runtimeCheckpointId = String(runtimeSession?.pendingRuntimeCheckpointId ?? '').trim()
|
||||
let outgoingMetadata = latestPendingCheckpointReply
|
||||
?? (runtimeCheckpointId ? { response_to_checkpoint_id: runtimeCheckpointId } : undefined)
|
||||
const checkpointReplyId = String(outgoingMetadata?.response_to_checkpoint_id ?? '').trim()
|
||||
if (!checkpointReplyId) {
|
||||
const uiMessageId = makeOptimisticUserMessageId()
|
||||
outgoingMetadata = { ...(latestPendingCheckpointReply ?? {}), ui_message_id: uiMessageId }
|
||||
@@ -1050,7 +1054,7 @@ export function WorkspacePage({
|
||||
}
|
||||
dispatchSessionSend(targetTaskId, content, attachments, outgoingMetadata)
|
||||
},
|
||||
[effectiveView.kind, activeSessionId, activeConversation.displaySession, activeSession, latestPendingCheckpointReply, chatStore, dispatchSessionSend, onSecretarySend],
|
||||
[effectiveView.kind, activeSessionId, activeConversation.runtimeSession, activeConversation.displaySession, activeSession, latestPendingCheckpointReply, chatStore, dispatchSessionSend, onSecretarySend],
|
||||
)
|
||||
|
||||
// ── MessageList send (checkpoint replies) ──
|
||||
@@ -1114,13 +1118,6 @@ export function WorkspacePage({
|
||||
{/* Middle column: Kanban Board (hidden when panel maximized) */}
|
||||
{panelState !== 'maximized' && (
|
||||
<div className="workspace-board">
|
||||
{recoveryStatus && onRecoveryResume && onRecoveryCancel && (
|
||||
<WorkItemRecoveryPanel
|
||||
data={recoveryStatus}
|
||||
onResume={onRecoveryResume}
|
||||
onCancel={onRecoveryCancel}
|
||||
/>
|
||||
)}
|
||||
{agents.length > 0 && <AgentStatusBar agents={agents} tasks={boardStore.tasks} />}
|
||||
{isCompanyMode ? (
|
||||
boardStore.activeBoard && activeSession && (
|
||||
@@ -1206,7 +1203,6 @@ export function WorkspacePage({
|
||||
onCommsRefresh={onCommsRefresh ? () => onCommsRefresh({ session_id: activeSession?.sessionId || undefined, project_id: projectId || undefined }) : undefined}
|
||||
onCommsReadMessage={onCommsReadMessage}
|
||||
orgInfoData={orgInfoData ?? null}
|
||||
recoveryStatus={recoveryStatus ?? null}
|
||||
canShowTeamTab={canShowTeamTab}
|
||||
onTeamStopRun={activeSessionId ? () => onSessionStop?.(activeSessionId) : undefined}
|
||||
onTitleChange={onTitleChange}
|
||||
|
||||
@@ -1605,132 +1605,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Work Item Recovery Panel ────────────────────────────────────────── */
|
||||
|
||||
.wfr-panel {
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.wfr-card {
|
||||
background: color-mix(in srgb, var(--yellow, #f39c12) 8%, var(--bg-secondary));
|
||||
border: 1px solid color-mix(in srgb, var(--yellow, #f39c12) 25%, transparent);
|
||||
border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.wfr-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.wfr-icon {
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wfr-header-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wfr-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.wfr-subtitle {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.wfr-work-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.wfr-work-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.wfr-work-item-icon {
|
||||
font-size: 12px;
|
||||
width: 14px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.wfr-work-item-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wfr-work-item-badge {
|
||||
font-size: 9px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
background: color-mix(in srgb, var(--red, #e74c3c) 15%, transparent);
|
||||
color: var(--red, #e74c3c);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wfr-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.wfr-btn {
|
||||
padding: 5px 14px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wfr-btn--resume {
|
||||
background: var(--green, #27ae60);
|
||||
color: #fff;
|
||||
}
|
||||
.wfr-btn--resume:hover { opacity: 0.85; }
|
||||
|
||||
.wfr-btn--cancel {
|
||||
background: var(--red, #e74c3c);
|
||||
color: #fff;
|
||||
}
|
||||
.wfr-btn--cancel:hover { opacity: 0.85; }
|
||||
|
||||
.wfr-btn--dismiss {
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.wfr-btn--dismiss:hover { color: var(--text); }
|
||||
|
||||
.wfr-recovering {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════
|
||||
Kanban empty-state
|
||||
═══════════════════════════════════════════════════════ */
|
||||
|
||||
Reference in New Issue
Block a user