[aidan] feat/workflows: launch-time scheduling UX and workflow-card polish (#101)

* [aidan] feat/schedule-list: lazy-load list view via scroll sentinel

* [aidan] feat/missed-runs: launch toast with per-workflow counts and pan-to-card

* [aidan] fix/dashboard-tethers: keep watching line anchored on canvas zoom

* [aidan] feat/scheduled-tasks: review missed runs at launch instead of auto-firing on_missed

* [aidan] refactor/workflow-cards: use radius and status design tokens, polish card chrome

* [aidan] ux/agent-card: keep convert-to-workflow visible during runs with mid-turn toast

* [aidan] ux/mcp-bubble: drop redundant verb label when a workflow label is shown

* [aidan] chore/backend: remove stale explanatory comments

* [aidan] fix/workflows-hub: load workflows on hub mount so calendar fills at launch

* [aidan] feat/workflows: generate title, description, step labels at convert time

* [aidan] fix/tidy-layout: include workflows hub in tidy and fit-to-view

* [aidan] feat/schedule-list: window long list via measured-height virtualizer

* [aidan] ux/workflows-hub: remove time-saved badge from calendar header

* [aidan] fix/types: add missing semantic-type labels and drop stray fade arg
This commit is contained in:
Aidan
2026-06-19 20:10:34 -07:00
committed by GitHub
parent 7daa8d573b
commit 6229463ce4
36 changed files with 1463 additions and 331 deletions
+91 -16
View File
@@ -14,6 +14,9 @@ from backend.apps.workflows.models import (
WorkflowRun,
WorkflowStep,
DraftCommitBody,
MissedRunAction,
GenerateMetadataRequest,
GenerateMetadataResponse,
)
from backend.apps.workflows import storage, scheduler, executor, audit, escalation
@@ -264,22 +267,25 @@ async def create_workflow(body: WorkflowCreate):
# leaving stale session names ("Inbox check") as titles. Step labels
# are the 3-6 word at-a-glance headlines surfaced in StepList; without
# them the UI falls back to truncated raw prompts.
try:
title, description, labels = await _generate_workflow_metadata(wf)
# Respect a user-supplied title (auto_named=False); only auto-fill the
# name + description while the workflow is still auto-named. Labels are
# always safe to fill since they don't override a user's title.
if wf.auto_named:
if title:
wf.title = title
if description:
wf.description = description
if labels and len(labels) == len(wf.steps):
for i, lab in enumerate(labels):
if lab:
wf.steps[i].label = lab
except Exception:
pass
# When the FE already generated metadata at preview time it ships the title,
# description, and per-step labels on the body, so we skip the aux call here.
if not body.metadata_generated:
try:
title, description, labels = await _generate_workflow_metadata(wf)
# Respect a user-supplied title (auto_named=False); only auto-fill the
# name + description while the workflow is still auto-named. Labels are
# always safe to fill since they don't override a user's title.
if wf.auto_named:
if title:
wf.title = title
if description:
wf.description = description
if labels and len(labels) == len(wf.steps):
for i, lab in enumerate(labels):
if lab:
wf.steps[i].label = lab
except Exception:
pass
storage.save_workflow(wf)
scheduler.kick()
enriched = _enriched(wf)
@@ -294,6 +300,15 @@ async def create_workflow(body: WorkflowCreate):
return enriched
@workflows.router.post("/generate-metadata")
async def generate_workflow_metadata(body: GenerateMetadataRequest) -> GenerateMetadataResponse:
# Preview-time naming for the convert-to-workflow draft. Generates without
# persisting so the card can show a real title before the user saves.
wf = Workflow(steps=body.steps, model=body.model or "sonnet")
title, description, labels = await _generate_workflow_metadata(wf)
return GenerateMetadataResponse(title=title, description=description, step_labels=labels)
async def _generate_workflow_metadata(wf: Workflow) -> tuple[str, str, list[str]]:
"""Single aux-model call returning (title, description, step_labels).
@@ -593,6 +608,66 @@ async def list_all_runs(limit: int = 200):
return {"runs": [r.model_dump(mode="json") for r in runs]}
@workflows.router.get("/missed")
async def list_missed_runs(limit: int = 50):
"""Pending fires that elapsed while the app was closed, newest-first.
Backs the launch-time review card."""
missed = sorted(storage.list_missed(), key=lambda m: m.scheduled_for, reverse=True)
out: list[dict] = []
for m in missed[:limit]:
wf = storage.get_workflow(m.workflow_id)
if not wf:
continue
out.append({
"id": m.id,
"workflow_id": m.workflow_id,
"workflow_title": wf.title,
"workflow_icon": wf.icon,
"scheduled_for": m.scheduled_for.isoformat() if isinstance(m.scheduled_for, datetime) else m.scheduled_for,
})
return {"missed": out}
@workflows.router.post("/missed/run")
async def run_missed_runs(body: MissedRunAction):
"""Run the selected missed fires now. Each lands in History as ran_late.
Fires of the same workflow run sequentially (the executor blocks
concurrent runs of one workflow)."""
wanted = set(body.ids)
selected = [m for m in storage.list_missed() if m.id in wanted]
if not selected:
return {"started": 0}
storage.remove_missed([m.id for m in selected])
by_wf: dict[str, list[datetime]] = {}
for m in sorted(selected, key=lambda m: m.scheduled_for):
by_wf.setdefault(m.workflow_id, []).append(m.scheduled_for)
started = 0
for wid, fors in by_wf.items():
wf = storage.get_workflow(wid)
if not wf:
continue
started += len(fors)
asyncio.create_task(scheduler.run_missed_sequence(wf, fors))
return {"started": started}
@workflows.router.post("/missed/dismiss")
async def dismiss_missed_runs(body: MissedRunAction):
"""Drop the selected missed fires, logging each as a skipped run so the
workflow's history still shows it happened."""
wanted = set(body.ids)
selected = [m for m in storage.list_missed() if m.id in wanted]
storage.remove_missed([m.id for m in selected])
dismissed = 0
for m in selected:
wf = storage.get_workflow(m.workflow_id)
if not wf:
continue
scheduler.record_skipped(wf, m.scheduled_for, "You dismissed this missed run")
dismissed += 1
return {"dismissed": dismissed}
@workflows.router.get("/calendar")
async def list_calendar_events(
from_: str = Query(..., alias="from"),