Skip to content

feat: Home becomes the workspace dashboard - #556

Merged
JArmandoAnaya merged 5 commits into
mainfrom
feat/workspace-home
Aug 12, 2026
Merged

feat: Home becomes the workspace dashboard#556
JArmandoAnaya merged 5 commits into
mainfrom
feat/workspace-home

Conversation

@JArmandoAnaya

Copy link
Copy Markdown
Contributor

Home stops redirecting to the project list and becomes the workspace dashboard. The route
table's own comment had named the condition — there is nothing else a workspace's front page
could honestly be until a dashboard has numbers to show
— and this supplies the numbers.

What shipped

Kernel. A new SummaryService whose scope is the whole workspace, composing a projection
out of reads that already existed: totals, the batch to carry on with, what is waiting, and a
feed derived from timestamps that are already stored. It writes nothing and caches nothing, on
the terms ProjectStats and DatasetStats already set. One narrow port method,
UnitOfWork.annotation_totals, is the method ProjectService.stats and
JobService.project_progress had both already named in their docstrings as the remedy for
counting a project's labels one query per asset — taken now because a caller finally appeared
that walks every project at once.

Wire. GET /home returns the page in one response. It is a projection rather than a
resource: no path parameters, no verb but GET, and no allowed_actions — every row points
at a resource whose own shape declares what may be done to it, and a second copy here would be
the hand-mirrored table the capabilities contract forbids one layer up. openapi.json and the
generated TypeScript client are regenerated, never hand-edited.

Screen. HomeScreen in ui-core, data-only, navigation as optional callbacks. Two columns
at 1fr / 320px: the resume card, what needs attention and recent projects on the left; four
stat cards and the activity feed in the 320px aside.

The populated state

The resume card leads — a thumbnail, the project and batch, 148 / 200 annotated in tabular
figures, and the view's one filled button. Beneath it, Needs your attention: one row per
batch holding frames awaiting review, per background job that failed, and per background job
still running. Then Recent projects, five dense rows with assets · % annotated and an
"All projects →" link in the header. The aside carries Projects / Images / Annotations /
Releases as locale-separated tabular figures, then Activity — icon, one line, relative time,
newest first.

A section with nothing in it is not rendered. Not a placeholder, not a zero, not "nothing
here yet". The stat cards are the deliberate exception, because a count of zero is a
measurement and four cards that came and went would make the aside jump on every visit.

The first-run state

A workspace with no projects gets the whole page replaced by one invitation: "Start your first
project", a line of body, the filled Create project, and three quiet cards naming the cycle
— Ingest, Annotate, Release — with no controls on them. It is recognised by
totals.projects === 0 rather than by a flag, which would be a second spelling of a fact the
response already carries.

That CTA opens the same dialog the project list's button opens; CreateProjectDialog is
exported for it. A filled button labelled Create project that only navigated to the screen
carrying the real one would promise an action it does not perform. ProjectsScreen's behaviour
is otherwise untouched.

The finding, and the decision it forced

The dispatch specified the resume target as the most recently active in_annotation batch.
That is not computable. There is no timestamp column on batch, on annotation, or on
annotation_job_asset — nothing records when a batch was created, when it changed state, when a
label was drawn, or when an asset's progress last moved. Deriving recency would mean a
migration, which the design exists to avoid.

Settled with Armando before implementing, and recorded on the issue: rank by progress
instead.
Home offers the in_annotation batch with the most settled assets that still has at
least one unannotated frame — the batch you are part-way through. It needs no schema change, it
degrades correctly (one open batch is offered whatever its progress), and it claims only what
the rows can support. The accepted limit is that two batches part-way through resolve to the
further-along one rather than the more recent.

Its one visible consequence is the CTA label. With a frame left the control reads Continue
annotating
and opens the editor there; with none left it reads Open batch and goes to the
gallery, because there is no frame to open and a button claiming otherwise would land somewhere
empty.

Worth noting: the project header's Annotate CTA hit the same wall from the other side a day
earlier — BatchOut carries no timestamp either, so "most recent" had no wire source there and
that work asked which batch instead (#544, merged as #545).

Declared deviations from the dispatch

  • Activity's "ingest completed" is a proxy. IngestJob records no times at all, so the row
    is the newest Asset.ingested_at in a project and reads as the last data that arrived rather
    than as one run finishing. The endpoint, the model and docs/api.md all say so; the UI copy
    does not overstate it.
  • "Schema version activated" is version creation. Which version is active is derived — it
    is the highest — so there is no activation event to date.
  • Attention rows for background jobs do not link. The dispatch asked for "links to details".
    There is no background-job detail screen in this build, and a job's payload names an ingest run
    or a release, never a project — so resolving one would need a per-job-type dispatch table that
    goes stale, pointing at a screen that does not show the job. The rows state what happened and
    are rendered as text rather than as dead links, which is DESIGN.md's rule for a section whose
    consuming surface does not exist yet.
  • ResumeTarget.job_id is non-null, after checking: approving an empty batch raises
    EmptyBatch, so an in_annotation batch always has at least one job. A nullable field that can
    never be null costs every consumer a check for a case that cannot happen, so the guard lives in
    the service — a job-less batch is not offered — and the wire stays total.

Verification

Every new rule was mutation-verified after committing the implementation, per the protocol: one
unconditional step per case, anchor asserted before and after, revert by recorded diff, clean
tree checked between cases. Eleven of twelve mutations turned a named test red, and all
twelve reverts landed:

mutation reddened
tie broken the other way (>=>) test_a_tie_goes_to_the_batch_created_later
tier inverted test_a_batch_with_labeling_left_beats_a_finished_one_however_far_ahead
rank inverted ..._furthest_through_wins, ..._wins_across_projects_too
settled narrowed to annotated test_a_skipped_frame_counts_as_dealt_with
landing always null five, including ..._first_unannotated_one_in_batch_order
batch order reversed the same five
review count zeroed test_a_batch_holding_frames_for_review_asks_for_attention
queued jobs included test_a_queued_job_is_not_news
activity cap removed / order reversed test_activity_is_newest_first_and_capped
projects cap removed test_the_project_shortcut_is_capped

The twelfth aborted on a harness artifact rather than running — grep -Fc counts lines, so a
two-line anchor never reports exactly 1. tier-inverted covers that same site and reddened
correctly, so the tier is verified; the harness lesson is that a multi-line anchor needs a
different count.

One defect in my own test was caught this way and is worth recording: a setup step used POST
where the progress route is PUT, so it 405'd silently and the assertion below it was measuring
a frame nobody had sent back. The status is asserted now, with the reason in a comment.

The gate, staged

Run in stages because the harness kills at ~10 minutes. Exit codes verbatim:

pytest tests/kernel tests/server                                   0
pytest tests/architecture tests/cli tests/formats tests/inference tests/jobs   0
pytest tests/mcp tests/packaging tests/scripts tests/examples tests/test_versioning.py   0
ruff check .                                                       0   All checks passed!
ruff format --check .                                              0   380 files already formatted
mypy src/visionset                                                 0   no issues found in 159 source files
lint-imports                                                       0   Contracts: 4 kept, 0 broken
pnpm lint                                                          0
pnpm test:scripts                                                  0   82 pass, 0 fail
pnpm version:check                                                 0
pnpm generate:client:check                                         0   client matches openapi.json
pnpm -r build                                                      0
annotator vitest                                                   0   1005 passed
app e2e (chromium)                                                 0   255 passed
browser cycle (chromium)                                           0   1 passed
ui-core vitest                                                     1   see below

openapi.json re-exported after the fact leaves no diff, so the committed spec matches the app.

The one red step, and why it is not this change

ui-core's vitest suite fails a varying handful of tests under machine load, always
Error: Test timed out in 5000ms. Reproduced on unmodified main at the merge-base
(49a3765), by me, in this environment:

 Test Files  4 failed | 45 passed (49)
      Tests  11 failed | 938 passed (949)

against this branch's worst observed run of 20 and best of 5. Four runs as the machine's load
average fell from ~90 to ~7 produced 41, 19, 11 and 5 failures — the same files each time,
fewer of their tests as the box freed up. Running those files alone passes all 75. The load was
another session, an unrelated ctest run and Spotlight indexing; nothing this task spawned, and
no orphaned process of mine (checked by PPID == 1).

The failures are in schemaDraft.test.tsx, screens.test.tsx, inference.test.tsx,
suggestFlow.test.tsx, drawingClass.test.tsx and addClassProvenance.test.tsx — none of them
files this branch touches. home.test.tsx's 17 tests have passed on every run. Filed as
#555 with the measurements and two candidate fixes; it is a sibling of the Playwright flakes
#511 and #550, one suite over. CI is the arbiter here and it is green on clean runners.

Found, not fixed

Flags for Armando

  • Home always shows exactly one filled button, and the third state needed a decision. First
    run offers Create project and a resumable workspace offers Continue annotating; a workspace
    with projects but nothing open for annotation offers New project, on the reasoning that when
    every batch is finished, starting the next piece of work is what comes next. It is the weakest
    of the three and the one to revisit if it reads wrong in use — the rule is a count tested in
    both directions, so zero filled buttons fails as loudly as two, which is why the slot could not
    simply be left empty.
  • A timestamp column would make the resume card better, and it is now the only thing standing
    between this and the dispatch's original rule. One nullable annotation_job_asset.touched_at
    written by the narrow progress write would give true recency without changing the endpoint or
    the card. Deliberately not taken here.
  • The activity feed has no per-run ingest entry until IngestJob carries times. Same shape of
    gap, smaller consequence.

Closes #552

A read service whose scope is the whole workspace rather than one project:
totals, the batch to carry on with, what is waiting, and a feed derived from
the timestamps that already exist.

The resume target is ranked by progress rather than by recency, because there
is no timestamp column on batch, annotation or annotation_job_asset — nothing
records when a batch changed state or when a label was drawn. Ranking by how
far through a batch is needs no schema change and claims only what the rows
can support.

UnitOfWork.annotation_totals is the narrow port method ProjectService.stats
and JobService.project_progress both already named as the remedy for counting
a project's labels one query per asset.
One read-only projection rather than a resource: no path parameters, no verbs
but GET, and no allowed_actions of its own — every row points at a resource
whose own wire shape already declares what may be done to it.

The two feed shapes are flat rows carrying a kind rather than discriminated
unions; they differ in which optional fields they fill, not in shape.

openapi.json and the generated client are regenerated, not hand-edited.
The rail's Home entry stops redirecting to the project list and lands on a
page that answers what is waiting across every project: the batch to carry on
with, batches holding frames for review, background work that failed or is
running, four workspace totals and a derived activity feed.

A workspace with no projects gets an invitation instead, naming the cycle.

Exactly one filled button in every state, which is how DESIGN.md states the
rule and how the test asserts it — swept over the whole document, so zero
filled buttons fails as loudly as two.

CreateProjectDialog is exported so the first-run CTA performs the action its
label promises rather than navigating to the screen that carries it.
Three scenarios asserted the redirect that no longer exists. Two invert —
signing in at / stays at /, and the rail's active entry is Home rather than
Projects — and the NotFound link now keeps the promise its label always made.
The 'end' prop's claim is checked from a page that is not Home, which is where
it can actually fail.

The cycle suite creates its project from Home's first-run invitation. That is
the honest route on a workspace seconds old, and it opens the same dialog the
project list's button does.
DESIGN.md gains a Home section: the two states, the one-filled-button rule as
it applies in all three, the section-omission rule, and why the resume CTA's
label changes. The information-architecture sitemap records the route and its
deep-link edges, which its own process rule requires in the same PR.

docs/ui.md and docs/api.md describe GET /home as a read-only projection, and
say plainly that the resume ranking is progress rather than recency and that
an ingest activity row is an arrival rather than a run.
@JArmandoAnaya
JArmandoAnaya merged commit d6777d6 into main Aug 12, 2026
15 checks passed
@JArmandoAnaya
JArmandoAnaya deleted the feat/workspace-home branch August 12, 2026 14:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Workspace Home page: resume, attention, stats, activity

1 participant