Initial commit
This commit is contained in:
@@ -0,0 +1,527 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import type {
|
||||
CommsStatePayload,
|
||||
CommsMessagePayload,
|
||||
CommsRolePayload,
|
||||
CommsMessageItem,
|
||||
} from '../lib/wsClient'
|
||||
|
||||
interface CommsPanelProps {
|
||||
state: CommsStatePayload | null
|
||||
message: CommsMessagePayload | null
|
||||
onRefresh: () => void
|
||||
onReadMessage: (path: string) => void
|
||||
pollIntervalMs?: number
|
||||
/** When true, renders without its own border/header (embedded in ContextPanel tab). */
|
||||
embedded?: boolean
|
||||
}
|
||||
|
||||
export function CommsPanel({
|
||||
state,
|
||||
message,
|
||||
onRefresh,
|
||||
onReadMessage,
|
||||
pollIntervalMs = 8000,
|
||||
embedded = false,
|
||||
}: CommsPanelProps) {
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(null)
|
||||
|
||||
// Auto-refresh
|
||||
useEffect(() => {
|
||||
if (!pollIntervalMs) return
|
||||
onRefresh()
|
||||
const id = window.setInterval(() => onRefresh(), pollIntervalMs)
|
||||
return () => window.clearInterval(id)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pollIntervalMs])
|
||||
|
||||
const totalUnread = useMemo(() => {
|
||||
if (!state?.roles) return 0
|
||||
return state.roles.reduce((acc, r) => acc + r.unread_count, 0)
|
||||
}, [state?.roles])
|
||||
|
||||
const handleSelectMessage = (path: string) => {
|
||||
setSelectedPath(path)
|
||||
onReadMessage(path)
|
||||
}
|
||||
|
||||
const wrapStyle: React.CSSProperties = embedded
|
||||
? { fontSize: 13, color: 'var(--text)' }
|
||||
: {
|
||||
border: '1px solid var(--border, #333)',
|
||||
borderRadius: 6,
|
||||
background: 'var(--bg-primary, #1e1e1e)',
|
||||
fontSize: 13,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="comms-panel" style={wrapStyle}>
|
||||
{/* Toolbar */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '8px 12px',
|
||||
borderBottom: '1px solid var(--border, #333)',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontWeight: 600, fontSize: 13, flex: 1 }}>
|
||||
Agent Communications
|
||||
</span>
|
||||
{totalUnread > 0 && <Badge color="var(--accent, #3498db)">{totalUnread} unread</Badge>}
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
style={{
|
||||
background: 'var(--surface, #2a2a2a)',
|
||||
border: '1px solid var(--border, #444)',
|
||||
color: 'var(--text-secondary, #aaa)',
|
||||
fontSize: 11,
|
||||
padding: '3px 10px',
|
||||
cursor: 'pointer',
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
{!state ? (
|
||||
<EmptyState>Loading communications...</EmptyState>
|
||||
) : !state.available ? (
|
||||
<EmptyState>Not available{state.reason ? `: ${state.reason}` : ''}</EmptyState>
|
||||
) : state.empty ? (
|
||||
<EmptyState>No communications yet. They will appear once agents start collaborating.</EmptyState>
|
||||
) : (
|
||||
<CommsBody
|
||||
state={state}
|
||||
selectedPath={selectedPath}
|
||||
onSelectMessage={handleSelectMessage}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Message viewer overlay */}
|
||||
{message && (
|
||||
<MessageViewer
|
||||
message={message}
|
||||
onClose={() => setSelectedPath(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Body ── */
|
||||
|
||||
function CommsBody({
|
||||
state,
|
||||
selectedPath,
|
||||
onSelectMessage,
|
||||
}: {
|
||||
state: CommsStatePayload
|
||||
selectedPath: string | null
|
||||
onSelectMessage: (path: string) => void
|
||||
}) {
|
||||
const hasRoles = (state.roles?.length ?? 0) > 0
|
||||
const hasMeetings = (state.meetings?.length ?? 0) > 0
|
||||
const hasFailures = (state.recent_failures?.length ?? 0) > 0
|
||||
|
||||
return (
|
||||
<div style={{ padding: '4px 0' }}>
|
||||
{hasFailures && (
|
||||
<Section title="Failures">
|
||||
{(state.recent_failures || []).map((f, i) => (
|
||||
<div
|
||||
key={`${f.recorded_at || 'f'}-${i}`}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
marginBottom: 4,
|
||||
background: 'color-mix(in srgb, var(--red, #e74c3c) 6%, transparent)',
|
||||
borderLeft: '3px solid var(--red, #e74c3c)',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600, fontSize: 12 }}>
|
||||
{f.operation} · {f.from_role} → {f.to_role}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-secondary, #999)', marginTop: 2 }}>
|
||||
{f.reason}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{hasRoles && (
|
||||
<Section title={`Inboxes (${state.roles!.length})`}>
|
||||
{state.roles!.map((role) => (
|
||||
<RoleSection
|
||||
key={role.role_id}
|
||||
role={role}
|
||||
selectedPath={selectedPath}
|
||||
onSelectMessage={onSelectMessage}
|
||||
/>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{hasMeetings && (
|
||||
<Section title="Meetings">
|
||||
{(state.meetings || []).map((m) => (
|
||||
<div
|
||||
key={m.meeting_id}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
marginBottom: 4,
|
||||
borderLeft: `3px solid ${m.status === 'open' ? 'var(--accent, #3498db)' : 'var(--text-dim, #555)'}`,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<Badge
|
||||
color={m.status === 'open' ? 'var(--accent, #3498db)' : 'var(--text-dim, #555)'}
|
||||
>
|
||||
{m.status}
|
||||
</Badge>
|
||||
<strong style={{ fontSize: 12, flex: 1 }}>{m.topic}</strong>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-dim, #888)' }}>
|
||||
{m.entry_count} entries
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-secondary, #999)', marginTop: 3 }}>
|
||||
by {m.organizer} · {m.participants.join(', ')}
|
||||
</div>
|
||||
{m.decision && (
|
||||
<div style={{ fontSize: 11, marginTop: 3, color: 'var(--green, #27ae60)' }}>
|
||||
Decision: {m.decision}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{!hasRoles && !hasMeetings && !hasFailures && (
|
||||
<EmptyState>No communication activity yet.</EmptyState>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Role Section ── */
|
||||
|
||||
type MessageTab = 'unread' | 'history' | 'sent'
|
||||
|
||||
function RoleSection({
|
||||
role,
|
||||
selectedPath,
|
||||
onSelectMessage,
|
||||
}: {
|
||||
role: CommsRolePayload
|
||||
selectedPath: string | null
|
||||
onSelectMessage: (path: string) => void
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(true)
|
||||
const hasAnyMessages =
|
||||
role.unread_count > 0 || (role.recent_seen?.length ?? 0) > 0 || (role.recent_outbox?.length ?? 0) > 0
|
||||
const [activeTab, setActiveTab] = useState<MessageTab>(role.unread_count > 0 ? 'unread' : 'history')
|
||||
|
||||
const messages: CommsMessageItem[] = useMemo(() => {
|
||||
switch (activeTab) {
|
||||
case 'unread':
|
||||
return role.recent_unread || []
|
||||
case 'history':
|
||||
return role.recent_seen || []
|
||||
case 'sent':
|
||||
return role.recent_outbox || []
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}, [activeTab, role.recent_unread, role.recent_seen, role.recent_outbox])
|
||||
|
||||
const roleName = role.role_id.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: 2 }}>
|
||||
{/* Role header */}
|
||||
<div
|
||||
onClick={() => setExpanded((e) => !e)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '6px 12px',
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
background: expanded ? 'var(--surface-hover, rgba(255,255,255,0.03))' : 'transparent',
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 10, width: 10 }}>{expanded ? '▼' : '▶'}</span>
|
||||
<span style={{ flex: 1, fontWeight: 600, fontSize: 12 }}>{roleName}</span>
|
||||
{role.has_blocking && <Badge color="var(--red, #e74c3c)">BLOCKING</Badge>}
|
||||
{role.unread_count > 0 && (
|
||||
<Badge color="var(--accent, #3498db)">{role.unread_count} new</Badge>
|
||||
)}
|
||||
<span style={{ fontSize: 11, color: 'var(--text-dim, #666)' }}>
|
||||
{role.seen_count} read · {role.outbox_count} sent
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{expanded && hasAnyMessages && (
|
||||
<div style={{ paddingLeft: 12 }}>
|
||||
{/* Sub-tabs */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 2,
|
||||
padding: '4px 0',
|
||||
borderBottom: '1px solid var(--border, #333)',
|
||||
marginBottom: 4,
|
||||
}}
|
||||
>
|
||||
<TabButton active={activeTab === 'unread'} onClick={() => setActiveTab('unread')}>
|
||||
Unread ({role.unread_count})
|
||||
</TabButton>
|
||||
<TabButton active={activeTab === 'history'} onClick={() => setActiveTab('history')}>
|
||||
Read ({role.recent_seen?.length ?? 0})
|
||||
</TabButton>
|
||||
<TabButton active={activeTab === 'sent'} onClick={() => setActiveTab('sent')}>
|
||||
Sent ({role.recent_outbox?.length ?? 0})
|
||||
</TabButton>
|
||||
</div>
|
||||
|
||||
{/* Message list */}
|
||||
{messages.length === 0 ? (
|
||||
<div style={{ padding: '8px 0', fontSize: 11, color: 'var(--text-dim, #666)' }}>
|
||||
No messages.
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ maxHeight: 280, overflow: 'auto' }}>
|
||||
{messages.map((m) => (
|
||||
<MessageRow
|
||||
key={m.message_id}
|
||||
message={m}
|
||||
selected={m.path === selectedPath}
|
||||
onClick={() => onSelectMessage(m.path)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Message Row ── */
|
||||
|
||||
function MessageRow({
|
||||
message,
|
||||
selected,
|
||||
onClick,
|
||||
}: {
|
||||
message: CommsMessageItem
|
||||
selected: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
const isSent = message.bucket === 'sent'
|
||||
const direction = isSent
|
||||
? `To: ${message.to || '?'}`
|
||||
: `From: ${message.from}`
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
style={{
|
||||
padding: '5px 8px',
|
||||
marginBottom: 2,
|
||||
background: selected ? 'var(--accent-soft, #2c4a6b)' : 'transparent',
|
||||
cursor: 'pointer',
|
||||
borderRadius: 4,
|
||||
borderLeft: message.blocking
|
||||
? '3px solid var(--red, #e74c3c)'
|
||||
: '3px solid transparent',
|
||||
transition: 'background 0.1s',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-secondary, #aaa)', minWidth: 0, flex: 1 }}>
|
||||
<strong>{direction}</strong>{' '}
|
||||
<span style={{ color: 'var(--text, #ddd)' }}>{message.subject}</span>
|
||||
</span>
|
||||
{message.blocking && (
|
||||
<span style={{ fontSize: 9, color: 'var(--red, #e74c3c)', fontWeight: 700 }}>BLK</span>
|
||||
)}
|
||||
<span style={{ fontSize: 10, color: 'var(--text-dim, #666)', flexShrink: 0 }}>
|
||||
{message.sent_at?.slice(11, 19) || ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Message Viewer ── */
|
||||
|
||||
function MessageViewer({
|
||||
message,
|
||||
onClose,
|
||||
}: {
|
||||
message: CommsMessagePayload
|
||||
onClose: () => void
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
margin: '8px 12px 12px',
|
||||
padding: 12,
|
||||
border: '1px solid var(--border, #444)',
|
||||
borderRadius: 6,
|
||||
background: 'var(--bg-secondary, #262626)',
|
||||
maxHeight: 400,
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||
<strong style={{ flex: 1, fontSize: 13 }}>
|
||||
{(message.header.subject as string) || '(no subject)'}
|
||||
</strong>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'var(--surface, #333)',
|
||||
border: '1px solid var(--border, #444)',
|
||||
color: 'var(--text-secondary, #aaa)',
|
||||
fontSize: 11,
|
||||
padding: '2px 10px',
|
||||
cursor: 'pointer',
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: 'var(--text-secondary, #999)',
|
||||
marginBottom: 8,
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
From: <strong>{(message.header.from as string) || '?'}</strong>
|
||||
</span>
|
||||
<span>
|
||||
To: <strong>{(message.header.to as string) || '?'}</strong>
|
||||
</span>
|
||||
<span>{(message.header.sent_at as string) || ''}</span>
|
||||
{message.header.blocking && (
|
||||
<Badge color="var(--red, #e74c3c)">BLOCKING</Badge>
|
||||
)}
|
||||
</div>
|
||||
<pre
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 12,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
color: 'var(--text, #ddd)',
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
{message.body}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Shared UI helpers ── */
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 10,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.6,
|
||||
fontWeight: 700,
|
||||
color: 'var(--text-dim, #777)',
|
||||
padding: '8px 12px 4px',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Badge({
|
||||
color,
|
||||
children,
|
||||
}: {
|
||||
color: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
background: color,
|
||||
color: 'white',
|
||||
borderRadius: 10,
|
||||
padding: '1px 7px',
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function TabButton({
|
||||
active,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
style={{
|
||||
background: active ? 'var(--accent-soft, #2c4a6b)' : 'transparent',
|
||||
border: 'none',
|
||||
color: active ? 'var(--text, #ddd)' : 'var(--text-secondary, #999)',
|
||||
fontSize: 11,
|
||||
padding: '3px 10px',
|
||||
cursor: 'pointer',
|
||||
borderRadius: 4,
|
||||
fontWeight: active ? 600 : 400,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyState({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: '20px 16px',
|
||||
fontSize: 12,
|
||||
color: 'var(--text-secondary, #888)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import type { Session } from '../types/kanban'
|
||||
import { composerExecModeForSession } from './ContextPanel'
|
||||
|
||||
function makeSession(overrides: Partial<Session> = {}): Session {
|
||||
return {
|
||||
projectId: 'default',
|
||||
taskId: 'task-1',
|
||||
channelId: 'session:task-1',
|
||||
title: 'Task',
|
||||
status: 'running',
|
||||
columnId: 'in-progress',
|
||||
assigneeIds: [],
|
||||
priority: null,
|
||||
tags: [],
|
||||
progressLog: [],
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
messageCount: 1,
|
||||
mode: 'primary',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
assert.equal(
|
||||
composerExecModeForSession(makeSession({
|
||||
execMode: 'task',
|
||||
companyProfile: 'corporate',
|
||||
isCompanyRuntime: true,
|
||||
workItemProjectionId: 'stale-company-marker',
|
||||
}), 'company'),
|
||||
'task',
|
||||
)
|
||||
|
||||
assert.equal(
|
||||
composerExecModeForSession(makeSession({
|
||||
isCompanyRuntime: true,
|
||||
workItemProjectionId: 'legacy-company-marker',
|
||||
}), 'task'),
|
||||
'company',
|
||||
)
|
||||
|
||||
assert.equal(
|
||||
composerExecModeForSession(makeSession({
|
||||
execMode: 'org',
|
||||
companyProfile: 'custom',
|
||||
orgId: 'quantum_harbor',
|
||||
}), 'task'),
|
||||
'org',
|
||||
)
|
||||
|
||||
console.log('ContextPanel composer identity checks passed')
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,261 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { OrgInfoPayload } from '../types/visual'
|
||||
import type { CommsStatePayload } from '../lib/wsClient'
|
||||
import { getRuntimeOrgView } from '../lib/runtimeOrg'
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {}
|
||||
}
|
||||
|
||||
function asRecordList(value: unknown): Record<string, unknown>[] {
|
||||
return Array.isArray(value)
|
||||
? value.filter((item) => item && typeof item === 'object' && !Array.isArray(item)) as Record<string, unknown>[]
|
||||
: []
|
||||
}
|
||||
|
||||
function summarizeText(value: unknown, fallback = 'Pending') {
|
||||
const text = String(value ?? '').trim()
|
||||
return text || fallback
|
||||
}
|
||||
|
||||
function humanizeId(value: unknown, fallback = 'Team') {
|
||||
const text = String(value ?? '').trim()
|
||||
if (!text) return fallback
|
||||
return text.replace(/[_-]+/g, ' ').replace(/\b\w/g, char => char.toUpperCase())
|
||||
}
|
||||
|
||||
function statusToken(value: unknown) {
|
||||
return summarizeText(value, 'idle').toLowerCase().replace(/[\s_]+/g, '-')
|
||||
}
|
||||
|
||||
interface TeamCardInfo {
|
||||
key: string
|
||||
label: string
|
||||
managerLabel: string
|
||||
status: string
|
||||
seatCount: number
|
||||
pendingApprovals: number
|
||||
latestAlert?: string
|
||||
seats: Array<{
|
||||
key: string
|
||||
roleLabel: string
|
||||
seatLabel: string
|
||||
status: string
|
||||
}>
|
||||
}
|
||||
|
||||
interface ProjectCockpitProps {
|
||||
orgInfoData?: OrgInfoPayload | null
|
||||
recoveryStatus?: Record<string, unknown> | null
|
||||
commsState?: CommsStatePayload | null
|
||||
onStopRun?: () => void
|
||||
embedded?: boolean
|
||||
}
|
||||
|
||||
export function ProjectCockpit({
|
||||
orgInfoData,
|
||||
recoveryStatus,
|
||||
commsState,
|
||||
onStopRun,
|
||||
embedded = false,
|
||||
}: ProjectCockpitProps) {
|
||||
const runtimeView = useMemo(() => getRuntimeOrgView(orgInfoData ?? null), [orgInfoData])
|
||||
const projectRun = runtimeView.projectRun
|
||||
const seatDigests = runtimeView.seatDigests
|
||||
const pendingDecisionCount = seatDigests.reduce((total, digest) => {
|
||||
const managerDigest = asRecord(digest.manager_digest)
|
||||
return total + asRecordList(managerDigest.pending_decisions).length
|
||||
}, 0)
|
||||
const actionableCount = seatDigests.reduce((count, digest) => (
|
||||
count + asRecordList(asRecord(digest.manager_digest).actionable_chat).length
|
||||
), 0)
|
||||
const protocolCount = seatDigests.reduce((count, digest) => (
|
||||
count + asRecordList(asRecord(digest.manager_digest).protocol_backlog).length
|
||||
), 0)
|
||||
const notificationCount = seatDigests.reduce((count, digest) => (
|
||||
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 },
|
||||
{ label: 'Protocol', value: protocolCount },
|
||||
{ label: 'Notifications', value: notificationCount },
|
||||
{ label: 'Meetings', value: commsState?.meetings?.length ?? 0 },
|
||||
{ label: 'Failures', value: commsState?.recent_failures?.length ?? 0 },
|
||||
]
|
||||
|
||||
const teamCards = useMemo<TeamCardInfo[]>(() => {
|
||||
const digestBySeatId = new Map<string, Record<string, unknown>>()
|
||||
for (const digest of seatDigests) {
|
||||
const seatId = String(digest.seat_id ?? '').trim()
|
||||
if (seatId) digestBySeatId.set(seatId, asRecord(digest.manager_digest))
|
||||
}
|
||||
|
||||
const cards: TeamCardInfo[] = runtimeView.runtimeTeams.map((team) => {
|
||||
const teamKeys = [
|
||||
String(team.cell_id ?? '').trim(),
|
||||
String(team.team_id ?? '').trim(),
|
||||
String(team.team_instance_id ?? '').trim(),
|
||||
].filter(Boolean)
|
||||
const seats = runtimeView.runtimeSeats.filter((seat) => (
|
||||
teamKeys.includes(String(seat.team_id ?? '').trim())
|
||||
|| teamKeys.includes(String(seat.team_instance_id ?? '').trim())
|
||||
))
|
||||
const pendingApprovals = seats.reduce((count, seat) => {
|
||||
const managerDigest = digestBySeatId.get(String(seat.seat_id ?? '').trim()) ?? {}
|
||||
return count + asRecordList(managerDigest.pending_decisions).length
|
||||
}, 0)
|
||||
const latestAlert = seats
|
||||
.map((seat) => summarizeText(seat.latest_notification?.subject ?? seat.latest_notification?.summary, ''))
|
||||
.find(Boolean)
|
||||
|
||||
return {
|
||||
key: String(team.cell_id ?? team.team_id ?? team.team_instance_id ?? team.manager_role_id ?? 'team'),
|
||||
label: humanizeId(team.team_id ?? team.cell_id, 'Team'),
|
||||
managerLabel: humanizeId(team.manager_role_id, 'Unassigned manager'),
|
||||
status: summarizeText(team.status, 'idle'),
|
||||
seatCount: seats.length || team.member_role_ids.length,
|
||||
pendingApprovals,
|
||||
latestAlert,
|
||||
seats: seats.map((seat) => ({
|
||||
key: String(seat.seat_id ?? seat.role_session_id ?? seat.role_id),
|
||||
roleLabel: humanizeId(seat.role_id, 'Seat'),
|
||||
seatLabel: summarizeText(seat.seat_id ?? seat.role_session_id, 'Seat'),
|
||||
status: summarizeText(seat.resident_status ?? seat.status, 'idle'),
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
const coveredTeamKeys = new Set(
|
||||
cards.flatMap((card) => card.seats.map((seat) => seat.key))
|
||||
)
|
||||
|
||||
for (const seat of runtimeView.runtimeSeats) {
|
||||
const seatKey = String(seat.seat_id ?? seat.role_session_id ?? seat.role_id).trim()
|
||||
if (!seatKey || coveredTeamKeys.has(seatKey)) continue
|
||||
|
||||
const teamKey = String(seat.team_id ?? seat.team_instance_id ?? 'unassigned').trim() || 'unassigned'
|
||||
let card = cards.find((item) => item.key === teamKey)
|
||||
if (!card) {
|
||||
card = {
|
||||
key: teamKey,
|
||||
label: humanizeId(teamKey, 'Unassigned Team'),
|
||||
managerLabel: 'Unassigned manager',
|
||||
status: summarizeText(seat.status, 'idle'),
|
||||
seatCount: 0,
|
||||
pendingApprovals: 0,
|
||||
seats: [],
|
||||
}
|
||||
cards.push(card)
|
||||
}
|
||||
|
||||
const managerDigest = digestBySeatId.get(String(seat.seat_id ?? '').trim()) ?? {}
|
||||
card.pendingApprovals += asRecordList(managerDigest.pending_decisions).length
|
||||
if (!card.latestAlert) {
|
||||
card.latestAlert = summarizeText(seat.latest_notification?.subject ?? seat.latest_notification?.summary, '')
|
||||
}
|
||||
card.seats.push({
|
||||
key: seatKey,
|
||||
roleLabel: humanizeId(seat.role_id, 'Seat'),
|
||||
seatLabel: summarizeText(seat.seat_id ?? seat.role_session_id, 'Seat'),
|
||||
status: summarizeText(seat.resident_status ?? seat.status, 'idle'),
|
||||
})
|
||||
card.seatCount = card.seats.length
|
||||
}
|
||||
|
||||
return cards.sort((left, right) => left.label.localeCompare(right.label))
|
||||
}, [runtimeView.runtimeTeams, runtimeView.runtimeSeats, seatDigests])
|
||||
|
||||
const latestAlert = teamCards.map((team) => team.latestAlert).find(Boolean) || 'No current alerts'
|
||||
|
||||
if (!projectRun?.run_id && teamCards.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={`project-cockpit${embedded ? ' embedded' : ''}`} aria-label="Team">
|
||||
<div className="project-cockpit-overview">
|
||||
<div className="project-cockpit-title">
|
||||
<span className="project-cockpit-label">Team</span>
|
||||
<strong>{summarizeText(projectRun?.run_id, 'Runtime Team Status')}</strong>
|
||||
{onStopRun && (
|
||||
<button
|
||||
className="project-cockpit-stop-btn"
|
||||
onClick={onStopRun}
|
||||
title="Stop this run"
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="project-cockpit-metrics">
|
||||
<span>{summarizeText(projectRun?.lifecycle_status, 'active')}</span>
|
||||
<span>Teams {teamCards.length}</span>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="project-cockpit-grid project-cockpit-grid--team">
|
||||
<section className="project-cockpit-panel">
|
||||
<header>
|
||||
<h3>Communication</h3>
|
||||
<span>{communicationItems.length}</span>
|
||||
</header>
|
||||
<div className="project-cockpit-summary">
|
||||
{communicationItems.map((item) => (
|
||||
<span key={item.label}>{item.label} {item.value}</span>
|
||||
))}
|
||||
<div className="project-cockpit-inline-text">{latestAlert}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="project-cockpit-panel project-cockpit-panel--wide">
|
||||
<header>
|
||||
<h3>Teams</h3>
|
||||
<span>{teamCards.length}</span>
|
||||
</header>
|
||||
<div className="project-cockpit-team-grid">
|
||||
{teamCards.map((team) => (
|
||||
<article key={team.key} className="project-cockpit-team-card">
|
||||
<div className="project-cockpit-team-head">
|
||||
<div className="project-cockpit-team-title">
|
||||
<strong>{team.label}</strong>
|
||||
<span>{team.managerLabel}</span>
|
||||
</div>
|
||||
<span className={`project-cockpit-team-status status-${statusToken(team.status)}`}>
|
||||
{team.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="project-cockpit-team-meta">
|
||||
<span>Seats {team.seatCount}</span>
|
||||
<span>Approvals {team.pendingApprovals}</span>
|
||||
{team.latestAlert && <span>{team.latestAlert}</span>}
|
||||
</div>
|
||||
<div className="project-cockpit-seat-list">
|
||||
{team.seats.length > 0 ? team.seats.map((seat) => (
|
||||
<div key={seat.key} className="project-cockpit-seat-row">
|
||||
<span className="project-cockpit-seat-role">{seat.roleLabel}</span>
|
||||
<span className="project-cockpit-seat-id">{seat.seatLabel}</span>
|
||||
<span className={`project-cockpit-seat-status status-${statusToken(seat.status)}`}>
|
||||
{seat.status}
|
||||
</span>
|
||||
</div>
|
||||
)) : (
|
||||
<div className="project-cockpit-inline-text">No seats assigned yet</div>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { KanbanTask, Session } from '../types/kanban'
|
||||
import type { ChatMessage } from '../types/chat'
|
||||
import { AGENT_STATUS_LABEL, PRIORITY_META } from '../types/kanban'
|
||||
import type { AgentInfo } from '../types/visual'
|
||||
import { MarkdownBody, MessageList } from '../chat/MessageList'
|
||||
import { getLinkedRuntimeTaskId } from '../lib/workItemRuntimeIds'
|
||||
|
||||
interface TaskDetailViewProps {
|
||||
task: KanbanTask
|
||||
linkedSession?: Session | null
|
||||
linkedSessionMessages?: ChatMessage[]
|
||||
agents: AgentInfo[]
|
||||
onBack: () => void
|
||||
onOpenLinkedSession?: (taskId: string) => void
|
||||
onOpenExecutionPanel?: (taskId: string) => void
|
||||
}
|
||||
|
||||
function prettyJson(value: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(value, null, 2)
|
||||
} catch {
|
||||
return String(value ?? '')
|
||||
}
|
||||
}
|
||||
|
||||
function stringList(value: string[] | undefined): string[] {
|
||||
return (value ?? []).map(item => item.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
function infoPairsFromRecord(value: Record<string, unknown> | undefined): Array<[string, string]> {
|
||||
if (!value) return []
|
||||
return Object.entries(value)
|
||||
.filter(([, entry]) => entry !== null && entry !== undefined && entry !== '' && entry !== false)
|
||||
.map(([key, entry]) => {
|
||||
if (Array.isArray(entry)) return [key, entry.join(', ')]
|
||||
if (typeof entry === 'object') return [key, prettyJson(entry)]
|
||||
return [key, String(entry)]
|
||||
})
|
||||
}
|
||||
|
||||
export function TaskDetailView({
|
||||
task,
|
||||
linkedSession,
|
||||
linkedSessionMessages,
|
||||
agents,
|
||||
onBack,
|
||||
onOpenLinkedSession,
|
||||
onOpenExecutionPanel,
|
||||
}: TaskDetailViewProps) {
|
||||
const liveAssignees = useMemo(() => (
|
||||
task.assigneeIds
|
||||
.map(id => agents.find(agent => agent.agent_id === id))
|
||||
.filter(Boolean) as AgentInfo[]
|
||||
), [agents, task.assigneeIds])
|
||||
|
||||
const priorityMeta = task.priority ? PRIORITY_META[task.priority] : null
|
||||
const residentAssignment = (task.residentAssignment ?? {}) as Record<string, unknown>
|
||||
const memberSessionState = (task.memberSessionState ?? {}) as Record<string, unknown>
|
||||
const ownershipContract = (task.ownershipContract ?? {}) as Record<string, unknown>
|
||||
const runtimeActive = !!(task.agentStatus && task.agentStatus !== 'idle')
|
||||
const deliverables = stringList(task.deliverables)
|
||||
const acceptanceCriteria = stringList(task.acceptanceCriteria)
|
||||
const dependencyIds = stringList(task.dependencies)
|
||||
const assignmentSummary = infoPairsFromRecord({
|
||||
role_id: residentAssignment.role_id,
|
||||
employee_id: residentAssignment.employee_id,
|
||||
manager_role_id: residentAssignment.manager_role_id,
|
||||
team_id: residentAssignment.team_id,
|
||||
seat_id: residentAssignment.seat_id,
|
||||
work_item_turn_type: residentAssignment.work_item_turn_type,
|
||||
resident_status: residentAssignment.resident_status,
|
||||
})
|
||||
const memberStateSummary = infoPairsFromRecord({
|
||||
status: memberSessionState.status,
|
||||
current_turn_mode: memberSessionState.current_turn_mode,
|
||||
manager_role_id: memberSessionState.manager_role_id,
|
||||
actionable_inbox_count: memberSessionState.actionable_inbox_count,
|
||||
protocol_backlog_count: memberSessionState.protocol_backlog_count,
|
||||
notification_backlog_count: memberSessionState.notification_backlog_count,
|
||||
})
|
||||
const promptContext = String(task.employeeAssignment?.promptContext ?? '').trim()
|
||||
const deltaContext = String(task.employeeAssignment?.deltaContext ?? '').trim()
|
||||
const linkedTaskId = getLinkedRuntimeTaskId(task)
|
||||
const isWorkItem = !!(task.workItemId || task.workItemProjectionId || linkedTaskId)
|
||||
|
||||
return (
|
||||
<div className="ctx-child-detail">
|
||||
<div className="ctx-child-topbar">
|
||||
<button className="ctx-back-btn" onClick={onBack}>
|
||||
{'\u2190'} Back to board
|
||||
</button>
|
||||
{linkedTaskId && (onOpenLinkedSession || onOpenExecutionPanel) && (
|
||||
<button
|
||||
className="ctx-child-stop-btn"
|
||||
onClick={() => {
|
||||
if (onOpenLinkedSession) {
|
||||
onOpenLinkedSession(linkedTaskId)
|
||||
return
|
||||
}
|
||||
onOpenExecutionPanel?.(linkedTaskId)
|
||||
}}
|
||||
>
|
||||
Open Runtime Session
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="ctx-child-header">
|
||||
<div className="ctx-child-avatar">
|
||||
{(task.workItemRoleName ?? task.title).charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="ctx-child-meta">
|
||||
<span className="ctx-child-name">{task.title}</span>
|
||||
<span className="ctx-child-work-item">
|
||||
{[task.workItemRoleName, task.phase].filter(Boolean).join(' · ')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{runtimeActive && (
|
||||
<div className={`task-detail-runtime status-${task.agentStatus}`}>
|
||||
<span className="kanban-runtime-dot" />
|
||||
<span>
|
||||
{task.agentStatus === 'tool_active' && task.currentTool
|
||||
? task.currentTool
|
||||
: AGENT_STATUS_LABEL[task.agentStatus!] ?? task.agentStatus}
|
||||
</span>
|
||||
{liveAssignees.length > 0 && (
|
||||
<span className="task-detail-runtime-agent">
|
||||
{liveAssignees.map(agent => agent.name).join(', ')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="task-detail-body">
|
||||
<div className="task-detail-section">
|
||||
<h4 className="task-detail-section-title">{isWorkItem ? 'Work Item' : 'Task'}</h4>
|
||||
<div className="ctx-task-chip-row">
|
||||
<span className="task-detail-dep-id">{task.displayId}</span>
|
||||
{priorityMeta && <span className="kanban-tag">{priorityMeta.label}</span>}
|
||||
{task.workItemRoleName && <span className="kanban-tag">{task.workItemRoleName}</span>}
|
||||
{task.managerRoleId && <span className="kanban-tag">Manager: {task.managerRoleId}</span>}
|
||||
{task.scopeKey && <span className="kanban-tag">{task.scopeKey}</span>}
|
||||
</div>
|
||||
{task.description && (
|
||||
<div className="msg-content-agent-card ctx-task-detail-card">
|
||||
<MarkdownBody content={task.description} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{task.originalMessage && (
|
||||
<div className="task-detail-section">
|
||||
<h4 className="task-detail-section-title">Session Goal</h4>
|
||||
<pre className="task-detail-handoff">{task.originalMessage}</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{task.planningContext && (
|
||||
<div className="task-detail-section">
|
||||
<h4 className="task-detail-section-title">Planning Context</h4>
|
||||
<pre className="task-detail-handoff">{task.planningContext}</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{deliverables.length > 0 && (
|
||||
<div className="task-detail-section">
|
||||
<h4 className="task-detail-section-title">Deliverables</h4>
|
||||
<ul className="task-detail-dep-list">
|
||||
{deliverables.map(item => (
|
||||
<li key={item} className="task-detail-dep-item">{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{acceptanceCriteria.length > 0 && (
|
||||
<div className="task-detail-section">
|
||||
<h4 className="task-detail-section-title">Acceptance Criteria</h4>
|
||||
<ul className="task-detail-dep-list">
|
||||
{acceptanceCriteria.map(item => (
|
||||
<li key={item} className="task-detail-dep-item">{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(task.delegationRationale || task.nonOverlapGuard || task.coordinationNotes) && (
|
||||
<div className="task-detail-section">
|
||||
<h4 className="task-detail-section-title">Delegation Notes</h4>
|
||||
{task.delegationRationale && <pre className="task-detail-handoff">{task.delegationRationale}</pre>}
|
||||
{task.nonOverlapGuard && <pre className="task-detail-handoff">{task.nonOverlapGuard}</pre>}
|
||||
{task.coordinationNotes && <pre className="task-detail-handoff">{task.coordinationNotes}</pre>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dependencyIds.length > 0 && (
|
||||
<div className="task-detail-section">
|
||||
<h4 className="task-detail-section-title">Dependencies</h4>
|
||||
<ul className="task-detail-dep-list">
|
||||
{dependencyIds.map(depId => (
|
||||
<li key={depId} className="task-detail-dep-item">
|
||||
<span className="task-detail-dep-id">{depId}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(assignmentSummary.length > 0 || memberStateSummary.length > 0 || linkedSession) && (
|
||||
<div className="task-detail-section">
|
||||
<h4 className="task-detail-section-title">Role Runtime Context</h4>
|
||||
{linkedSession && (
|
||||
<div className="ctx-task-chip-row">
|
||||
<span className="kanban-tag">Runtime Session: {linkedSession.title}</span>
|
||||
<span className="kanban-tag">Status: {linkedSession.status}</span>
|
||||
</div>
|
||||
)}
|
||||
{assignmentSummary.length > 0 && (
|
||||
<div className="ctx-task-kv-grid">
|
||||
{assignmentSummary.map(([label, value]) => (
|
||||
<div key={label} className="ctx-task-kv-item">
|
||||
<span className="ctx-task-kv-label">{label}</span>
|
||||
<span className="ctx-task-kv-value">{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{memberStateSummary.length > 0 && (
|
||||
<div className="ctx-task-kv-grid">
|
||||
{memberStateSummary.map(([label, value]) => (
|
||||
<div key={label} className="ctx-task-kv-item">
|
||||
<span className="ctx-task-kv-label">{label}</span>
|
||||
<span className="ctx-task-kv-value">{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{task.handoffContext && (
|
||||
<div className="task-detail-section">
|
||||
<h4 className="task-detail-section-title">Handoff Context</h4>
|
||||
<pre className="task-detail-handoff">{task.handoffContext}</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{promptContext && (
|
||||
<div className="task-detail-section">
|
||||
<h4 className="task-detail-section-title">Role Prompt Context</h4>
|
||||
<div className="msg-content-agent-card ctx-task-detail-card">
|
||||
<MarkdownBody content={promptContext} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{deltaContext && (
|
||||
<div className="task-detail-section">
|
||||
<h4 className="task-detail-section-title">Role Delta Context</h4>
|
||||
<pre className="task-detail-handoff">{deltaContext}</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{Object.keys(ownershipContract).length > 0 && (
|
||||
<div className="task-detail-section">
|
||||
<h4 className="task-detail-section-title">Ownership Contract</h4>
|
||||
<pre className="task-detail-handoff">{prettyJson(ownershipContract)}</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{task.progressLog && task.progressLog.length > 0 && (
|
||||
<div className="task-detail-section">
|
||||
<h4 className="task-detail-section-title">Activity</h4>
|
||||
<ul className="task-detail-progress">
|
||||
{task.progressLog.map((entry, index) => (
|
||||
<li key={`${entry.timestamp}-${index}`} className={`progress-entry type-${entry.type}`}>
|
||||
<span className="progress-time">
|
||||
{new Date(entry.timestamp).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
<span className="progress-summary">{entry.summary}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Runtime session transcript — live chat of the agent processing this work item */}
|
||||
<div className="task-detail-section">
|
||||
<h4 className="task-detail-section-title">Runtime Session Activity</h4>
|
||||
{linkedSession && linkedSessionMessages && linkedSessionMessages.length > 0 ? (
|
||||
<div className="task-detail-linked-messages">
|
||||
<MessageList
|
||||
messages={linkedSessionMessages}
|
||||
channelName={linkedSession.title ?? 'Runtime Session'}
|
||||
detailMode="summary"
|
||||
/>
|
||||
</div>
|
||||
) : linkedSession ? (
|
||||
<p className="task-detail-empty-hint">Runtime session has no visible messages yet.</p>
|
||||
) : (
|
||||
<p className="task-detail-empty-hint">No Runtime Session linked yet.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const src = readFileSync(join(here, 'WorkspacePage.tsx'), 'utf8')
|
||||
|
||||
assert.match(src, /makeOptimisticUserMessageId/, 'ordinary composer sends must create a stable optimistic ui_message_id')
|
||||
assert.match(src, /chatStore\.sendMessage/, 'ordinary composer sends must echo the user message locally before backend response')
|
||||
assert.match(src, /ui_message_id: uiMessageId/, 'optimistic local message and websocket metadata must share ui_message_id')
|
||||
assert.match(src, /checkpointReplyId/, 'checkpoint replies must be excluded from ordinary optimistic composer echo')
|
||||
|
||||
console.log('WorkspacePage.test.ts: OK (optimistic composer echo wiring)')
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,73 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
interface UseResizePanelOptions {
|
||||
initialWidth: number
|
||||
minWidth: number
|
||||
maxWidth: number
|
||||
onCollapse?: () => void
|
||||
}
|
||||
|
||||
interface UseResizePanelReturn {
|
||||
width: number
|
||||
isResizing: boolean
|
||||
handleMouseDown: (e: React.MouseEvent) => void
|
||||
}
|
||||
|
||||
export function useResizePanel({
|
||||
initialWidth,
|
||||
minWidth,
|
||||
maxWidth,
|
||||
onCollapse,
|
||||
}: UseResizePanelOptions): UseResizePanelReturn {
|
||||
const [width, setWidth] = useState(initialWidth)
|
||||
const [isResizing, setIsResizing] = useState(false)
|
||||
const startXRef = useRef(0)
|
||||
const startWidthRef = useRef(0)
|
||||
const widthRef = useRef(initialWidth)
|
||||
|
||||
// Keep ref in sync with state
|
||||
widthRef.current = width
|
||||
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
startXRef.current = e.clientX
|
||||
startWidthRef.current = widthRef.current
|
||||
setIsResizing(true)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isResizing) return
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
// Panel is on the right, so dragging left increases width
|
||||
const delta = startXRef.current - e.clientX
|
||||
const next = startWidthRef.current + delta
|
||||
|
||||
if (next < minWidth - 50) {
|
||||
onCollapse?.()
|
||||
setIsResizing(false)
|
||||
return
|
||||
}
|
||||
|
||||
setWidth(Math.min(maxWidth, Math.max(minWidth, next)))
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsResizing(false)
|
||||
}
|
||||
|
||||
document.body.style.userSelect = 'none'
|
||||
document.body.style.cursor = 'col-resize'
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
|
||||
return () => {
|
||||
document.body.style.userSelect = ''
|
||||
document.body.style.cursor = ''
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
}, [isResizing, minWidth, maxWidth, onCollapse])
|
||||
|
||||
return { width, isResizing, handleMouseDown }
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user