Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .agents/skills/frontend/information-architecture/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Navigation maps 1:1 to domain objects. This is the target structure; if implemen

```
/projects Projects list
/inference Inference — model connections (workspace-scoped)
/projects/:id Project — tabs, in this order:
?tab=overview Overview (dashboard, see below)
?tab=schema Schema (contract)
Expand Down Expand Up @@ -40,13 +41,14 @@ Rules derived from the 2026-08 audit (§6):
- **"Schema history" is not a sibling tab.** Version history lives inside the Schema tab, below the editor and beside the `VersionNavigator` seam. The two overlap on purpose: the navigator is the *reader* (one version, with what it changed), the history is the *ledger* (every version at once). `?tab=versions` remains as a redirect; it does not appear in the tab bar.
- **The 4-step checklist is onboarding, not navigation.** It retires itself twice over: when the journey is finished (`hasReleases` makes `done` derivable) and when somebody dismisses it. Dismissal is **per project** and persisted — finishing one project does not teach you the pipeline for the next. It gates nothing and is never the sole path to a screen.
`hasReleases` is derived in `useProjectReadiness` from the two-hop read (project → dataset → releases) rather than added to the project-stats wire model: the Overview dashboard already makes both requests for its own cards, so a third spelling of the fact on the server would be the drift this audit was about.
- **Inference is a rail destination, not a project tab.** Model connections carry no `project_id`: one workspace is one SQLite file, every project uses the same connections, and navigation maps 1:1 to domain objects — so a project tab would state a scope the object does not have. The decision is recorded on #421 (2026-08-08) and **supersedes #58's rail rule** ("logo, collapse toggle, Home, Projects, account avatar — nothing else"); the rail now carries Home, Projects, Inference and the account control, and `DESIGN.md` carries the same membership in both places it states it. What earns a rail entry is a workspace-level object with nowhere else to live, never mere frequency of use.
- **Overview is a dashboard**: pipeline state of batches, trunk size, latest release, active schema version — each card links to its tab (`StatCard`'s `onGo`, which renders the card as a **button** so it is keyboard-reachable and announced as an action). Overview never duplicates a tab's full function: every number on it is a *pointer* at the section that owns it, and a section with nothing yet says so in words rather than showing a zero.

## Structural invariants

- **Single route definition site**: `frontend/app/src/routes.tsx`. No routes defined elsewhere.
- **`ui-core` stays router-free.** Screens receive navigation as callback props (`routes.tsx:113-121` pattern). Never import a router in `ui-core`.
- **Back-links are declared** in the routes parent map (`PARENT` in `routes.tsx`) and must point to the contextual parent: the gallery's back is the Batches tab. **A tab has no back-link** — its way out is the tab bar, and one inside a panel would be a second, contradictory answer to "where am I". That is why `DatasetScreen` takes `onBack` as optional and the tab mount passes none.
- **Back-links are declared** in the routes parent map (`PARENT` in `routes.tsx`) and must point to the contextual parent: the gallery's back is the Batches tab. **A tab has no back-link** — its way out is the tab bar, and one inside a panel would be a second, contradictory answer to "where am I". That is why `DatasetScreen` takes `onBack` as optional and the tab mount passes none. **A rail destination has no back-link either**, for the same reason and with the rail in the tab bar's place: `InferenceScreen` takes no `onBack`, and `PARENT.inference` exists as the address other screens send people *to* (#424's D6 panel is the first) rather than as a parent anything returns from.
- Tab state lives in `?tab=` with `replace: true`; unknown values fall back to `overview` silently.

## Process rule
Expand Down
15 changes: 11 additions & 4 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -289,9 +289,14 @@ base**, line-height 1.6. One scale — reuse it, don't invent sizes:

## Layout

- **App shell**: dark left rail (logo, collapse toggle, Home, Projects, account avatar at
the bottom — nothing else; #58), bright content area. Rail width 240px, 60px collapsed,
280px mobile — a single source of truth.
- **App shell**: dark left rail (logo, collapse toggle, Home, Projects, Inference, account
avatar at the bottom — nothing else; #58, and #421's decision of 2026-08-08 which
supersedes it by adding Inference), bright content area. Rail width 240px, 60px
collapsed, 280px mobile — a single source of truth.
**What earns a rail entry**: a workspace-level object every project uses, which has
nowhere else to live. Model connections carry no project id, so a project tab would
state a scope the object does not have. A destination that belongs to one project does
not qualify, however often it is visited.
- **Page widths**: lists/dashboards/detail `max-w-7xl`; forms/settings `max-w-3xl`;
centered, `px-4 md:px-6 py-6`.
- **Page header**: title + subtitle left, actions right, `border-b` below, `mb-8`.
Expand Down Expand Up @@ -329,7 +334,9 @@ from being rediscovered one screen at a time.
to send anybody renders no control rather than a dead one.
- **The rail is for top-level destinations only.** Per-screen return navigation never
lives on it — that is what lets it name where it goes, and what keeps the rail the
four things `## Layout` gives it.
five things `## Layout` gives it. A rail destination therefore has no back-link of
its own, for the reason a tab has none: the rail *is* its way out, and a second
answer to "where am I" inside the pane would contradict it.
- **The browser's Back button stays correct, and is never the only way out.** Nothing
here replaces it; a `replace` navigation is still right where a change is a view of
the same resource rather than a place (#171's tabs).
Expand Down
72 changes: 72 additions & 0 deletions docs/inference.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ with WorkspaceService.open("./road-signs") as workspace:
print(one.name, one.connection_type.value, one.setup_state.value)
```

**In the browser:** the **Inference** entry in the rail. See *The Inference section* below.

**Over HTTP:** `GET`/`POST /inference/connections`,
`GET`/`PATCH`/`DELETE /inference/connections/{connection_id}`, and
`POST /inference/connections/{connection_id}/download`. `PATCH` edits in place and leaves out what
Expand Down Expand Up @@ -76,6 +78,32 @@ The action stays **offered** on a machine that lacks the extra, deliberately. Wh
installation has torch is not a fact about your connection, and a control that quietly vanished
would leave the install command with nowhere to be shown.

## Knowing what a download costs, before agreeing to it

A decision you cannot see the price of is not a decision, so the size is readable on its own,
ahead of anything being fetched:

```bash
visionset inference size facebook/sam2-hiera-base-plus --revision main
```

It prints the byte count on stdout and the file count on stderr, and `--json` gives the document
the API answers with. Over HTTP it is
`GET /inference/download-size?model_id=…&model_revision=…`.

This reads the publishing hub's **file listing** — names and byte counts — and downloads nothing.
It takes a model and a revision rather than a connection, because the moment the number is wanted
is usually the moment before a connection exists; the same call answers for a connection that
already has one.

**Every file in the revision is counted**, because fetching takes the whole snapshot. A repository
publishing two serialisations of the same tensors really does cost both, and a figure counting one
of them would understate what lands on your disk. A revision the listing cannot fully size is
refused rather than estimated.

It needs the `local-inference` extra, because the size is read with the same client that would do
the fetching. Without it you get `LOCAL_INFERENCE_UNAVAILABLE` and the install command.

## Fetching weights

Nothing arrives on your behalf: not at install, not at startup, not on the way to anything else.
Expand Down Expand Up @@ -214,9 +242,50 @@ provenance, and only this configuration is removed.
That is also why a connection has no lifecycle to speak of — it is a form somebody filled in, and
the remedy for a wrong one is to edit it or make another.

## The Inference section

Connections live behind **Inference** in the rail, beside Home and Projects. It is a top-level
destination rather than something inside a project because a connection belongs to the
*workspace*: it carries no project id, and every project uses the same ones.

A workspace with none says so and offers one thing — **Add connection**. Creating one is two
steps, because the two kinds share almost no fields: first where the model runs, then that kind's
form.

- **Local** opens pre-filled with the suggested model, `facebook/sam2-hiera-base-plus` at `main`,
a `cpu` device and `fp16` precision. Every one of those is a starting point you can type over.
Underneath the fields is what fetching that revision would cost — the size described above, read
while you are still deciding. If this machine has no `local-inference` extra the size cannot be
read, and the form says so, in the server's own words, with the install command. **It stays
usable**: creating a connection downloads nothing, so not knowing the size is information rather
than a barrier.
- **HTTP** asks for the endpoint URL. There is no credential field; where a secret would live is
still open (`cf. #421`), and a field added ahead of that answer would be answering it.

Each row shows its name, its kind, `model @ revision`, and its status as a word — **Ready** or
**Not set up** — beside a colour, never as a colour alone. A local row that is not set up carries
**Download weights**, which launches the background job described above and reports its progress
in place; the row becomes **Ready** when the job finishes. A machine without the extra still shows
the control, and pressing it answers with the install command — a control that vanished would take
the remedy with it.

Editing does not offer to change the kind, because the kind is not editable. Deleting asks once
and says exactly what it destroys: *annotations keep their model provenance; only this
configuration is removed.*

Above twenty rows the list grows a filter, which matches a name substring and keeps saying how
many it hid.

**Reached from the editor, too.** Arming the editor's suggest tool with no usable connection shows
a panel naming what is missing and offering **Set up a connection**, which lands here. Nothing
about that flow forces you out of the editor or loses work: the panel is an explanation with a
door, and the door is optional — a host that wires no destination gets the explanation and no
control.

## At a terminal

```bash
visionset inference size some/model --revision abc123
visionset inference create local-detector \
--type local --model some/model --revision abc123 --device cuda --precision fp16
visionset inference list
Expand All @@ -226,6 +295,9 @@ visionset inference download local-detector
visionset inference delete local-detector --yes
```

`size` is the one command here that opens no workspace: it asks about a published model rather
than about a configured row, so it takes no `--workspace`.

`create` prints the new id on stdout alone. `list` leads with the id, so `awk '{print $1}'` is
stable even for a name holding internal whitespace. Every command takes `--json`, and the
document it prints is the same shape the REST API answers with.
Expand Down
9 changes: 6 additions & 3 deletions docs/ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ handler is not an operation.
| --- | --- | --- |
| `/` | Home | yes |
| `/projects`, `/projects/:id` (`?tab=schema\|batches\|versions`), `/projects/:id/ingest`, `/projects/:id/batches/:id`, `/projects/:id/dataset` | the product | yes |
| `/inference` | model connections, workspace-scoped | yes |
| `/jobs/:jobId` (`?asset=<id>`) | the annotation page | yes |
| `/demo` | the annotator showcase (`?scene=bench` for #49's benchmark) | **no** |
| `/styleguide` | the rendered design system | **no** |
Expand All @@ -62,9 +63,11 @@ prefix the wheel serves under. A **reload** on a client route is a real request
path no file backs; [`api.md`](api.md#where-the-ui-lives) describes the server-side
fallback that answers it.

The rail is the whole shell: logo, collapse toggle, Home, Projects, sign out. Anything
richer growing on it is what the thin-app audit exists to catch — a capability in
`app/` is one the future enterprise UI cannot reuse.
The rail is the whole shell: logo, collapse toggle, Home, Projects, Inference, sign
out. Anything richer growing on it is what the thin-app audit exists to catch — a
capability in `app/` is one the future enterprise UI cannot reuse. `Inference` joined by
the decision recorded on #421 (2026-08-08): what earns an entry is a workspace-level
object every project uses and that has nowhere else to live, never frequency of use.

### Two panes, and which one a route gets

Expand Down
24 changes: 24 additions & 0 deletions frontend/app/e2e/annotate.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,12 @@ async function serveApi(
return route.fulfill({ contentType: "image/png", body: PIXEL });
}
if (path === "/projects") return route.fulfill({ json: { items: [], total: 0 } });
// The suggest tool's own read (#424 D5/D6). Empty is the interesting answer
// here: it is the state D6's panel exists for, and it is what a workspace
// that has never been to the Inference section is in.
if (path === "/inference/connections") {
return route.fulfill({ json: { items: [], total: 0 } });
}
return route.fulfill({ status: 500, json: { code: "NO_STUB", message: path } });
});
}
Expand Down Expand Up @@ -2690,3 +2696,21 @@ test("Escape closes the canvas picker and leaves the object alone", async ({ pag
await expect(page.getByTestId("canvas-reclass-pedestrian")).toHaveCount(0);
await expect(page.getByTestId("object-row-0")).toContainText("1. vehicle");
});

test("the no-connection panel now has somewhere to send you (#424 D6)", async ({ page }) => {
const sent: Request[] = [];
await openJob(page, sent);

// Arming the tool is what makes the editor ask whether a model is reachable —
// a job nobody suggests on makes no inference request at all.
await page.getByTestId("tool-suggest").click();
await expect(page.getByTestId("suggest-no-connections")).toBeVisible();

// The half that did not exist until this slice. `ui-core` imports no router,
// so the panel's action is a callback and `routes.tsx` is the only place that
// can name a destination for it — which is why this is asserted here and not
// in a component test.
await page.getByTestId("suggest-configure").click();
await expect(page).toHaveURL(/\/inference$/);
await expect(page.getByTestId("inference-screen")).toBeVisible();
});
28 changes: 27 additions & 1 deletion frontend/app/e2e/shell.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ async function serveApi(page: Page, { session = false } = {}): Promise<void> {
// because the gate and the sign-out button are what it is about and both are
// only reachable when the server declines to sign the browser in by itself.
await page.route("**/api/session", (route) => route.fulfill({ json: { issued: session } }));
// The Inference section's one read. Empty, because this suite is about the
// rail and the router: what the screen does with rows is `ui-core`'s
// `inference.test.tsx`, and an unrouted request here would leave the page
// waiting on a network that is not there.
await page.route("**/api/inference/**", (route) =>
route.fulfill({ status: 200, json: { items: [], total: 0 } }),
);
await page.route("**/api/projects**", (route) => {
const path = new URL(route.request().url()).pathname;
if (path.endsWith("/schema")) {
Expand Down Expand Up @@ -154,13 +161,32 @@ test("the rail carries exactly what the design gives it", async ({ page }) => {
await signIn(page);

const rail = page.getByTestId("app-rail");
await expect(rail.getByRole("link")).toHaveCount(2);
// Three, not two: `Inference` joined by the decision recorded on #421
// (2026-08-08), which supersedes #58's rule. The count is the assertion — a
// fourth destination arriving without that decision fails here first.
await expect(rail.getByRole("link")).toHaveCount(3);
await expect(page.getByTestId("rail-home")).toBeVisible();
await expect(page.getByTestId("rail-projects")).toBeVisible();
await expect(page.getByTestId("rail-inference")).toBeVisible();
await expect(page.getByTestId("rail-collapse")).toBeVisible();
await expect(page.getByTestId("rail-sign-out")).toBeVisible();
});

test("the Inference entry goes to the section, and is current once you are on it", async ({
page,
}) => {
await signIn(page);
await expect(page.getByTestId("rail-inference")).toHaveAttribute("href", "/inference");

await page.getByTestId("rail-inference").click();
await expect(page).toHaveURL(/\/inference$/);
await expect(page.getByTestId("rail-inference")).toHaveAttribute("aria-current", "page");
await expect(page.getByTestId("rail-projects")).not.toHaveAttribute("aria-current", "page");
// A rail destination has no back-link: the rail is its way out, and a second
// answer to "where am I" inside the pane would contradict it.
await expect(page.getByTestId("back-link")).toHaveCount(0);
});

test("navigation is real links, and the active one is the one you are on", async ({ page }) => {
await signIn(page);

Expand Down
16 changes: 16 additions & 0 deletions frontend/app/src/routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import {
AnnotationPage,
assetParamFor,
GalleryScreen,
InferenceScreen,
resolveProjectTab,
IngestScreen,
ProjectScreen,
Expand Down Expand Up @@ -74,6 +75,13 @@ export function AppRoutes(): JSX.Element {
redirect keeps one screen rather than two that drift. */}
<Route index element={<Navigate to="/projects" replace />} />
<Route path="projects" element={<Projects />} />
{/*
A top-level destination rather than a project route, per the
decision recorded on #421: a connection carries no project id and
every project uses the same ones, so nesting it under a project
would put a workspace-scoped object inside one project's URL.
*/}
<Route path="inference" element={<InferenceScreen />} />
<Route path="projects/:projectId" element={<Project />} />
<Route path="projects/:projectId/ingest" element={<Ingest />} />
<Route path="projects/:projectId/batches/:batchId" element={<Gallery />} />
Expand Down Expand Up @@ -155,6 +163,10 @@ function Projects(): JSX.Element {
*/
const PARENT = {
projects: "/projects",
//: A rail destination, so nothing declares it as a parent — it is here because
//: this table is the route map's own index, and an entry point missing from it
//: is the drift #199 was about. Its own way out is the rail.
inference: "/inference",
project: (projectId: string) => `/projects/${projectId}`,
batches: (projectId: string) => `/projects/${projectId}?tab=batches`,
dataset: (projectId: string) => `/projects/${projectId}?tab=dataset`,
Expand Down Expand Up @@ -313,6 +325,10 @@ function Annotate(): JSX.Element {
const next = assetParamFor(showing, query.get("asset"));
if (next !== null) setQuery({ asset: next }, { replace: true });
}}
// #424's D6: the editor's no-connection panel had no destination until
// there was an Inference section to send somebody to. `ui-core` renders no
// control when this callback is absent, which is what it did until now.
onConfigureInference={() => void navigate(PARENT.inference)}
/>
);
}
Expand Down
Loading
Loading