Initial commit

This commit is contained in:
LZH-YS1998
2026-07-01 17:56:31 +08:00
commit d78931979d
731 changed files with 311088 additions and 0 deletions
@@ -0,0 +1,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} &middot; {f.from_role} &rarr; {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} &middot; {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 &middot; {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">&#x26A0;</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 && <> &middot; {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