409 lines
18 KiB
Python
409 lines
18 KiB
Python
"""Task detail pane with structured checkpoint panels."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
from typing import Any
|
|
|
|
from rich.console import Group, RenderableType
|
|
from rich.panel import Panel
|
|
from rich.text import Text
|
|
from textual.widgets import Static
|
|
|
|
from ..state.models import PendingCheckpointView, TaskDetailView
|
|
from ..state.store import BoardStateStore
|
|
from .render_utils import adaptive_summary, badge, format_clock, humanize_age, priority_style, status_style, truncate_text
|
|
|
|
_RISK_STYLES = {
|
|
"low": "bold #22c55e",
|
|
"medium": "bold #f59e0b",
|
|
"high": "bold #ef4444",
|
|
"critical": "bold white on #ef4444",
|
|
}
|
|
|
|
_SCOPE_LABELS = {
|
|
"task_adjustment": "Task Adjustment",
|
|
"org_mutation": "Org Mutation",
|
|
}
|
|
|
|
_CHANGE_ACTION_STYLES = {
|
|
"add": ("+ ", "bold #22c55e"),
|
|
"remove": ("- ", "bold #ef4444"),
|
|
"replace": ("~ ", "bold #f59e0b"),
|
|
"update": ("~ ", "bold #f59e0b"),
|
|
}
|
|
|
|
|
|
def _progress_bar(ratio: float | None, width: int = 10) -> str:
|
|
"""Render a block progress bar: ████████░░"""
|
|
try:
|
|
val = float(ratio if ratio is not None else 0)
|
|
if math.isnan(val) or math.isinf(val):
|
|
val = 0.0
|
|
clamped = max(0.0, min(1.0, val))
|
|
except (TypeError, ValueError):
|
|
clamped = 0.0
|
|
filled = int(clamped * width)
|
|
return "\u2588" * filled + "\u2591" * (width - filled)
|
|
|
|
|
|
class DetailPaneWidget(Static):
|
|
"""Render the selected task's metadata and linked executions."""
|
|
|
|
def __init__(self, state: BoardStateStore) -> None:
|
|
super().__init__(id="detail-pane")
|
|
self.state = state
|
|
self.detail: TaskDetailView | None = None
|
|
|
|
def set_detail(self, detail: TaskDetailView | None) -> None:
|
|
self.detail = detail
|
|
self.refresh()
|
|
|
|
def render(self) -> RenderableType:
|
|
focused = self.state.pane_focus == "context" and self.state.context_tab == "detail"
|
|
company_mode = self.state.snapshot.mode == "company"
|
|
detail_title = "Work Item Detail" if company_mode else "Task Detail"
|
|
if self.detail is None:
|
|
return Panel(
|
|
Text(
|
|
"Select a work item to inspect details." if company_mode else "Select a task to inspect details.",
|
|
style="dim",
|
|
),
|
|
title=f"{detail_title} [Focused]" if focused else detail_title,
|
|
border_style="cyan" if focused else "white",
|
|
)
|
|
|
|
task = self.detail.task
|
|
runtime = self.state.runtime_for(task.task_id)
|
|
blocks: list[RenderableType] = [self._render_overview(task, runtime)]
|
|
|
|
if self.detail.pending_checkpoint:
|
|
blocks.append(self._render_checkpoint())
|
|
if self.detail.linked_executions:
|
|
blocks.append(self._render_linked())
|
|
if self.detail.result_content:
|
|
blocks.append(self._render_section("Latest Result", self.detail.result_content, 560))
|
|
if self.detail.context_preview:
|
|
blocks.append(self._render_section("Context Preview", self.detail.context_preview, 560))
|
|
|
|
return Panel(
|
|
Group(*blocks),
|
|
title=f"{detail_title} [Focused]" if focused else detail_title,
|
|
border_style="cyan" if focused else "white",
|
|
)
|
|
|
|
def _render_overview(self, task: Any, runtime: Any) -> Text:
|
|
adaptive = adaptive_summary(task.metadata)
|
|
header = Text()
|
|
header.append(f"{task.display_id or task.task_id}\n", style="bold #38bdf8")
|
|
header.append(f"{task.title}\n", style="bold white")
|
|
header += badge(task.status.upper(), status_style(task.status))
|
|
if task.priority:
|
|
header.append(" ")
|
|
header += badge(task.priority.upper(), priority_style(task.priority))
|
|
if task.pending_checkpoint:
|
|
header.append(" ")
|
|
header += badge("REVIEW", status_style("warn"))
|
|
if runtime and runtime.status not in {"idle", ""}:
|
|
header.append(" ")
|
|
header += badge(runtime.status.upper(), status_style(runtime.status))
|
|
header.append("\n")
|
|
header.append(f"column {task.column_id}", style="dim")
|
|
header.append(f" updated {humanize_age(task.updated_at)}", style="dim")
|
|
header.append(f" created {format_clock(task.created_at)}", style="dim")
|
|
if task.assigned_to:
|
|
header.append(f"\nowner {task.assigned_to}", style="dim")
|
|
if task.tags:
|
|
header.append(f"\ntags {truncate_text(', '.join(task.tags), 70)}", style="dim")
|
|
if task.dependencies:
|
|
header.append(f"\ndeps {truncate_text(', '.join(task.dependencies), 70)}", style="dim")
|
|
if adaptive["state"]:
|
|
header.append(f"\nadaptive {adaptive['state']}", style="dim")
|
|
if adaptive["gate_owner"]:
|
|
header.append(f"\ngate owner {adaptive['gate_owner']}", style="dim")
|
|
if adaptive["missing_signals"]:
|
|
header.append(
|
|
f"\nmissing signals {truncate_text(', '.join(adaptive['missing_signals']), 70)}",
|
|
style="dim",
|
|
)
|
|
if adaptive["confidence_label"]:
|
|
header.append(f"\nconfidence {adaptive['confidence_label']}", style="dim")
|
|
if adaptive["blocked_reason"]:
|
|
header.append(f"\nwaiting {truncate_text(adaptive['blocked_reason'], 120)}", style="dim")
|
|
if runtime and runtime.current_tool:
|
|
header.append(f"\nactive tool {runtime.current_tool}", style="dim")
|
|
if task.description:
|
|
header.append(f"\n\n{truncate_text(task.description, 420)}", style="white")
|
|
return header
|
|
|
|
# ----- Checkpoint dispatcher -----
|
|
|
|
def _render_checkpoint(self) -> RenderableType:
|
|
checkpoint = self.detail.pending_checkpoint
|
|
assert checkpoint is not None
|
|
cp_type = checkpoint.checkpoint_type.strip().lower()
|
|
if cp_type == "company_staffing_selection":
|
|
return self._render_staffing_checkpoint(checkpoint)
|
|
if cp_type == "company_recruitment_confirmation":
|
|
return self._render_recruitment_checkpoint(checkpoint)
|
|
if cp_type == "company_reorg_pending":
|
|
return self._render_reorg_checkpoint(checkpoint)
|
|
if cp_type == "human_escalation":
|
|
return self._render_escalation_checkpoint(checkpoint)
|
|
return self._render_generic_checkpoint(checkpoint)
|
|
|
|
# ----- Recruitment -----
|
|
|
|
def _render_staffing_checkpoint(self, checkpoint: PendingCheckpointView) -> RenderableType:
|
|
payload = checkpoint.payload
|
|
roles = payload.get("staffing_roles", []) or []
|
|
pool = payload.get("staffing_pool", {}) or {}
|
|
employees = {
|
|
str(item.get("employee_id", "") or ""): item
|
|
for item in list(pool.get("employees", []) or [])
|
|
if str(item.get("employee_id", "") or "")
|
|
}
|
|
profile = payload.get("company_profile", "")
|
|
|
|
header = Text()
|
|
header.append("MANUAL STAFFING", style="bold #22c55e")
|
|
header.append(" ")
|
|
header += badge("PENDING", status_style("warn"))
|
|
if profile:
|
|
header.append(f" {profile}", style="dim italic")
|
|
|
|
parts: list[RenderableType] = [header]
|
|
for index, role in enumerate(roles, start=1):
|
|
role_id = str(role.get("role_id", "") or "?")
|
|
role_label = str(role.get("role_label", "") or role_id)
|
|
selection = role.get("default_selection", {}) or {}
|
|
text = Text(f"\n{index}. ", style="bold white")
|
|
text.append(role_id, style="bold #38bdf8")
|
|
if role_label and role_label != role_id:
|
|
text.append(f" {role_label}", style="dim")
|
|
if selection.get("kind") == "employee":
|
|
employee_id = str(selection.get("employee_id") or selection.get("id") or "")
|
|
employee = employees.get(employee_id, {})
|
|
name = employee.get("employee_name") or employee_id
|
|
text.append(f"\n default: {name}", style="bold white")
|
|
text.append(f" ({employee_id})", style="dim")
|
|
else:
|
|
text.append("\n default: fallback role-only", style="dim")
|
|
parts.append(text)
|
|
parts.append(Text("\n[a] Approve defaults [r] Auto Recruit [d] Deny", style="dim italic"))
|
|
return Panel(Group(*parts), title="Checkpoint: Manual Staffing", border_style="#22c55e")
|
|
|
|
def _render_recruitment_checkpoint(self, checkpoint: PendingCheckpointView) -> RenderableType:
|
|
payload = checkpoint.payload
|
|
plan = payload.get("recruitment_plan", {})
|
|
proposals = plan.get("proposals", [])
|
|
summary_text = plan.get("summary", "") or checkpoint.summary
|
|
profile = plan.get("company_profile", "")
|
|
|
|
header = Text()
|
|
header.append("RECRUITMENT", style="bold #fbbf24")
|
|
header.append(" ")
|
|
header += badge("PENDING", status_style("warn"))
|
|
if profile:
|
|
header.append(f" {profile}", style="dim italic")
|
|
|
|
parts: list[RenderableType] = [header]
|
|
|
|
if summary_text:
|
|
parts.append(Text(f"\n{truncate_text(summary_text, 280)}", style="white"))
|
|
|
|
for i, proposal in enumerate(proposals):
|
|
parts.append(self._render_proposal(i + 1, proposal))
|
|
|
|
parts.append(Text("\n[a] Approve [d] Deny [e] Feedback", style="dim italic"))
|
|
|
|
return Panel(Group(*parts), title="Checkpoint: Recruitment", border_style="#fbbf24")
|
|
|
|
def _render_proposal(self, index: int, proposal: dict[str, Any]) -> Text:
|
|
role_id = proposal.get("role_id") or "?"
|
|
status = proposal.get("status") or ""
|
|
status_label = {"proposed_hire": "New Hire", "existing_staff": "Existing"}.get(status, status or "Fallback")
|
|
role_labels = proposal.get("role_labels") or []
|
|
|
|
text = Text(f"\n{index}. ", style="bold white")
|
|
text.append(role_id, style="bold #38bdf8")
|
|
text.append(" ")
|
|
text += badge(status_label, "bold black on #64748b")
|
|
|
|
if role_labels:
|
|
text.append(f"\n roles: {', '.join(str(label) for label in role_labels)}", style="dim")
|
|
|
|
candidate = proposal.get("candidate")
|
|
if candidate and isinstance(candidate, dict):
|
|
name = candidate.get("proposed_employee_name") or candidate.get("template_name") or "unnamed"
|
|
category = candidate.get("category") or ""
|
|
domains = candidate.get("domains") or []
|
|
rationale = candidate.get("rationale") or ""
|
|
text.append(f"\n {name}", style="bold white")
|
|
if category:
|
|
text.append(f" [{category}]", style="dim")
|
|
if domains:
|
|
text.append(f"\n domains: {', '.join(str(d) for d in domains[:6])}", style="#a78bfa")
|
|
if rationale:
|
|
text.append(f"\n {truncate_text(str(rationale), 120)}", style="dim italic")
|
|
|
|
existing = proposal.get("existing_employee")
|
|
if existing and isinstance(existing, dict):
|
|
emp_name = existing.get("employee_name") or "?"
|
|
emp_id = existing.get("employee_id") or ""
|
|
score_raw = existing.get("experience_score")
|
|
score = float(score_raw) if score_raw is not None else 0.0
|
|
domains = existing.get("domains") or []
|
|
rationale = existing.get("rationale") or ""
|
|
text.append(f"\n {emp_name}", style="bold white")
|
|
if emp_id:
|
|
text.append(f" ({emp_id})", style="dim")
|
|
text.append(f"\n score: {_progress_bar(score)} {int(score * 100)}%", style="#22c55e")
|
|
if domains:
|
|
text.append(f"\n domains: {', '.join(str(d) for d in domains[:6])}", style="#a78bfa")
|
|
if rationale:
|
|
text.append(f"\n {truncate_text(str(rationale), 120)}", style="dim italic")
|
|
|
|
top_rationale = proposal.get("rationale") or ""
|
|
if top_rationale and not candidate and not existing:
|
|
text.append(f"\n {truncate_text(str(top_rationale), 120)}", style="dim italic")
|
|
|
|
return text
|
|
|
|
# ----- Reorg -----
|
|
|
|
def _render_reorg_checkpoint(self, checkpoint: PendingCheckpointView) -> RenderableType:
|
|
payload = checkpoint.payload
|
|
title = payload.get("title", "") or "Company Reorg"
|
|
scope = payload.get("scope", "org_mutation")
|
|
risk = payload.get("risk_level", "medium")
|
|
summary = payload.get("summary", "") or checkpoint.summary
|
|
rationale = payload.get("rationale", "")
|
|
role_changes = payload.get("role_changes", [])
|
|
impact = payload.get("impact_summary", {})
|
|
|
|
header = Text()
|
|
header.append("REORG", style="bold #fbbf24")
|
|
header.append(" ")
|
|
scope_label = _SCOPE_LABELS.get(scope, scope)
|
|
header += badge(scope_label, "bold black on #64748b")
|
|
header.append(" Risk: ", style="dim")
|
|
risk_style = _RISK_STYLES.get(risk, "bold #f59e0b")
|
|
header.append(f"\u25a0 {risk.upper()}", style=risk_style)
|
|
|
|
parts: list[RenderableType] = [header]
|
|
|
|
if summary:
|
|
parts.append(Text(f"\n{truncate_text(summary, 280)}", style="white"))
|
|
if rationale:
|
|
parts.append(Text(f"\n{truncate_text(rationale, 200)}", style="dim italic"))
|
|
|
|
if role_changes:
|
|
changes_text = Text("\nRole Changes:", style="bold #cbd5e1")
|
|
for rc in role_changes[:8]:
|
|
action = rc.get("action", "?")
|
|
prefix, style = _CHANGE_ACTION_STYLES.get(action, ("? ", "bold white"))
|
|
changes_text.append(f"\n {prefix}", style=style)
|
|
changes_text.append(rc.get("role_id", "?"), style="bold white")
|
|
replacement = rc.get("replacement_role_id", "")
|
|
if replacement:
|
|
changes_text.append(f" \u2192 {replacement}", style="dim")
|
|
reason = rc.get("reason", "")
|
|
if reason:
|
|
changes_text.append(f" {truncate_text(reason, 50)}", style="dim italic")
|
|
parts.append(changes_text)
|
|
|
|
if impact:
|
|
impact_text = Text("\nImpact: ", style="dim")
|
|
impact_parts = []
|
|
if impact.get("affected_roles") is not None:
|
|
impact_parts.append(f"{impact['affected_roles']} roles")
|
|
if impact.get("affected_tasks") is not None:
|
|
impact_parts.append(f"{impact['affected_tasks']} tasks")
|
|
if impact.get("migration_count") is not None:
|
|
impact_parts.append(f"{impact['migration_count']} migrations")
|
|
if impact_parts:
|
|
impact_text.append(", ".join(impact_parts), style="white")
|
|
parts.append(impact_text)
|
|
|
|
parts.append(Text("\n[a] Approve [d] Deny [e] Feedback", style="dim italic"))
|
|
|
|
return Panel(Group(*parts), title=f"Checkpoint: {title}", border_style="#fbbf24")
|
|
|
|
# ----- Escalation -----
|
|
|
|
def _render_escalation_checkpoint(self, checkpoint: PendingCheckpointView) -> RenderableType:
|
|
payload = checkpoint.payload
|
|
prompt = payload.get("prompt", "") or payload.get("summary", "") or checkpoint.prompt
|
|
escalation_type = payload.get("escalation_type", "decision_needed")
|
|
options = payload.get("options", [])
|
|
default_action = payload.get("default_action", "")
|
|
|
|
lines = [line.strip() for line in prompt.split("\n") if line.strip()]
|
|
title = (lines[0].lstrip("[").split("]", 1)[-1].strip() if lines else "Action Required") or "Action Required"
|
|
detail_lines = lines[1:] if len(lines) > 1 else []
|
|
|
|
header = Text()
|
|
header.append("ESCALATION", style="bold #fbbf24")
|
|
header.append(" ")
|
|
type_label = escalation_type.replace("_", " ")
|
|
header += badge(type_label, "bold black on #64748b")
|
|
|
|
parts: list[RenderableType] = [header]
|
|
|
|
if detail_lines:
|
|
body = Text()
|
|
for line in detail_lines[:10]:
|
|
body.append(f"\n{truncate_text(line, 200)}", style="white")
|
|
parts.append(body)
|
|
elif not detail_lines and title != "Action Required":
|
|
parts.append(Text(f"\n{truncate_text(title, 200)}", style="white"))
|
|
|
|
if options:
|
|
opts_text = Text("\nOptions:", style="bold #cbd5e1")
|
|
for opt in options:
|
|
opt_id = opt.get("id", "") if isinstance(opt, dict) else str(opt)
|
|
opt_label = opt.get("label", opt_id) if isinstance(opt, dict) else str(opt)
|
|
opts_text.append(f"\n \u25c6 {opt_label}", style="white")
|
|
parts.append(opts_text)
|
|
|
|
if default_action:
|
|
parts.append(Text(f"\nDefault on timeout: {default_action}", style="dim italic"))
|
|
|
|
parts.append(Text("\n[a] Approve [d] Deny [e] Feedback", style="dim italic"))
|
|
|
|
return Panel(Group(*parts), title=f"Checkpoint: {title[:40]}", border_style="#fbbf24")
|
|
|
|
# ----- Generic fallback -----
|
|
|
|
def _render_generic_checkpoint(self, checkpoint: PendingCheckpointView) -> RenderableType:
|
|
section = Text()
|
|
section.append(checkpoint.checkpoint_type.upper(), style="bold #fbbf24")
|
|
section.append(" ")
|
|
section += badge("PENDING", status_style("warn"))
|
|
if checkpoint.summary:
|
|
section.append(f"\n{truncate_text(checkpoint.summary, 120)}", style="white")
|
|
section.append(f"\n{truncate_text(checkpoint.prompt, 420)}", style="dim")
|
|
section.append("\n[a] Approve [d] Deny [e] Feedback", style="dim italic")
|
|
return Panel(section, title="Checkpoint", border_style="#fbbf24")
|
|
|
|
# ----- Shared renderers -----
|
|
|
|
def _render_linked(self) -> Text:
|
|
title = "Linked Execution Turns" if self.state.snapshot.mode == "company" else "Linked Executions"
|
|
linked_text = Text(f"\n{title}\n", style="bold #cbd5e1")
|
|
for linked in self.detail.linked_executions[:8]:
|
|
linked_text.append(f"\u2022 {truncate_text(linked.title, 40)}", style="white")
|
|
linked_text.append(f" {linked.status}", style=status_style(linked.status))
|
|
linked_text.append(f" {humanize_age(linked.updated_at)}", style="dim")
|
|
if linked.assigned_to:
|
|
linked_text.append(f" {truncate_text(linked.assigned_to, 16)}", style="dim")
|
|
linked_text.append("\n")
|
|
return linked_text
|
|
|
|
@staticmethod
|
|
def _render_section(title: str, body: str, limit: int) -> Text:
|
|
section = Text(f"\n{title}\n", style="bold #cbd5e1")
|
|
section.append(truncate_text(body, limit), style="white")
|
|
return section
|