researcher / research /app_ui.py
evalstate's picture
evalstate HF Staff
Poll research status outside ChatGPT broker
9620305 verified
Raw
History Blame Contribute Delete
34.9 kB
"""Prefab UI for a research job."""
from __future__ import annotations
from copy import deepcopy
from typing import Any
from prefab_ui.actions import (
CloseOverlay,
Fetch,
OpenLink,
SetInterval,
SetState,
ShowToast,
)
from prefab_ui.actions.mcp import CallTool, SendMessage, UpdateContext
from prefab_ui.app import PrefabApp
from prefab_ui.components import (
Badge,
Button,
Column,
Dialog,
Div,
Heading,
If,
Image,
Markdown,
Row,
Separator,
Text,
)
from prefab_ui.components.control_flow import ForEach
from prefab_ui.rx import RESULT, STATE
from .hf_design import (
HF_DESIGN_CSS,
HF_FONT_STYLESHEET,
HF_LOGO_DATA_URI,
HFDesign,
design_css_class,
validate_design,
)
from .report_preview import markdown_only_preview
BROADSHEET_CSS = """
.dispatch-app {
--dispatch-accent: color-mix(in oklab, var(--warning) 68%, var(--foreground));
--dispatch-live: color-mix(in oklab, var(--success) 76%, #4a8a63);
--dispatch-panel: color-mix(in oklab, var(--card) 76%, var(--muted));
width: 100%;
min-height: 760px;
display: flex;
justify-content: center;
background: var(--muted);
color: var(--foreground);
}
.dispatch-sheet {
width: min(100%, 800px);
height: 760px;
overflow: hidden;
display: flex;
flex-direction: column;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--background);
box-shadow: 0 3px 12px color-mix(in oklab, var(--foreground) 10%, transparent);
}
.dispatch-header {
flex: none;
padding: 22px 36px 14px;
}
.dispatch-kicker,
.dispatch-section-label,
.dispatch-time,
.dispatch-source,
.dispatch-meta,
.dispatch-log-line,
.dispatch-query-toggle,
.dispatch-status {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
.dispatch-topbar {
min-width: 0;
align-items: center;
}
.dispatch-kicker {
flex: none;
color: var(--dispatch-accent);
font-size: 11px;
letter-spacing: .14em;
text-transform: uppercase;
}
.dispatch-status-controls {
min-width: 0;
margin-left: 10px;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
}
.dispatch-stats {
min-width: 0;
margin-left: auto;
align-items: center;
color: var(--muted-foreground);
}
.dispatch-stat {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 11px;
font-variant-numeric: tabular-nums;
letter-spacing: .035em;
text-transform: uppercase;
white-space: nowrap;
}
.dispatch-status {
min-height: 25px;
padding: 4px 11px;
border-radius: 3px;
font-size: 11px;
font-weight: 500;
letter-spacing: .03em;
}
.dispatch-status-running {
background: color-mix(in oklab, var(--warning) 17%, var(--background));
color: color-mix(in oklab, var(--warning) 46%, var(--foreground));
}
.dispatch-status-complete {
background: color-mix(in oklab, var(--warning) 30%, var(--background));
color: color-mix(in oklab, var(--warning) 28%, var(--foreground));
}
.dispatch-status-failed {
border: 1px solid var(--muted-foreground);
color: var(--muted-foreground);
}
.dispatch-status-cancelled {
background: var(--muted);
color: var(--muted-foreground);
}
.dispatch-status-dot {
width: 7px;
height: 7px;
border-radius: 999px;
background: var(--dispatch-live);
animation: dispatch-pulse 1.4s ease-in-out infinite;
}
.dispatch-header-separator {
margin: 14px 0 16px;
}
.dispatch-query-row {
min-width: 0;
align-items: baseline;
}
.dispatch-section-label {
flex: none;
color: var(--muted-foreground);
font-size: 10px;
letter-spacing: .14em;
text-transform: uppercase;
}
.dispatch-query-wrap {
min-width: 0;
flex: 1;
}
.dispatch-headline {
margin: 0;
font-family: ui-serif, Georgia, Cambria, "Times New Roman", serif;
font-size: clamp(26px, 4vw, 34px);
font-weight: 500;
line-height: 1.12;
letter-spacing: -.02em;
text-wrap: balance;
}
.dispatch-original-label {
display: block;
margin-top: 16px;
}
.dispatch-query {
margin: 6px 0 0;
font-family: ui-serif, Georgia, Cambria, "Times New Roman", serif;
color: var(--muted-foreground);
font-size: 15px;
font-weight: 400;
line-height: 1.45;
letter-spacing: 0;
text-wrap: pretty;
}
.dispatch-query-clamped {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.dispatch-query-toggle {
height: auto;
min-height: 0;
margin-top: 6px;
padding: 0;
color: var(--dispatch-accent);
font-size: 11px;
letter-spacing: .06em;
}
.dispatch-body {
flex: 1;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
padding: 0 36px;
scrollbar-color: var(--muted-foreground) transparent;
scrollbar-width: thin;
}
.dispatch-body::-webkit-scrollbar {
width: 8px;
}
.dispatch-body::-webkit-scrollbar-thumb {
border-radius: 4px;
background: var(--muted-foreground);
}
.dispatch-report-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
margin: 12px 0 22px;
}
.dispatch-report-action {
width: 100%;
color: var(--dispatch-accent);
}
.dispatch-markdown-report {
margin-top: 16px;
padding: 22px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--card);
}
.dispatch-markdown-body {
margin-top: 14px;
color: var(--foreground);
font-size: 14px;
line-height: 1.65;
}
.dispatch-markdown-body > :first-child {
margin-top: 0;
}
.dispatch-markdown-body > :last-child {
margin-bottom: 0;
}
.dispatch-markdown-image {
display: block;
max-width: 100%;
height: auto;
margin: 18px auto;
border-radius: 8px;
}
.dispatch-log {
padding: 12px 16px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--dispatch-panel);
}
.dispatch-log-body {
margin-top: 24px;
}
.dispatch-log-footer {
margin: 0;
padding-bottom: 0;
border: 0;
background: transparent;
}
.dispatch-log-line {
min-width: 0;
align-items: baseline;
color: var(--muted-foreground);
font-size: 12px;
line-height: 1.35;
}
.dispatch-log-line + .dispatch-log-line {
margin-top: 5px;
}
.dispatch-log-message {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dispatch-current-block {
margin-top: 22px;
}
.dispatch-current-meta {
align-items: baseline;
}
.dispatch-time {
flex: none;
color: var(--muted-foreground);
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.dispatch-source {
min-width: 0;
overflow: hidden;
color: var(--dispatch-accent);
font-size: 11px;
letter-spacing: .02em;
text-overflow: ellipsis;
white-space: nowrap;
}
.dispatch-current {
margin-top: 9px;
align-items: flex-start;
}
.dispatch-current-copy {
max-width: 60ch;
font-family: ui-serif, Georgia, Cambria, "Times New Roman", serif;
font-size: 22px;
line-height: 1.34;
letter-spacing: -.005em;
text-wrap: pretty;
}
.dispatch-current-copy > :first-child,
.dispatch-event-message > :first-child {
margin-top: 0;
}
.dispatch-current-copy > :last-child,
.dispatch-event-message > :last-child {
margin-bottom: 0;
}
.dispatch-live-dot {
position: relative;
width: 11px;
height: 11px;
flex: none;
margin-top: 8px;
border-radius: 999px;
background: var(--dispatch-live);
animation: dispatch-pulse 1.4s ease-in-out infinite;
}
.dispatch-live-dot::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background: inherit;
animation: dispatch-ring 1.6s ease-out infinite;
}
.dispatch-rule {
height: 2px;
margin-top: 16px;
overflow: hidden;
border-radius: 2px;
background: var(--border);
}
.dispatch-rule-running::after {
content: "";
display: block;
width: 40%;
height: 100%;
background: var(--dispatch-accent);
animation: dispatch-progress 1.5s ease-in-out infinite;
}
.dispatch-rule-completed {
background: var(--dispatch-accent);
}
.dispatch-rule-failed {
height: 0;
border-top: 2px dashed var(--muted-foreground);
border-radius: 0;
background: transparent;
}
.dispatch-rule-cancelled {
background: color-mix(in oklab, var(--muted-foreground) 62%, transparent);
}
.dispatch-history {
margin-top: 26px;
padding-bottom: 26px;
}
.dispatch-event {
min-width: 0;
padding: 12px 0;
border-top: 1px solid var(--border);
opacity: .72;
}
.dispatch-event-copy {
min-width: 0;
}
.dispatch-event-message {
margin-top: 3px;
font-family: ui-serif, Georgia, Cambria, "Times New Roman", serif;
font-size: 15px;
line-height: 1.5;
text-wrap: pretty;
}
.dispatch-read-confirmation {
margin: -12px 0 18px;
color: var(--muted-foreground);
font-size: 11px;
}
.dispatch-footer {
flex: none;
border-top: 1px solid var(--border);
}
.dispatch-footer-log {
padding: 8px 36px 0;
}
.dispatch-log-toggle {
min-height: 24px;
align-items: center;
}
.dispatch-log-toggle-button {
height: auto;
min-height: 0;
margin-left: auto;
padding: 2px 0;
color: var(--muted-foreground);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 10px;
letter-spacing: .04em;
}
.dispatch-session {
min-width: 0;
padding: 12px 36px;
align-items: center;
}
.dispatch-meta {
flex: none;
color: var(--muted-foreground);
font-size: 11px;
letter-spacing: .1em;
text-transform: uppercase;
}
.dispatch-trace {
min-width: 0;
overflow: hidden;
color: var(--muted-foreground);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.dispatch-build {
flex: none;
margin-left: auto;
color: var(--muted-foreground);
font-size: 9px;
opacity: .6;
}
.dispatch-archive-link {
height: auto;
min-height: 0;
margin-left: auto;
padding: 0;
color: var(--muted-foreground);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 10px;
}
.dispatch-archive-link + .dispatch-build {
margin-left: 8px;
}
.dispatch-dialog-actions {
margin-top: 8px;
justify-content: flex-end;
}
@keyframes dispatch-progress {
from { transform: translateX(-110%); }
to { transform: translateX(250%); }
}
@keyframes dispatch-ring {
from { transform: scale(1); opacity: .45; }
to { transform: scale(2.6); opacity: 0; }
}
@keyframes dispatch-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: .4; transform: scale(.78); }
}
@media (max-width: 720px) {
.dispatch-sheet {
border-right: 0;
border-left: 0;
border-radius: 0;
box-shadow: none;
}
.dispatch-header {
padding: 18px 20px 12px;
}
.dispatch-query-row {
align-items: flex-start;
}
.dispatch-query-row {
flex-direction: column;
}
.dispatch-query {
margin-top: 8px;
font-size: 14px;
}
.dispatch-body {
padding: 0 20px;
}
.dispatch-log-footer {
margin: 0;
}
.dispatch-footer-log {
padding-right: 20px;
padding-left: 20px;
}
.dispatch-session {
padding: 12px 20px;
}
.dispatch-event {
gap: 12px;
}
.dispatch-build {
display: none;
}
}
@media (max-width: 560px) {
.dispatch-topbar {
align-items: flex-start;
flex-wrap: wrap;
}
.dispatch-stats {
order: 3;
width: 100%;
margin: 9px 0 0;
}
.dispatch-status-controls {
margin-left: auto;
}
}
@media (max-width: 460px) {
.dispatch-report-actions {
grid-template-columns: 1fr;
}
.dispatch-status-controls {
gap: 8px;
}
.dispatch-stats { justify-content: space-between; }
.dispatch-current-copy {
font-size: 20px;
}
}
@media (prefers-reduced-motion: reduce) {
.dispatch-live-dot,
.dispatch-live-dot::after,
.dispatch-status-dot,
.dispatch-rule-running::after {
animation: none;
}
}
"""
def _display_source(source: str | None) -> str:
return (source or "researcher/agent_loop").replace("/", " / ")
def _ui_snapshot(snapshot: dict[str, Any]) -> dict[str, Any]:
"""Add display-only fields without mutating the retained job snapshot."""
prepared = deepcopy(snapshot)
prepared.setdefault("headline", "Briefing the researcher")
prepared.setdefault("workspace_id", None)
prepared.setdefault(
"markdown_report_ready",
bool(prepared.get("markdown_report_uri") or prepared.get("markdown_report")),
)
prepared.setdefault(
"activity_source_label", _display_source(prepared.get("activity_source"))
)
for summary in prepared.get("recent_summaries", []):
summary.setdefault("source_label", _display_source(summary.get("source")))
return prepared
def build_research_ui(
topic: str,
snapshot: dict[str, Any],
*,
build_id: str = "dev",
live: bool = True,
design: HFDesign | None = None,
status_url: str | None = None,
recovery_url: str | None = None,
) -> PrefabApp:
snapshot = _ui_snapshot(snapshot)
if design is not None:
design = validate_design(design)
on_mount = None
if live:
report_is_current = RESULT.markdown_report_revision == STATE.report_revision
poll_status = CallTool(
"research_status",
arguments={"job_id": STATE.job_id},
on_success=[
SetState(
"report_loaded",
report_is_current.then(STATE.report_loaded, False),
),
SetState("job", RESULT),
],
)
poll_while = ~STATE.job.done
if status_url is not None:
poll_status = Fetch(
STATE.status_url,
on_success=[
SetState(
"report_loaded",
report_is_current.then(STATE.report_loaded, False),
),
SetState("job", RESULT),
SetState("report_blocks", RESULT.report_blocks),
SetState("report_loaded", RESULT.report_ready),
SetState(
"report_revision",
RESULT.markdown_report_revision,
),
],
on_error=[
SetState("status_unavailable", True),
ShowToast(
"Live status is unavailable",
description=(
"The research may still be running. "
"Reconnect the app to restore updates."
),
variant="warning",
),
],
)
poll_while = (
(STATE.status_url != "")
& ~STATE.job.done
& ~STATE.status_unavailable
)
on_mount = SetInterval(
duration=4000,
while_=poll_while,
on_tick=poll_status,
)
cancel_action = CloseOverlay()
if live:
cancel_action = CallTool(
"cancel_research",
arguments={"job_id": STATE.job_id},
on_success=[
SetState("job", RESULT),
SetState("cancel_requested", False),
CloseOverlay(),
ShowToast(
"Cancellation requested",
description="The active research session is being closed.",
variant="warning",
),
],
on_error=[
SetState("cancel_requested", False),
ShowToast(
"Could not cancel research",
description="The job may already have finished.",
variant="error",
),
],
)
read_report = CallTool(
"research_chat_context",
arguments={"job_id": STATE.job_id},
on_success=[
UpdateContext(content=RESULT.markdown),
SendMessage(
RESULT.message,
on_success=SetState("chat_sent", True),
on_error=ShowToast(
"This host could not send the chat message.",
variant="error",
),
),
],
on_error=ShowToast(
"Could not load the Markdown report.",
variant="error",
),
)
if not live:
read_report = SetState("chat_sent", True)
load_report = CallTool(
"research_report_preview",
arguments={"job_id": STATE.job_id},
on_success=[
SetState(
"report_blocks",
(RESULT.revision == STATE.job.markdown_report_revision).then(
RESULT.blocks,
STATE.report_blocks,
),
),
SetState(
"report_loaded",
RESULT.revision == STATE.job.markdown_report_revision,
),
SetState(
"report_revision",
(RESULT.revision == STATE.job.markdown_report_revision).then(
RESULT.revision,
STATE.report_revision,
),
),
],
on_error=[
SetState("report_loaded", True),
SetState(
"report_revision",
STATE.job.markdown_report_revision,
),
ShowToast(
"Could not load the in-app report preview.",
description="The Markdown and HTML artifacts remain available.",
variant="error",
),
],
)
css_class = "dispatch-app"
css = [BROADSHEET_CSS]
stylesheets = None
if design is not None:
css_class = design_css_class(design)
css.append(HF_DESIGN_CSS)
stylesheets = [HF_FONT_STYLESHEET]
with PrefabApp(
title="Research Dispatch",
css_class=css_class,
css=css,
stylesheets=stylesheets,
state={
"job": snapshot,
"report_blocks": (
snapshot.get("markdown_report_blocks")
or (
markdown_only_preview(snapshot["markdown_report"])
if snapshot.get("markdown_report")
else []
)
),
"report_loaded": bool(
snapshot.get("markdown_report_blocks")
or snapshot.get("markdown_report")
),
"report_revision": snapshot.get("markdown_report_revision", 0),
"topic": topic,
"job_id": snapshot["job_id"],
"cancel_requested": False,
"query_expanded": False,
"query_toggleable": len(topic) > 90,
"event_log_expanded": False,
"chat_sent": False,
"app_version": f"build {build_id}",
"status_url": status_url or "",
"recovery_url": recovery_url or "",
"status_unavailable": False,
},
) as ui:
with Div(css_class="dispatch-sheet", on_mount=on_mount):
with Column(css_class="dispatch-header", gap=0):
with Row(css_class="dispatch-topbar", gap=4):
if design is None:
Text("Research Dispatch", css_class="dispatch-kicker")
else:
with Row(css_class="hf-brand", gap=2):
Image(
src=HF_LOGO_DATA_URI,
alt="Hugging Face",
css_class="hf-brand-logo",
)
with Column(css_class="hf-brand-copy", gap=0):
Text("Hugging Face", css_class="hf-brand-name")
Text(
"Researcher",
css_class="hf-brand-product",
)
with Row(css_class="dispatch-stats", gap=2):
Text(STATE.job.elapsed, css_class="dispatch-stat")
Text("·", css_class="dispatch-stat")
Text(
"{{ job.event_count + ' events' }}",
css_class="dispatch-stat",
)
Text("·", css_class="dispatch-stat")
Text(
"{{ job.turn_count + ' turns' }}",
css_class="dispatch-stat",
)
with Row(css_class="dispatch-status-controls", gap=3):
with If(
(STATE.job.status == "queued")
| (STATE.job.status == "running")
| (STATE.job.status == "finalizing")
| (STATE.job.status == "cancelling")
):
with Badge(
css_class=("dispatch-status dispatch-status-running")
):
Div(css_class="dispatch-status-dot")
Text(
"{{ job.status == 'cancelling' ? "
"'Cancelling' : job.status == 'finalizing' ? "
"'Finalizing' : job.phase == 'reporting' ? "
"'Building report' : job.phase == 'wrapping_up' ? "
"'Wrapping up' : 'Working' }}"
)
with If(STATE.job.status == "completed"):
Badge(
"Complete",
css_class=("dispatch-status dispatch-status-complete"),
)
with If(STATE.job.status == "failed"):
Badge(
"Failed",
variant="outline",
css_class=("dispatch-status dispatch-status-failed"),
)
with If(
(STATE.job.status == "cancelled")
| (STATE.job.status == "expired")
):
Badge(
"{{ job.status == 'expired' ? "
"'Unavailable' : 'Cancelled' }}",
css_class=("dispatch-status dispatch-status-cancelled"),
)
with If(STATE.job.cancellable):
with Dialog(
title="Cancel this research run?",
description=(
"The agent will stop where it is. Partial "
"work and the session trace collected so far "
"will be kept, but no final report will be "
"produced."
),
):
Button("Cancel", variant="outline", size="sm")
with Row(css_class="dispatch-dialog-actions", gap=2):
Button(
"Keep running",
variant="outline",
onClick=CloseOverlay(),
)
Button(
"Cancel research",
variant="destructive",
disabled=STATE.cancel_requested,
onClick=[
SetState("cancel_requested", True),
cancel_action,
],
)
Separator(css_class="dispatch-header-separator")
with Div(css_class="dispatch-query-wrap"):
Text("Research brief", css_class="dispatch-section-label")
Heading(
STATE.job.headline,
level=1,
css_class="dispatch-headline",
)
Text(
"Original query",
css_class=("dispatch-section-label dispatch-original-label"),
)
with Div():
with If(STATE.query_toggleable):
with If(~STATE.query_expanded):
Heading(
STATE.topic,
level=1,
css_class="dispatch-query dispatch-query-clamped",
)
Button(
"Show full query",
variant="link",
size="xs",
css_class="dispatch-query-toggle",
onClick=SetState("query_expanded", True),
)
with If(STATE.query_expanded):
Heading(
STATE.topic,
level=1,
css_class="dispatch-query",
)
Button(
"Show less",
variant="link",
size="xs",
css_class="dispatch-query-toggle",
onClick=SetState("query_expanded", False),
)
with If(~STATE.query_toggleable):
Heading(
STATE.topic,
level=1,
css_class="dispatch-query",
)
with Div(css_class="dispatch-body"):
with If(
STATE.job.markdown_report_ready
| (STATE.job.phase == "reporting")
| (STATE.job.phase == "wrapping_up")
| (STATE.job.status == "finalizing")
| (STATE.job.status == "completed")
):
with If(STATE.job.markdown_report_ready & ~STATE.report_loaded):
Div(on_mount=load_report)
with If(STATE.report_loaded):
with Div(css_class="dispatch-markdown-report"):
Text(
"Markdown report",
css_class="dispatch-section-label",
)
with ForEach("report_blocks") as block:
with If(block.kind == "markdown"):
Markdown(
block.content,
css_class="dispatch-markdown-body",
)
with If(block.kind == "image"):
Image(
src=block.src,
alt=block.alt,
width="100%",
height="auto",
css_class="dispatch-markdown-image",
)
with Div(css_class="dispatch-report-actions"):
Button(
"{{ job.markdown_report_ready ? 'Add to chat' : "
"'Preparing Markdown…' }}",
variant="outline",
css_class="dispatch-report-action",
disabled=(
STATE.chat_sent | ~STATE.job.markdown_report_ready
),
onClick=read_report,
)
Button(
"{{ job.html_report_ready ? 'Open HTML report' : "
"job.done ? 'HTML report unavailable' : "
"'Building HTML report…' }}",
variant="outline",
css_class="dispatch-report-action",
disabled=~STATE.job.html_report_ready,
onClick=OpenLink(STATE.job.html_report_url),
)
with If(STATE.chat_sent):
Text(
"Added to chat.",
css_class="dispatch-read-confirmation",
)
with If("{{ job.done && job.activity_roll.length > 0 }}"):
with Div(css_class="dispatch-log dispatch-log-body"):
with ForEach("job.activity_roll") as event:
with Row(css_class="dispatch-log-line", gap=3):
Text(event.elapsed, css_class="dispatch-time")
Text(
event.message,
css_class="dispatch-log-message",
)
with Div(css_class="dispatch-current-block"):
with Row(css_class="dispatch-current-meta", gap=3):
Text(STATE.job.elapsed, css_class="dispatch-time")
Text(
STATE.job.activity_source_label,
css_class="dispatch-source",
)
with Row(css_class="dispatch-current", gap=3):
with If("{{ !job.done }}"):
Div(css_class="dispatch-live-dot")
Markdown(
STATE.job.activity_summary,
css_class="dispatch-current-copy",
)
with If("{{ !job.done }}"):
Div(css_class="dispatch-rule dispatch-rule-running")
with If(STATE.job.status == "completed"):
Div(css_class="dispatch-rule dispatch-rule-completed")
with If(STATE.job.status == "failed"):
Div(css_class="dispatch-rule dispatch-rule-failed")
with If(
(STATE.job.status == "cancelled")
| (STATE.job.status == "expired")
):
Div(css_class="dispatch-rule dispatch-rule-cancelled")
with If("{{ job.recent_summaries.length > 0 }}"):
with Div(css_class="dispatch-history"):
Text("Before this", css_class="dispatch-section-label")
with ForEach("job.recent_summaries") as event:
with Row(css_class="dispatch-event", gap=4):
Text(event.elapsed, css_class="dispatch-time")
with Column(css_class="dispatch-event-copy", gap=0):
Text(
event.source_label,
css_class="dispatch-source",
)
Markdown(
event.message,
css_class="dispatch-event-message",
)
with Div(css_class="dispatch-footer"):
with If("{{ !job.done && job.activity_roll.length > 0 }}"):
with Div(css_class="dispatch-footer-log"):
with Row(css_class="dispatch-log-toggle"):
Text("Agent events", css_class="dispatch-meta")
with If(STATE.event_log_expanded):
Button(
"Hide log",
icon="chevron-down",
variant="link",
size="xs",
css_class="dispatch-log-toggle-button",
onClick=SetState("event_log_expanded", False),
)
with If(~STATE.event_log_expanded):
Button(
"Show log",
icon="chevron-up",
variant="link",
size="xs",
css_class="dispatch-log-toggle-button",
onClick=SetState("event_log_expanded", True),
)
with If(STATE.event_log_expanded):
with Div(css_class="dispatch-log dispatch-log-footer"):
with ForEach("job.activity_roll") as event:
with Row(css_class="dispatch-log-line", gap=3):
Text(event.elapsed, css_class="dispatch-time")
Text(
event.message,
css_class="dispatch-log-message",
)
with Row(css_class="dispatch-session", gap=3):
Text("Session", css_class="dispatch-meta")
Text(
"{{ job.trace_path || 'Trace pending' }}",
css_class="dispatch-trace",
)
with If(STATE.job.archive_space_url):
Button(
"Archive ↗",
variant="link",
size="xs",
css_class="dispatch-archive-link",
onClick=OpenLink(STATE.job.archive_space_url),
)
Text(STATE.app_version, css_class="dispatch-build")
return ui