From fa81c99af969e8e17a8cb7858695b96ac75d73f6 Mon Sep 17 00:00:00 2001 From: Luka Skugor Date: Wed, 4 Nov 2026 16:51:24 +0100 Subject: [PATCH 1/2] cockpit v3: demos are never hidden (calm rule 10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Node pages carry their recorded demos: a change and its task show the demo of the PR's current head, a project its newest across its PRs, a feature its acceptance run's recording (else its newest project demo), each with verdict, short title, length, poster and video (the demo file routes), PR and a one-line note on what did not pass. Feature rows carry Home's play chip. GET .../n/{ref}/demos lists the subtree newest first; viewers see only the demos of PRs they may read. Demo cards in the node chat are conversation. The web shows one "Latest demo · PASS · 0:14 ▶" row under Your move that plays in place with the PR page's player, a Demos sheet behind "n more demos", and a ▶ chip on Home's feature rows. The calm gates hold the proof row to its own few words, the e2e seed records demos with posters and recordings, and the sample factory has demos for stories and tests. Co-Authored-By: Claude Opus 5.5 --- docs/FACTORY.md | 20 + omnigent/server/factory/api/node_demos.py | 53 +++ omnigent/server/factory/chat_feed.py | 18 +- omnigent/server/factory/node_proof.py | 415 ++++++++++++++++++ omnigent/server/factory/node_views.py | 61 +++ omnigent/server/factory/nodes.py | 14 +- tests/e2e_ui/factory_v3/checks.py | 19 +- tests/e2e_ui/factory_v3/demo_media.py | 150 +++++++ tests/e2e_ui/factory_v3/seed.py | 188 +++++++- tests/e2e_ui/factory_v3/test_v3_proof.py | 106 +++++ tests/server/factory/node_fixtures.py | 116 ++++- tests/server/factory/test_chat_feed.py | 5 +- tests/server/factory/test_node_proof.py | 373 ++++++++++++++++ .../__fixtures__/nodeLevels.server.json | 15 + .../__fixtures__/nodePages.server.json | 113 +++++ .../components/factory/review/DemoPanel.tsx | 3 +- web/src/components/factory/v3/NodeContext.tsx | 8 +- .../components/factory/v3/NodePage.test.tsx | 3 +- web/src/components/factory/v3/NodePage.tsx | 7 +- web/src/components/factory/v3/NodeTile.tsx | 12 +- .../factory/v3/calm/calmGate.test.tsx | 39 +- .../factory/v3/chat/chatLanes.test.ts | 7 + .../components/factory/v3/chat/chatLanes.ts | 13 +- .../factory/v3/fixture/sampleData.ts | 5 + .../factory/v3/fixture/sampleDemos.ts | 167 +++++++ .../factory/v3/fixture/sampleEngine.ts | 88 ++++ .../factory/v3/fixture/sampleNodeSource.ts | 1 + web/src/components/factory/v3/nodeSource.ts | 16 +- web/src/components/factory/v3/nodeTypes.ts | 62 +++ .../factory/v3/proof/DemosSheet.tsx | 164 +++++++ .../factory/v3/proof/Proof.stories.tsx | 63 +++ .../components/factory/v3/proof/ProofRow.tsx | 224 ++++++++++ .../factory/v3/proof/proof.test.tsx | 226 ++++++++++ .../components/factory/v3/proof/proofModel.ts | 110 +++++ .../components/factory/v3/sheets/V3Sheets.tsx | 32 +- 35 files changed, 2873 insertions(+), 43 deletions(-) create mode 100644 omnigent/server/factory/api/node_demos.py create mode 100644 omnigent/server/factory/node_proof.py create mode 100644 tests/e2e_ui/factory_v3/demo_media.py create mode 100644 tests/e2e_ui/factory_v3/test_v3_proof.py create mode 100644 tests/server/factory/test_node_proof.py create mode 100644 web/src/components/factory/v3/fixture/sampleDemos.ts create mode 100644 web/src/components/factory/v3/proof/DemosSheet.tsx create mode 100644 web/src/components/factory/v3/proof/Proof.stories.tsx create mode 100644 web/src/components/factory/v3/proof/ProofRow.tsx create mode 100644 web/src/components/factory/v3/proof/proof.test.tsx create mode 100644 web/src/components/factory/v3/proof/proofModel.ts diff --git a/docs/FACTORY.md b/docs/FACTORY.md index edf001a2535..b5b7dd9f33c 100644 --- a/docs/FACTORY.md +++ b/docs/FACTORY.md @@ -1094,6 +1094,26 @@ count, and lanes never join. `GET …/n/{ref}/money` lists the children's cost a their budgets (the roll-ups' numbers), most first, the total, and `rest`: what the node spent that no child holds (its leads and agents). +**Proof** (`node_proof.py`; calm rule 10, "proof is never hidden"). Every +recorded demo the viewer may play belongs to one node: a PR's demo to its +change, an acceptance run's recording to its feature, a demo of a PR the tree +has no change for to its project. A node page carries `proof: {latest?, +count}`: `count` is its subtree's demos, and `latest` (the one row the page +shows by default, "Latest demo · PASS · 0:14 ▶") is, for a change and the task +that owns it, the demo at the PR's current head (else its newest, `stale`); +for a project its newest across its PRs, current heads first; for a feature +its newest acceptance-run recording, else its newest project demo. Each is +`{id, verdict, title (a few words), duration, poster_url, video_url (the demo +file routes), pr_number, at, partial_note}`; `partial_note` says in one line +what did not pass ("Step 6: live search fails on the base env"). A feature's +row (and a project shown in place of its folded implicit feature) carries +`demo: {id, verdict, duration}`, Home's "▶". `GET …/n/{ref}/demos?cursor= +&limit=` lists the subtree's demos newest first (what "n more demos" opens). +A viewer reads what they may of the PRs: demos of a project whose lead they +cannot read are left out, and so are demos whose files belong to a session +they cannot read. In the node chat a demo card is conversation, never +Activity. + **Needs you and Find** (`node_sheets.py`). `GET …/factories/{fid}/needs-you ?tree=product|factory` lists every ask a person owes in one tree, ranked by the work it holds (the blocker paths' weight: the estimates of the tasks it diff --git a/omnigent/server/factory/api/node_demos.py b/omnigent/server/factory/api/node_demos.py new file mode 100644 index 00000000000..029f4fe3304 --- /dev/null +++ b/omnigent/server/factory/api/node_demos.py @@ -0,0 +1,53 @@ +""" +A node's recorded demos (calm rule 10, "Proof is never hidden"). + +``GET /v1/factory/factories/{fid}/n/{ref}/demos`` lists the demos of the +node's subtree, newest first, ``limit`` a page (``cursor``: a page's +``next``), each with its verdict, short title, length, poster and video (the +demo file routes) and its PR (:mod:`omnigent.server.factory.node_proof`). The +node page carries the one it shows by default (``proof.latest``) and the +count; this is what "n more demos" opens. A viewer reads what they may read of +the PRs: demos of a project whose lead they cannot read are left out, and so +are demos whose files belong to a session they cannot read. 404 +``unknown_node`` for a ref the factory has no node for; 400 ``bad_cursor`` +for a cursor no page handed out. +""" + +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Query, Request +from fastapi.responses import JSONResponse + +from omnigent.server.factory import node_proof, node_views, nodes +from omnigent.server.factory.api.agents import fleet_context + +router = APIRouter(prefix="/v1/factory", tags=["factory"]) + + +def _error(status: int, code: str, message: str) -> JSONResponse: + return JSONResponse(status_code=status, content={"error": {"code": code, "message": message}}) + + +@router.get("/factories/{factory_id}/n/{ref}/demos", response_model=None) +async def get_node_demos( + request: Request, + factory_id: str, + ref: str, + cursor: str | None = None, + limit: int = Query(node_proof.PAGE_SIZE, ge=1, le=node_proof.PAGE_MAX), +) -> dict[str, Any] | JSONResponse: + """The node's subtree's recorded demos, newest first.""" + ctx = await fleet_context(request, factory_id) + tree = await nodes.tree_for( + ctx.deps, ctx.root, ctx.raw, ctx.readable, app_state=request.app.state + ) + found = tree.lookup(ref) + if found is None: + return _error(404, "unknown_node", f"no node {ref!r} in this factory") + try: + view = node_proof.demos_page(tree, tree.fold(found), cursor=cursor, limit=limit) + except ValueError as exc: + return _error(400, "bad_cursor", str(exc)) + return node_views.validate(node_views.NodeDemosView, view) diff --git a/omnigent/server/factory/chat_feed.py b/omnigent/server/factory/chat_feed.py index 84d39653ed0..d2126aaa30d 100644 --- a/omnigent/server/factory/chat_feed.py +++ b/omnigent/server/factory/chat_feed.py @@ -3,13 +3,13 @@ Cockpit v3's node chat shows what people and agents say, plus the live cards that need the person reading it (an open ask they answer, a merge they make, -an offer, the "why" they asked for, a verb they can still undo). Everything -else the Room mirrors from events is activity: base moves, members spawned, -incidents, loops, controls, PR and demo news, closed alarms, and every -decision or proposal that is answered, applied, closed or someone else's. -Activity never shows inline; it folds into one "Activity · n since you -looked" line that opens it as a list. Resolved incidents and closed or -applied proposals are never conversation. +an offer, the "why" they asked for, a verb they can still undo), and the +recorded demos (proof is never hidden). Everything else the Room mirrors from +events is activity: base moves, members spawned, incidents, loops, controls, +PR news, closed alarms, and every decision or proposal that is answered, +applied, closed or someone else's. Activity never shows inline; it folds into +one "Activity · n since you looked" line that opens it as a list. Resolved +incidents and closed or applied proposals are never conversation. :func:`classify` sorts one row by the live state of what its card shows (the open decisions, proposals and PRs read once per request: :class:`Live`). :func:`feed_page` is @@ -53,6 +53,8 @@ """Counts since the read mark stop here (the cockpit shows "99+").""" _ASKING_CARDS: Final = frozenset({"why", "offer"}) """Cards that answer or ask the reader directly: conversation while they stand.""" +_PROOF_CARDS: Final = frozenset({"demo"}) +"""Cards that show what was built (a recorded demo): always conversation.""" _CLOSED_ISSUES: Final = frozenset({"resolved", "wontfix"}) @@ -208,6 +210,8 @@ def classify(row: FactoryChatMessage, live: Live, *, closed: bool = True) -> Sor return Sorted(ACTIVITY, kind, closed=issue is None or issue.status in _CLOSED_ISSUES) if kind in _ASKING_CARDS: return Sorted(CONVERSATION, kind, needs_you=kind == "offer") + if kind in _PROOF_CARDS: + return Sorted(CONVERSATION, kind) if kind == "preview_applied": meta = _meta(card) until = meta.get("undo_until") diff --git a/omnigent/server/factory/node_proof.py b/omnigent/server/factory/node_proof.py new file mode 100644 index 00000000000..3f2f4c28b89 --- /dev/null +++ b/omnigent/server/factory/node_proof.py @@ -0,0 +1,415 @@ +""" +Proof on the cockpit's node pages: the recorded demos (calm rule 10, "Proof is +never hidden"). + +Every recorded demo the viewer may read belongs to one node of the level tree: + +- a PR's demo to its change (``PR-n``); +- an acceptance run's recording (a demo of no PR that names its initiative) + to that feature (``I-n``); +- a demo of a PR the tree shows no change for (closed, or not tracked) to its + project. + +A demo of a project the viewer cannot read is left out with the project, as +its PRs are; a demo whose files belong to a session they cannot read is left +out when the facts are loaded (:func:`readable_demos`). + +A node's demos are its subtree's, newest first. The page shows one of them by +default (``NodePage.proof.latest``: "Latest demo · PASS · 0:14 ▶"): + +- a change, and the task that owns it: the demo of the PR's current head, else + its newest (``stale``: it ran at an earlier commit); +- a project: its newest across its PRs, current heads first; +- a feature: its newest acceptance-run recording, else its newest project demo. + +Other levels show no row; the product's Home links each feature's latest +from that feature's row (``NodeSummary.demo``: one "▶" chip). The rest sit +behind "n more demos": ``GET …/n/{ref}/demos`` (:func:`demos_page`), newest +first. PARTIAL and FAIL say what was partial in one line (``partial_note``). + +Nothing here names a product: titles are the demos' own, made short. +""" + +from __future__ import annotations + +import time +from collections.abc import Iterable, Mapping, Sequence +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Final + +from omnigent.server.factory import node_names +from omnigent.stores.factory_store import norm_id + +if TYPE_CHECKING: + from omnigent.entities.factory import FactoryDemo, FactoryPr + from omnigent.server.factory.nodes import _N, Tree + +READ_MAX: Final = 1000 +"""The newest demos one projection reads (older ones are history no page shows).""" +PAGE_SIZE: Final = 20 +"""Demos per page of ``GET …/n/{ref}/demos``.""" +PAGE_MAX: Final = 100 +"""The most a caller may ask for in one page.""" +LATEST_LEVELS: Final = frozenset({"feature", "project", "task", "change"}) +"""Levels whose page shows the latest demo as a row (a task shows its change's).""" +CHIP_LEVELS: Final = frozenset({"feature"}) +"""Levels whose rows and tiles carry the "▶" chip (Home lists features; a node +shown in place of its folded implicit feature carries it too).""" +VERDICTS: Final = ("PASS", "PARTIAL", "FAIL", "ERROR") +_NOTE_MAX: Final = 140 +_FILE_ROUTE: Final = "/v1/factory/demos/{demo}/files/{file}" + + +@dataclass(frozen=True) +class Demo: + """ + One demo as the tree places it. + + :param row: The stored demo. + :param home: The node it belongs to (a change, a feature or a project). + :param current: Recorded at its PR's current head (always, for a demo of no PR). + :param run: The acceptance run it records (``None`` for a PR demo). + :param tag: Its PR as the tree tags it (``#9876``), when it has one. + """ + + row: FactoryDemo + home: str + current: bool = True + run: int | None = None + tag: str | None = None + + +@dataclass(frozen=True) +class Index: + """ + Every placed demo (newest first) and, per node, the demos of its subtree. + + :param demos: Placed demos, newest first. + :param under: Node ref -> positions in *demos* of its subtree's demos (ascending). + """ + + demos: tuple[Demo, ...] + under: Mapping[str, tuple[int, ...]] + + def of(self, ref: str) -> list[Demo]: + """:returns: The demos of *ref*'s subtree, newest first.""" + return [self.demos[i] for i in self.under.get(ref, ())] + + +def readable_demos( + demos: Iterable[FactoryDemo], readable: Iterable[str] | None, root_id: str +) -> list[FactoryDemo]: + """ + The demos a viewer may play: their files belong to a session the viewer + may read (the file route checks the same), newest first. + + :param readable: The sessions the viewer may read (``None``: every one, an agent). + :param root_id: The factory's root, which every viewer of it may read. + """ + rows = sorted(demos, key=lambda d: (d.created_at, d.id), reverse=True) + if readable is None: + return rows + sessions = {norm_id(s) for s in readable} | {norm_id(root_id)} + return [d for d in rows if _bare(d.session_id) in sessions] + + +def _bare(value: str | None) -> str | None: + try: + return norm_id(value) if value else None + except ValueError: + return None + + +def _acceptance_run(demo: FactoryDemo) -> tuple[str, int | None] | None: + """``(initiative ref, run number)`` for an acceptance run's recording, else ``None``.""" + accepted = (demo.manifest or {}).get("acceptance") + if not isinstance(accepted, Mapping) or not accepted.get("initiative"): + return None + run = accepted.get("run") + return str(accepted["initiative"]), run if isinstance(run, int) else None + + +def _pr_of( + tree: Tree, demo: FactoryDemo, by_number: Mapping[tuple[str, int], FactoryPr] +) -> FactoryPr | None: + if (pr_id := _bare(demo.pr_id)) and pr_id in tree.pr_rows: + return tree.pr_rows[pr_id] + return by_number.get((demo.repo.lower(), demo.pr_number)) + + +def _project_home(tree: Tree, slug: str | None) -> str | None: + """The node a demo of no change lands on: its project, when the tree shows it.""" + if not slug or slug in tree.hidden: + return None + node = tree.lookup(slug) + return node.ref if node is not None and node.level == "project" else None + + +def _place( + tree: Tree, demo: FactoryDemo, by_number: Mapping[tuple[str, int], FactoryPr] +) -> Demo | None: + if demo.project_slug and demo.project_slug in tree.hidden: + return None + if (accepted := _acceptance_run(demo)) is not None: + feature = tree.lookup(accepted[0]) + if feature is not None and feature.level == "feature": + return Demo(demo, feature.ref, run=accepted[1]) + return None + if not demo.pr_number: + home = _project_home(tree, demo.project_slug) + return Demo(demo, home) if home else None + pr = _pr_of(tree, demo, by_number) + refs = tree.pr_refs_by_id() + change = refs.get(norm_id(pr.id)) if pr is not None else None + if pr is not None and change is not None and change in tree.nodes: + node = tree.nodes[change] + current = bool(pr.head_sha) and (demo.head_sha or "").lower() == pr.head_sha.lower() + return Demo(demo, change, current=current or not pr.head_sha, tag=node.tag) + home = _project_home(tree, (pr.project_slug if pr is not None else None) or demo.project_slug) + return Demo(demo, home, tag=f"#{demo.pr_number}") if home else None + + +def index(tree: Tree) -> Index: + """Every demo the tree's viewer may play, placed on its node (made once per tree).""" + + def make() -> Index: + by_number: dict[tuple[str, int], FactoryPr] = {} + for pr in sorted(tree.facts.prs, key=lambda p: p.created_at): + by_number[(pr.repo.lower(), pr.number)] = pr + placed = [ + found + for demo in tree.facts.demos + if (found := _place(tree, demo, by_number)) is not None + ] + under: dict[str, list[int]] = {} + for position, demo in enumerate(placed): + under.setdefault(demo.home, []).append(position) + for ancestor in tree.ancestors(demo.home): + under.setdefault(ancestor.ref, []).append(position) + return Index(tuple(placed), {ref: tuple(found) for ref, found in under.items()}) + + return tree.memo("proof", "index", make) + + +def _pick(demos: Sequence[Demo]) -> Demo | None: + """The newest demo recorded at its PR's current head, else the newest.""" + return next((d for d in demos if d.current), demos[0] if demos else None) + + +def latest(tree: Tree, node: _N) -> Demo | None: + """The demo *node*'s page shows by default (``None``: no row at its level).""" + if node.level not in LATEST_LEVELS: + return None + demos = index(tree).of(node.ref) + if node.level == "feature": + runs = [d for d in demos if d.run is not None or d.home == node.ref] + return runs[0] if runs else _pick(demos) + # A project's and a change's proof is its PRs' (an acceptance run is its feature's). + return _pick([d for d in demos if d.run is None]) + + +def _iso(seconds: float) -> str: + return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(int(seconds))) + + +def duration_words(seconds: float | None) -> str | None: + """``0:14`` from ``14.2`` seconds (``None`` when not recorded).""" + if seconds is None or seconds < 0: + return None + whole = round(seconds) + return f"{whole // 60}:{whole % 60:02d}" + + +def _one_line(text: Any, limit: int = _NOTE_MAX) -> str: + clean = " ".join(str(text or "").split()) + return clean if len(clean) <= limit else clean[: limit - 1].rstrip() + "…" + + +def _steps(demo: FactoryDemo) -> list[Mapping[str, Any]]: + steps = (demo.manifest or {}).get("steps") + return [s for s in steps if isinstance(s, Mapping)] if isinstance(steps, list) else [] + + +def _step_words(step: Mapping[str, Any], index: int) -> tuple[str, str]: + n = step.get("n") + number = n if isinstance(n, int) and not isinstance(n, bool) else index + return str(number), _one_line(step.get("note") or step.get("title") or "", _NOTE_MAX) + + +def partial_note(demo: FactoryDemo) -> str | None: + """ + What was partial, in one line, for a demo that did not pass: its first + failed step ("Step 6: live search fails on the base env"; the verdict beside + it says it failed), else the steps it could not run, else its summary's + first sentence. + """ + if demo.status == "PASS": + return None + steps = list(enumerate(_steps(demo), start=1)) + failed = [(i, s) for i, s in steps if str(s.get("status") or "").lower() == "fail"] + skipped = [(i, s) for i, s in steps if str(s.get("status") or "").lower() == "skipped"] + if failed: + number, words = _step_words(failed[0][1], failed[0][0]) + more = f" (+{len(failed) - 1} more)" if len(failed) > 1 else "" + return _one_line(f"Step {number}{f': {words}' if words else ' failed'}{more}") + if skipped: + number, words = _step_words(skipped[0][1], skipped[0][0]) + if len(skipped) == 1: + return _one_line(f"Step {number} could not run{f': {words}' if words else ''}") + return _one_line(f"{len(skipped)} steps could not run, first: {words or f'step {number}'}") + summary = " ".join(str(demo.summary or "").split()) + first = summary.split(". ")[0].rstrip(".") if summary else "" + return _one_line(first) if first else None + + +def short_title(demo: Demo) -> str: + """The demo's title in a few words; an acceptance run is "Acceptance run 2".""" + if demo.run is not None: + return f"Acceptance run {demo.run}" + text = demo.row.title or demo.row.summary or "" + words = node_names.fallback(text) if text.strip() else "" + return words or (f"Demo of {demo.tag}" if demo.tag else "Demo") + + +def _file_url(demo: FactoryDemo, entry: Mapping[str, Any]) -> str | None: + file_id = entry.get("file_id") or entry.get("id") + if not file_id: + return None + return _FILE_ROUTE.format(demo=demo.id, file=file_id) + + +def _media(demo: FactoryDemo) -> tuple[str | None, str | None]: + """``(poster url, video url)``: the poster, else the first step's screenshot.""" + files = [f for f in demo.files or [] if isinstance(f, Mapping)] + video = next((f for f in files if f.get("kind") == "video"), None) + poster = next((f for f in files if f.get("kind") == "poster"), None) + if poster is None: + shots = [f for f in files if f.get("kind") == "screenshot"] + by_step = sorted( + (f for f in shots if isinstance(f.get("step"), int)), key=lambda f: f["step"] + ) + poster = by_step[0] if by_step else (shots[0] if shots else None) + return ( + _file_url(demo, poster) if poster is not None else None, + _file_url(demo, video) if video is not None else None, + ) + + +def view(demo: Demo) -> dict[str, Any]: + """A ``DemoRef``: one demo as a page row or a list line shows it.""" + row = demo.row + manifest = row.manifest or {} + seconds = manifest.get("duration_s") + seconds = float(seconds) if isinstance(seconds, int | float) else None + poster, video = _media(row) + verdict = row.status if row.status in VERDICTS else "ERROR" + out: dict[str, Any] = { + "id": row.id, + "verdict": verdict, + "title": short_title(demo), + "ref": demo.home, + "at": _iso(row.created_at), + } + full = " ".join(str(row.title or "").split()) + if full and full != out["title"]: + out["full_title"] = _one_line(full, 256) + if seconds is not None: + out["duration_s"] = round(seconds, 1) + out["duration"] = duration_words(seconds) + if poster: + out["poster_url"] = poster + if video: + out["video_url"] = video + if row.pr_number: + out["pr_number"] = row.pr_number + if demo.tag: + out["pr_tag"] = demo.tag + if demo.run is not None: + out["acceptance_run"] = demo.run + if not demo.current: + out["stale"] = True + steps = [str(s.get("status") or "").lower() for s in _steps(row)] + if steps: + out["steps"] = {"passed": steps.count("pass"), "total": len(steps)} + if note := partial_note(row): + out["partial_note"] = note + return out + + +def proof(tree: Tree, node: _N) -> dict[str, Any] | None: + """``NodePage.proof``: the latest demo (where its level shows one) and the subtree's count.""" + demos = index(tree).of(node.ref) + if not demos: + return None + out: dict[str, Any] = {"count": len(demos)} + if (shown := latest(tree, node)) is not None: + out["latest"] = view(shown) + return out + + +def _feature_row(tree: Tree, node: _N) -> bool: + """A feature, or a node shown in a feature's place (its implicit feature folds into it).""" + if node.level in CHIP_LEVELS: + return True + return any(a.level in CHIP_LEVELS and tree.folded(a) for a in tree.ancestors(node.ref)) + + +def chip(tree: Tree, node: _N) -> dict[str, Any] | None: + """``NodeSummary.demo``: a feature row's "▶" (its latest demo, in a few fields).""" + if not _feature_row(tree, node) or (shown := latest(tree, node)) is None: + return None + found = view(shown) + out: dict[str, Any] = {"id": found["id"], "verdict": found["verdict"]} + if found.get("duration"): + out["duration"] = found["duration"] + return out + + +def demos_page( + tree: Tree, node: _N, *, cursor: str | None = None, limit: int = PAGE_SIZE +) -> dict[str, Any]: + """ + ``GET …/n/{ref}/demos``: the subtree's demos, newest first, *limit* a page. + + :param cursor: ``next`` of a previous page. + :raises ValueError: On a cursor no page handed out. + """ + demos = index(tree).of(node.ref) + offset = 0 + if cursor: + if not cursor.isdigit() or int(cursor) > len(demos): + raise ValueError(f"unknown cursor {cursor!r}") + offset = int(cursor) + size = max(1, min(limit, PAGE_MAX)) + shown = demos[offset : offset + size] + out: dict[str, Any] = { + "ref": node.ref, + "items": [view(d) for d in shown], + "total": len(demos), + } + if (shown_latest := latest(tree, node)) is not None: + out["latest"] = shown_latest.row.id + if offset + size < len(demos): + out["next"] = str(offset + size) + return out + + +__all__ = [ + "CHIP_LEVELS", + "LATEST_LEVELS", + "PAGE_MAX", + "PAGE_SIZE", + "READ_MAX", + "Demo", + "Index", + "chip", + "demos_page", + "duration_words", + "index", + "latest", + "partial_note", + "proof", + "readable_demos", + "short_title", + "view", +] diff --git a/omnigent/server/factory/node_views.py b/omnigent/server/factory/node_views.py index 66d252966fc..026eaee81b5 100644 --- a/omnigent/server/factory/node_views.py +++ b/omnigent/server/factory/node_views.py @@ -31,6 +31,7 @@ "moving", "needs", "waiting", "done", "later", "offered", "proposed", "paused", "idle" ] StatusEnd = Literal["person", "server", "moving", "cycle", "cut", "other"] +DemoVerdict = Literal["PASS", "PARTIAL", "FAIL", "ERROR"] class _View(BaseModel): @@ -133,6 +134,64 @@ class ChangeLinkView(_View): label: str +class DemoChipView(_View): + """A feature row's "▶": its latest demo (``GET …/n/{ref}/demos`` plays it).""" + + id: str + verdict: DemoVerdict + duration: str | None = None + + +class DemoStepsView(_View): + passed: int + total: int + + +class DemoRefView(_View): + """One recorded demo as a page row or a Demos list line shows it (:mod:`.node_proof`).""" + + id: str + verdict: DemoVerdict + title: str + """A few words; an acceptance run's recording is "Acceptance run 2".""" + ref: str + """The node it belongs to: a change, a feature (an acceptance run) or a project.""" + at: str + full_title: str | None = None + duration_s: float | None = None + duration: str | None = None + """``0:14``.""" + poster_url: str | None = None + video_url: str | None = None + """The demo file routes (``/v1/factory/demos/{id}/files/{file}``), authenticated.""" + pr_number: int | None = None + pr_tag: str | None = None + acceptance_run: int | None = None + stale: bool | None = None + """Recorded at an earlier commit than its PR's head.""" + steps: DemoStepsView | None = None + partial_note: str | None = None + """What did not pass, in one line (PARTIAL, FAIL).""" + + +class ProofView(_View): + """``NodePage.proof``: the latest demo (where the level shows one) and the subtree's count.""" + + count: int + latest: DemoRefView | None = None + + +class NodeDemosView(_View): + """``GET …/n/{ref}/demos``: the subtree's demos, newest first.""" + + ref: str + items: list[DemoRefView] + total: int + latest: str | None = None + """The id of the one the page shows as its row.""" + next: str | None = None + + class NodeSummaryView(_View): ref: str level: str @@ -168,6 +227,7 @@ class NodeSummaryView(_View): working: list[ActorView] | None = None idle: int | None = None change: ChangeLinkView | None = None + demo: DemoChipView | None = None class AcceptanceStepView(_View): @@ -460,6 +520,7 @@ class NodePageView(_View): thread: ThreadRefView version: str home: str | None = None + proof: ProofView | None = None class GroupParentView(CrumbView): diff --git a/omnigent/server/factory/nodes.py b/omnigent/server/factory/nodes.py index ed6d501fb6d..065c92916ee 100644 --- a/omnigent/server/factory/nodes.py +++ b/omnigent/server/factory/nodes.py @@ -47,7 +47,8 @@ (:mod:`.node_status`); roll-ups add burn (``turn`` spans), budgets, an ETA only from observed durations, Try it from passing acceptance steps and what is in the base env (:func:`.base.contains`); the factory tree's Health lists -the alarms the server closed (:mod:`.alarms`). +the alarms the server closed (:mod:`.alarms`); a page's recorded demos (its +latest and how many) come from :mod:`.node_proof`. Nothing here names a product: titles come from the factory's records and level names from its definition. @@ -83,6 +84,7 @@ features, levels, node_names, + node_proof, node_refs, node_status, subtrees, @@ -110,6 +112,7 @@ from omnigent.entities import Conversation from omnigent.entities.factory import ( FactoryDecision, + FactoryDemo, FactoryInitiative, FactoryLoop, FactoryNodeName, @@ -293,6 +296,7 @@ class NodeFacts: :param turns: Recent ``turn`` spans (the cost of the last hour: burn). :param closed_alarms: Alarms the server closed today, with their causes. :param names: Node ref -> its stored short name (:mod:`.node_names`). + :param demos: The recorded demos the viewer may play, newest first (:mod:`.node_proof`). """ factory_id: str @@ -328,6 +332,7 @@ class NodeFacts: turns: Sequence[FactorySpan] = () closed_alarms: Sequence[alarms.ClosedAlarm] = () names: Mapping[str, FactoryNodeName] = field(default_factory=dict) + demos: Sequence[FactoryDemo] = () def version(self) -> str: """:returns: A digest of the rows read (``NodePage.version``; a write's ``based_on``).""" @@ -481,6 +486,9 @@ def load_facts( turns=turns, closed_alarms=alarms.load(store, fid, clock), names={r.node_ref: r for r in store.list_node_names(fid)}, + demos=node_proof.readable_demos( + store.list_demos(fid, limit=node_proof.READ_MAX), readable, fid + ), ) @@ -2223,6 +2231,8 @@ def summary(self, node: _N) -> dict[str, Any]: change = next((k for k in self.children(node.ref) if k.level == "change"), None) if change is not None and change.tag: out["change"] = {"ref": change.ref, "label": change.tag} + if (demo := node_proof.chip(self, node)) is not None: + out["demo"] = demo return out def _responder_seat(self, node: _N) -> str | None: @@ -3295,6 +3305,8 @@ def page( out["results"] = self.results(node) if node.parent is None: out["home"] = self.home.get(node.tree) or (self.ladder[node.tree][1:2] or [""])[0] + if (proof := node_proof.proof(self, node)) is not None: + out["proof"] = proof return your_move.mark_repeats(out, (i["id"] for i in moves["items"])) def level_view( diff --git a/tests/e2e_ui/factory_v3/checks.py b/tests/e2e_ui/factory_v3/checks.py index c5760fda121..d8eaff4b1f2 100644 --- a/tests/e2e_ui/factory_v3/checks.py +++ b/tests/e2e_ui/factory_v3/checks.py @@ -8,7 +8,8 @@ only "none", "0" or "—", and no sideways scroll of the page. In the chat column the Room's answer cards and the chat's icons stay as they are. - :data:`CALM_CHECKS` holds a page to the calm rules: about 120 words outside - the chat, titles of six words or fewer and never a slug, Your move naming + the chat besides one short proof row (its latest demo), titles of six + words or fewer and never a slug, Your move naming what it unblocks by name (no slug, no bare id), each ask once (one answer button), and a chat with no system event inline and no resolved incident. - :func:`node` is a node page by ref; :func:`api` reads the factory's API as @@ -74,12 +75,17 @@ PAGE_WORDS_MAX: Final = 120 """Words a default page may show outside its chat (the level strip and the main column).""" +PROOF_WORDS_MAX: Final = 24 +"""Words the one proof row may hold (rule 10: verdict, length, title, what did not pass).""" TITLE_WORDS_MAX: Final = 6 CALM_CHECKS = """(node) => { const isWord = (w) => /[\\p{L}\\p{N}]/u.test(w); const count = (t) => t.split(/\\s+/).filter(isWord).length; const chatSel = '[data-testid="v3-chat"], [data-testid="v3-chat-pinned"]'; + // The proof row (rule 10: one per page that has demos) is held to its own few words. + const proofs = [...node.querySelectorAll('[data-testid="v3-proof"]')]; + const proofText = proofs.map((el) => el.innerText).join(' '); const outside = [ node.querySelector('[data-testid="v3-level-strip"], [data-testid="v3-phone-levels"]'), node.querySelector('[data-testid="v3-main"]'), @@ -111,15 +117,19 @@ } const chat = document.querySelector('[data-testid="v3-chat"], [data-testid="v3-chat-full"]'); const list = chat ? chat.querySelector('[data-testid="room-message-list"]') : null; + // Live cards for this viewer, and recorded demos (proof is conversation, never Activity). const live = '[data-testid^="decision-embed-"], [data-testid^="proposal-card-"], ' - + '[data-testid="v3-why"], [data-testid="v3-offer"], [data-testid="v3-applied"]'; + + '[data-testid="v3-why"], [data-testid="v3-offer"], [data-testid="v3-applied"], ' + + '[data-testid^="demo-card-"]'; const system = list ? [...list.querySelectorAll(':scope > li[data-kind="system"]')] .filter((li) => !li.querySelector(live)) .map((li) => li.innerText.slice(0, 80)) : []; return { - words: count(outside), + words: count(outside) - count(proofText), + proofRows: proofs.length, + proofWords: Math.max(0, ...proofs.map((el) => count(el.innerText))), longTitles: titles.filter((t) => count(t) > TITLE_WORDS_MAX), slugTitles: titles.filter((t) => slug.test(t)), machineMoves: unblocks.filter((t) => machine.test(t)), @@ -135,6 +145,8 @@ def assert_calm(found: dict[str, Any], *where: object) -> None: """A :data:`CALM_CHECKS` result that holds every calm rule.""" assert found["words"] <= PAGE_WORDS_MAX, (*where, "words", found["words"]) + assert found["proofRows"] <= 1, (*where, "proof rows", found["proofRows"]) + assert found["proofWords"] <= PROOF_WORDS_MAX, (*where, "proof words", found["proofWords"]) assert found["longTitles"] == [], (*where, "long titles", found["longTitles"]) assert found["slugTitles"] == [], (*where, "slugs as titles", found["slugTitles"]) assert found["machineMoves"] == [], (*where, "ids in Your move", found["machineMoves"]) @@ -195,6 +207,7 @@ def assert_clean(found: dict[str, Any], *where: object) -> None: "BODY_CHECKS", "CALM_CHECKS", "PAGE_WORDS_MAX", + "PROOF_WORDS_MAX", "api", "assert_calm", "assert_clean", diff --git a/tests/e2e_ui/factory_v3/demo_media.py b/tests/e2e_ui/factory_v3/demo_media.py new file mode 100644 index 00000000000..10940697f1d --- /dev/null +++ b/tests/e2e_ui/factory_v3/demo_media.py @@ -0,0 +1,150 @@ +""" +(sample) Posters and recordings for the seeded demos. Not a test module. + +A poster is an app frame drawn without fonts (a header, a sidebar, cards and +a chat column in the demo's accent), written as a PNG with the standard +library. A recording is the same frame over *seconds*, with one more chat +bubble each second, encoded as WebM by ``ffmpeg`` when it is on the PATH +(``None`` otherwise: the demo then has a poster and no recording). +""" + +from __future__ import annotations + +import functools +import shutil +import struct +import subprocess +import zlib +from pathlib import Path +from typing import Final + +WIDTH: Final = 640 +HEIGHT: Final = 360 + +Rgb = tuple[int, int, int] + +_PAGE: Final[Rgb] = (244, 246, 249) +_WHITE: Final[Rgb] = (255, 255, 255) +_LINE: Final[Rgb] = (227, 230, 235) +_SIDE: Final[Rgb] = (236, 239, 243) +_MUTED: Final[Rgb] = (207, 213, 222) +_INK: Final[Rgb] = (32, 36, 43) + + +def _hex(color: str) -> Rgb: + value = color.lstrip("#") + return (int(value[0:2], 16), int(value[2:4], 16), int(value[4:6], 16)) + + +class _Canvas: + def __init__(self, fill: Rgb) -> None: + self.rows = [bytearray(bytes(fill) * WIDTH) for _ in range(HEIGHT)] + + def rect(self, x: int, y: int, w: int, h: int, color: Rgb) -> None: + x0, x1 = max(0, x), min(WIDTH, x + w) + y0, y1 = max(0, y), min(HEIGHT, y + h) + if x0 >= x1: + return + run = bytes(color) * (x1 - x0) + for row in self.rows[y0:y1]: + row[x0 * 3 : x1 * 3] = run + + def png(self) -> bytes: + raw = b"".join(b"\x00" + bytes(row) for row in self.rows) + + def chunk(kind: bytes, data: bytes) -> bytes: + body = kind + data + return struct.pack(">I", len(data)) + body + struct.pack(">I", zlib.crc32(body)) + + header = struct.pack(">IIBBBBB", WIDTH, HEIGHT, 8, 2, 0, 0, 0) + return ( + b"\x89PNG\r\n\x1a\n" + + chunk(b"IHDR", header) + + chunk(b"IDAT", zlib.compress(raw, 6)) + + chunk(b"IEND", b"") + ) + + +def frame(accent: str, variant: int = 0, bubbles: int = 3) -> bytes: + """ + One app frame as a PNG. + + :param accent: The demo's colour, e.g. ``"#16a34a"``. + :param variant: Varies the card widths so demos do not all look alike. + :param bubbles: Chat bubbles shown (a recording adds one a second). + """ + tint = _hex(accent) + c = _Canvas(_PAGE) + c.rect(0, 0, WIDTH, 44, _WHITE) + c.rect(0, 44, WIDTH, 2, _LINE) + c.rect(16, 14, 16, 16, tint) + c.rect(44, 18, 88, 10, _MUTED) + c.rect(WIDTH - 104, 12, 84, 20, tint) + c.rect(0, 46, 116, HEIGHT - 46, _SIDE) + for i in range(6): + c.rect(16, 68 + i * 28, 80 - (i * 13 + variant * 9) % 28, 10, _MUTED) + c.rect(144, 64, 190 + variant * 16, 18, _INK) + for i in range(3): + y = 104 + i * 76 + w = 256 - ((i + variant) % 3) * 44 + c.rect(144, y, 304, 60, _LINE) + c.rect(146, y + 2, 300, 56, _WHITE) + c.rect(160, y + 16, w - 80, 10, (58, 64, 74)) + c.rect(160, y + 36, w, 8, _MUTED) + c.rect(468, 60, 156, 284, _LINE) + c.rect(470, 62, 152, 280, _WHITE) + for i in range(min(bubbles, 6)): + mine = i % 2 == 1 + c.rect(500 if mine else 484, 80 + i * 40, 108, 28, tint if mine else _SIDE) + c.rect(484, 304, 124, 24, _PAGE) + return c.png() + + +@functools.cache +def recording(accent: str, seconds: int, work: Path, variant: int = 0) -> bytes | None: + """ + *seconds* of the app frame, one more chat bubble each second, as WebM. + + :param work: Where the frames and the encoding are written. + :returns: The video, or ``None`` when ``ffmpeg`` is not installed, fails or + takes over two minutes. + """ + ffmpeg = shutil.which("ffmpeg") + if ffmpeg is None: + return None + where = work / f"{accent.lstrip('#')}-{seconds}-{variant}" + where.mkdir(parents=True, exist_ok=True) + for n in range(seconds): + (where / f"f{n:03d}.png").write_bytes(frame(accent, variant, bubbles=1 + n % 6)) + out = where / "demo.webm" + argv = [ + ffmpeg, + "-y", + "-loglevel", + "error", + "-framerate", + "1", + "-i", + str(where / "f%03d.png"), + "-c:v", + "libvpx", + "-deadline", + "realtime", + "-cpu-used", + "8", + "-b:v", + "300k", + "-pix_fmt", + "yuv420p", + "-r", + "5", + str(out), + ] + try: + done = subprocess.run(argv, capture_output=True, check=False, timeout=120) + except (OSError, subprocess.TimeoutExpired): + return None + return out.read_bytes() if done.returncode == 0 and out.exists() else None + + +__all__ = ["HEIGHT", "WIDTH", "frame", "recording"] diff --git a/tests/e2e_ui/factory_v3/seed.py b/tests/e2e_ui/factory_v3/seed.py index 4ef0e865a75..20d0b67155d 100644 --- a/tests/e2e_ui/factory_v3/seed.py +++ b/tests/e2e_ui/factory_v3/seed.py @@ -19,6 +19,10 @@ - the server closed three alarms today (D-46, D-47, D-48); - the base env (the integration project ``base-env``) runs the base's head, which holds every merge, and answers its smoke checks; +- the demo agent recorded #9876 (PASS, 0:14, after a FAIL at an earlier + commit; its card is in the PR's thread), #9881, #9882, the drafts' two PRs + and one shortlist fix, and I-1's acceptance run 1 (PARTIAL, step 6), each + with a poster and, where ``ffmpeg`` is installed, a recording; - Agent assistant's later waves hold "Google Ads integration" and a sales skill. Rows are written with the server's own stores (the same SQL tables the server @@ -36,7 +40,9 @@ import argparse import time +import uuid from dataclasses import dataclass +from pathlib import Path from typing import Any, Final import httpx @@ -45,12 +51,14 @@ from omnigent import factory as F from omnigent.db.db_models import ( SqlFactoryDecision, + SqlFactoryDemo, SqlFactoryIssue, SqlFactoryProject, SqlFactoryProposal, ) from omnigent.entities.factory import EventDraft from omnigent.server.auth import LEVEL_OWNER, RESERVED_USER_LOCAL +from omnigent.server.factory.actor import pr_thread from omnigent.server.factory.base import ( BaseHead, DeployedHead, @@ -59,10 +67,13 @@ head_label, head_value, ) +from omnigent.server.factory.demo_storage import demo_object_key from omnigent.stores.agent_store.sqlalchemy_store import SqlAlchemyAgentStore +from omnigent.stores.artifact_store import create_artifact_store from omnigent.stores.conversation_store.sqlalchemy_store import SqlAlchemyConversationStore from omnigent.stores.factory_store.sqlalchemy_store import SqlAlchemyFactoryStore from omnigent.stores.permission_store.sqlalchemy_store import SqlAlchemyPermissionStore +from tests.e2e_ui.factory_v3 import demo_media REPO: Final = "sample-org/sample-app" BASE: Final = "sample-main" @@ -289,6 +300,164 @@ def system(body: str, at: float, card: dict[str, Any]) -> None: ) +def _head(number: int) -> str: + """The head a seeded PR was at (its demos ran there).""" + return f"{number:07x}" + "0" * 33 + + +@dataclass(frozen=True) +class _DemoSpec: + """One demo the seed records: its PR (0: an acceptance run), verdict, length and steps.""" + + pr: int + slug: str + ago_s: float + title: str + seconds: int + accent: str + status: str = "PASS" + steps: tuple[tuple[int, str, str, str | None], ...] = () + head: str | None = None + run: int | None = None + + +def _demo_specs() -> list[_DemoSpec]: + passed = tuple((n, f"Step {n}", "pass", None) for n in range(1, 6)) + return [ + _DemoSpec( + 0, + "base-env", + 18 * 60, + "Acceptance I-1 run 1 PARTIAL", + 42, + "#c58a1a", + status="PARTIAL", + steps=( + (2, "Ask how the app is doing", "pass", None), + (3, "A flag-off user gets no assistant", "pass", None), + ( + 6, + "The discovery skill runs a live web search", + "fail", + "live search fails on the base env", + ), + ), + head=DEPLOYED_SHA, + run=1, + ), + _DemoSpec(9882, "connectors-core", 20 * 60, "Connector core", 18, "#3b82f6"), + _DemoSpec(9826, SHORTLIST[0], 25 * 60, "Env badge copy", 9, "#8b5cf6"), + _DemoSpec(9881, "skill-discovery", 40 * 60, "Discovery skill", 21, "#0ea5a4"), + _DemoSpec(9857, "build-tier-drafts-3", 2 * H + 5 * 60, "Part 2", 11, "#ef6c3a"), + _DemoSpec(9856, "build-tier-drafts-3", 2 * H + 6 * 60, "Part 1", 9, "#ef6c3a"), + _DemoSpec( + 9876, + "assistant-core", + 6 * H + 55 * 60, + "Sessions, tool registry, flagged chat API", + 14, + "#16a34a", + steps=passed, + ), + _DemoSpec( + 9876, + "assistant-core", + 7 * H + 28 * 60, + "Sessions and tool registry", + 12, + "#dc2626", + status="FAIL", + steps=(*passed[:3], (4, "Flagged chat API", "fail", "404 on /chat")), + head="01d9876" + "0" * 33, + ), + ] + + +def _demo_files( + store: Any, fid: str, demo_id: str, spec: _DemoSpec, variant: int, work: Path +) -> list[dict[str, Any]]: + """Write a demo's poster (and recording) into *store*; the demo row's file entries.""" + media = [("poster.png", "poster", "image/png", demo_media.frame(spec.accent, variant))] + video = demo_media.recording(spec.accent, spec.seconds, work, variant) + if video is not None: + media.append(("demo.webm", "video", "video/webm", video)) + entries: list[dict[str, Any]] = [] + for filename, kind, content_type, content in media: + file_id = uuid.uuid4().hex + key = demo_object_key(fid, demo_id, file_id, filename) + store.put(key, content) + entries.append( + { + "file_id": file_id, + "name": filename, + "kind": kind, + "caption": None, + "content_type": content_type, + "bytes": len(content), + "filename": filename, + "store": store.storage_location, + "key": key, + } + ) + return entries + + +def _demos( + db_uri: str, + fs: SqlAlchemyFactoryStore, + fid: str, + clock: float, + prs: dict[int, Any], + owners: dict[str, str], +) -> str | None: + """ + Record the story's demos with their files in a store next to the database + (a server opens a recorded store by its location). :returns: #9876's + current demo id (its card goes to the PR's thread), else ``None`` when + the database is not a file. + """ + if not db_uri.startswith("sqlite:///"): + return None + root = Path(db_uri.removeprefix("sqlite:///")).parent + store = create_artifact_store(str(root / "demo-files")) + work = root / "demo-media" + times: dict[str, dict[str, float]] = {} + core: str | None = None + for variant, spec in enumerate(_demo_specs()): + demo_id = uuid.uuid4().hex + pr = prs.get(spec.pr) + manifest: dict[str, Any] = { + "duration_s": float(spec.seconds), + "steps": [ + {"n": n, "title": title, "kind": "ui", "status": status, "note": note} + for n, title, status, note in spec.steps + ], + } + if spec.run is not None: + manifest["acceptance"] = {"initiative": "I-1", "run": spec.run} + row = fs.create_demo( + factory_id=fid, + project_slug=spec.slug, + repo=REPO, + pr_number=spec.pr, + head_sha=spec.head or _head(spec.pr), + session_id=owners.get(spec.slug, fid), + created_by_session=fid, + status=spec.status, + title=spec.title, + summary=f"{spec.title} (sample)", + files=_demo_files(store, fid, demo_id, spec, variant % 4, work), + manifest=manifest, + pr_id=pr.id if pr is not None else None, + demo_id=demo_id, + ) + times[row.id] = {"created_at": clock - spec.ago_s} + if spec.pr == 9876 and spec.status == "PASS": + core = row.id + _set_times(db_uri, SqlFactoryDemo, times) + return core + + def seed_factory_1340( db_uri: str, base_url: str, owner: str, *, now: float | None = None ) -> Seeded: @@ -615,6 +784,7 @@ def seed_factory_1340( for n, slug in zip((9826, 9828, 9829, 9827), SHORTLIST, strict=True) }, } + prs: dict[int, Any] = {} for number, (slug, task_id, title, at) in merged.items(): pr, _ = fs.upsert_pr( fid, @@ -626,12 +796,14 @@ def seed_factory_1340( state="merged", merged_at=int(at), merged_sha=f"{number:07x}" + "0" * 33, + head_sha=_head(number), task_id=task_id, ) + prs[number] = pr if task_id: fs.update_task(task_id, pr_id=pr.id) for number, part in ((9856, 1), (9857, 2)): - fs.upsert_pr( + prs[number], _ = fs.upsert_pr( fid, REPO, number, @@ -641,6 +813,7 @@ def seed_factory_1340( state="open", ready=True, ready_at=int(clock - 2 * H), + head_sha=_head(number), ) # ── Decisions D-1…D-44 (D-1…D-42 filler; D-37 an ask that blocks nothing) ── @@ -816,6 +989,19 @@ def seed_factory_1340( }, ) + # ── The demos (posters and recordings) and #9876's card in its PR's thread ── + core_demo = _demos(db_uri, fs, fid, clock, prs, leads) + if core_demo is not None: + fs.append_chat( + fid, + thread=pr_thread(prs[9876].id), + author_kind="system", + author="server", + body="demo PASS on #9876: sessions, tool registry, flagged chat API", + card={"type": "demo", "ref": "!9876", "id": core_demo}, + created_at=int(clock - 6 * H - 55 * 60), + ) + # ── An hour of work on assistant-core (its burn) ── fs.insert_span( fid, diff --git a/tests/e2e_ui/factory_v3/test_v3_proof.py b/tests/e2e_ui/factory_v3/test_v3_proof.py new file mode 100644 index 00000000000..4895a1e5fdd --- /dev/null +++ b/tests/e2e_ui/factory_v3/test_v3_proof.py @@ -0,0 +1,106 @@ +""" +Proof is never hidden (calm rule 10), on a real server with the 13:40 seed +(``seed.py``: posters and, where ``ffmpeg`` is installed, recordings in a +demo store the server opens by its location). + +- The project, the change and the feature open with one row, "Latest demo · + PASS · 0:14 ▶" with its poster (served by the demo file route); a click + plays the recording in place. +- The feature shows its acceptance run's recording, PARTIAL, with the step + that failed in one line. +- "n more demos" opens the Demos list for the subtree, newest first; Home's + feature rows carry one "▶" that opens their newest demo playing. +- The change's chat shows the demo card as conversation, not Activity. +""" + +from __future__ import annotations + +from collections.abc import Callable + +from playwright.sync_api import Page, expect + +from tests.e2e_ui.factory_v3.checks import CALM_CHECKS, api, assert_calm, node +from tests.e2e_ui.factory_v3.conftest import LOAD_MS, Cockpit, Theme, V3Server +from tests.e2e_ui.factory_v3.seed import Seeded + + +def _poster_loads(page: Page, row_selector: str) -> None: + """The row's poster is an image the demo file route served (not a broken one).""" + poster = page.locator(f"{row_selector} [data-testid='v3-proof-thumb'] img").first + expect(poster).to_be_visible(timeout=LOAD_MS) + page.wait_for_function( + "(img) => img.complete && img.naturalWidth > 0", arg=poster.element_handle() + ) + + +def test_pages_open_with_their_latest_demo_and_play_it_in_place( + open_cockpit: Callable[[int, int, Theme], Cockpit], + v3_server: V3Server, + factory: Seeded, +) -> None: + cockpit = open_cockpit(1440, 900, "light") + page = cockpit.page + demos = api(v3_server.base_url, factory.fid, "/n/product/demos") + has_video = all(d.get("video_url") for d in demos["items"]) + + cockpit.open(factory.project) + row = node(page, factory.project).get_by_test_id("v3-proof") + expect(row).to_have_attribute("data-verdict", "PASS") + expect(row.get_by_test_id("v3-proof-play")).to_have_text("Latest demo · PASS · 0:14") + expect(row).to_contain_text("Sessions, tool registry, flagged chat API · #9876") + expect(row.get_by_test_id("v3-proof-more")).to_have_text("1 more demo ›") + _poster_loads(page, f'[data-ref="{factory.project}"] [data-testid="v3-proof"]') + assert_calm(node(page, factory.project).evaluate(CALM_CHECKS), "project with proof") + + row.get_by_test_id("v3-proof-play").click() + player = row.get_by_test_id("v3-proof-player") + expect(player).to_be_visible() + if has_video: + video = player.locator("video") + page.wait_for_function("(v) => v.currentTime > 0.5", arg=video.element_handle()) + + cockpit.open(factory.feature) + feature = node(page, factory.feature).get_by_test_id("v3-proof") + expect(feature).to_have_attribute("data-verdict", "PARTIAL") + expect(feature).to_contain_text("Acceptance run 1") + expect(feature.get_by_test_id("v3-proof-note")).to_have_text( + "Step 6: live search fails on the base env" + ) + feature.get_by_test_id("v3-proof-more").click() + sheet = page.get_by_test_id("v3-demos-sheet") + expect(sheet).to_be_visible(timeout=LOAD_MS) + lines = sheet.get_by_test_id("v3-demo-line") + expect(lines).to_have_count(5) + expect(lines.first).to_have_attribute("data-verdict", "PARTIAL") + expect(lines.nth(4)).to_contain_text("earlier commit") + assert cockpit.errors == [], cockpit.errors + + +def test_home_links_each_features_newest_demo_and_the_chat_keeps_the_card( + open_cockpit: Callable[[int, int, Theme], Cockpit], + factory: Seeded, +) -> None: + cockpit = open_cockpit(1440, 900, "dark") + page = cockpit.page + + cockpit.open("product") + home = node(page, "product") + expect(home.get_by_test_id("v3-proof")).to_have_count(0) + chip = home.locator(f'[data-ref="{factory.feature}"] [data-testid="v3-demo-chip"]') + expect(chip).to_have_text("PARTIAL 0:42") + chip.click() + sheet = page.get_by_test_id("v3-demos-sheet") + expect(sheet).to_be_visible(timeout=LOAD_MS) + expect( + sheet.get_by_test_id("v3-demo-line").first.get_by_test_id("v3-proof-player") + ).to_be_visible() + page.keyboard.press("Escape") + + cockpit.open("PR-9876") + expect(node(page, "PR-9876").get_by_test_id("v3-proof")).to_have_attribute( + "data-verdict", "PASS" + ) + chat = page.get_by_test_id("v3-chat") + expect(chat.locator('[data-testid="demo-card-9876"]')).to_be_visible(timeout=LOAD_MS) + assert_calm(node(page, "PR-9876").evaluate(CALM_CHECKS), "change with its demo card") + assert cockpit.errors == [], cockpit.errors diff --git a/tests/server/factory/node_fixtures.py b/tests/server/factory/node_fixtures.py index 2742de04018..d6ea06f03b1 100644 --- a/tests/server/factory/node_fixtures.py +++ b/tests/server/factory/node_fixtures.py @@ -20,6 +20,9 @@ purpose for C-16; the real ask is D-45), D-46 and D-47 (skill-discovery's stall and claim alarms, closed when #9881 merged); - assistant-core's builder spent $2.50 in the last hour; +- the demo agent recorded #9876 (PASS, 0:14, after a FAIL at an earlier + commit), #9881, #9882, #9856 and #9857, and I-1's acceptance run 1 + (PARTIAL: step 6 fails, live search on the base env); - the factory has no goals, so the Goal level is hidden. :func:`scale_factory` is a synthetic factory of a given size (13 features, @@ -34,6 +37,7 @@ from omnigent.entities.factory import ( FactoryDecision, + FactoryDemo, FactoryInitiative, FactoryPr, FactoryProject, @@ -67,6 +71,8 @@ SHORTLIST_MESSAGE: Final = "a" * 31 + "2" DEPLOYED_SHA: Final = "817480d" + "0" * 33 SHORTLIST: Final = ("ux-env-badge-copy", "ux-slash-focus", "ux-status-page", "api-request-id") +CORE_HEAD: Final = "c0de9876" + "0" * 32 +"""#9876's head when it merged (its PASS demo ran there).""" WAVE_1: Final = ("assistant-core", "connectors-core", "skill-discovery", "assistant-panel-ux") @@ -145,6 +151,7 @@ def _pr( ready_at: int | None = None, base: str = BASE, title: str | None = None, + head_sha: str = "", ) -> FactoryPr: return FactoryPr( id=hex_id(number), @@ -161,6 +168,7 @@ def _pr( merged_at=merged_at if state == "merged" else None, merged_sha=f"{number:07x}" + "0" * 33 if state == "merged" else None, task_id=task_id, + head_sha=head_sha, created_at=AT_0601, updated_at=merged_at or AT_0601, ) @@ -387,7 +395,12 @@ def factory_1340() -> NodeFacts: ] prs = [ _pr( - 9876, "assistant-core", merged_at=AT_0655, task_id=hex_id(215), title="Assistant core" + 9876, + "assistant-core", + merged_at=AT_0655, + task_id=hex_id(215), + title="Assistant core", + head_sha=CORE_HEAD, ), _pr(9882, "connectors-core", task_id=hex_id(217)), _pr(9881, "skill-discovery", merged_at=AT_1303, task_id=hex_id(218)), @@ -644,10 +657,109 @@ def factory_1340() -> NodeFacts: ) ], closed_alarms=closed_alarms(), + demos=demos_1340(), ) ) +def _demo( + n: int, + slug: str, + pr: int, + at: int, + *, + title: str, + status: str = "PASS", + duration: float = 14.0, + head: str = "", + steps: list[dict[str, Any]] | None = None, + acceptance: dict[str, Any] | None = None, + video: bool = True, +) -> FactoryDemo: + files = [ + {"file_id": f"poster{n}", "name": "poster.png", "kind": "poster", "bytes": 9}, + *( + [{"file_id": f"video{n}", "name": "demo.webm", "kind": "video", "bytes": 99}] + if video + else [] + ), + ] + manifest: dict[str, Any] = {"duration_s": duration, "steps": steps or []} + if acceptance: + manifest["acceptance"] = acceptance + return FactoryDemo( + id=hex_id(900 + n), + factory_id=FID, + project_slug=slug, + repo=REPO, + pr_number=pr, + head_sha=head or f"{pr:07x}" + "0" * 33, + session_id=FID, + created_by_session=hex_id(705), + status=status, + title=title, + files=files, + manifest=manifest, + pr_id=hex_id(pr) if pr else None, + created_at=at, + ) + + +def demos_1340() -> list[FactoryDemo]: + """:returns: The demos recorded by 13:40, newest first.""" + passed = [{"n": i, "title": f"Step {i}", "status": "pass"} for i in range(1, 6)] + return [ + _demo( + 1, + "base-env", + 0, + AT_1322, + title="Acceptance I-1 run 1 PARTIAL", + status="PARTIAL", + duration=42.0, + head=DEPLOYED_SHA, + steps=[ + {"n": 2, "title": "Ask how the app is doing", "status": "pass"}, + {"n": 3, "title": "A flag-off user gets no assistant", "status": "pass"}, + { + "n": 6, + "title": "The discovery skill runs a live web search", + "status": "fail", + "note": "live search fails on the base env", + }, + ], + acceptance={"initiative": "I-1", "run": 1}, + ), + _demo(2, "connectors-core", 9882, AT_1322 - 60, title="Connector core", duration=18.0), + _demo(3, "skill-discovery", 9881, AT_1303 - 120, title="Discovery skill", duration=21.0), + _demo(4, "build-tier-drafts-3", 9857, AT_1200 - 300, title="Part 2", duration=11.0), + _demo(5, "build-tier-drafts-3", 9856, AT_1200 - 360, title="Part 1", duration=9.0), + _demo( + 6, + "assistant-core", + 9876, + AT_0655 - 600, + title="Sessions, tool registry, flagged chat API", + head=CORE_HEAD, + steps=passed, + ), + _demo( + 7, + "assistant-core", + 9876, + AT_0612, + title="Sessions and tool registry", + status="FAIL", + duration=12.0, + head="01d9876" + "0" * 33, + steps=[ + *passed[:3], + {"n": 4, "title": "Flagged chat API", "status": "fail", "note": "404 on /chat"}, + ], + ), + ] + + LIVE_ASK: Final = ( "i want to build a chat agent like feature. those agents can not only help with " "building, but also discovery, sales etc. for stuff like sales, promotions we might " @@ -995,6 +1107,7 @@ def scale_factory(features: int = 13, projects: int = 45, tasks: int = 360) -> N "AT_1303", "AT_1325", "BASE", + "CORE_HEAD", "DEPLOYED_SHA", "FID", "HEAD_AT", @@ -1004,6 +1117,7 @@ def scale_factory(features: int = 13, projects: int = 45, tasks: int = 360) -> N "SHORTLIST_MESSAGE", "WAVE_1", "closed_alarms", + "demos_1340", "factory_1340", "hex_id", "scale_factory", diff --git a/tests/server/factory/test_chat_feed.py b/tests/server/factory/test_chat_feed.py index 49432068969..80dd452d9f5 100644 --- a/tests/server/factory/test_chat_feed.py +++ b/tests/server/factory/test_chat_feed.py @@ -291,6 +291,8 @@ async def test_live_cards_stay_while_they_need_the_reader(cockpit: Cockpit) -> N }, ), "closed_alarm": _system(cockpit, "Closed", {"type": "closed_alarm", "ref": "D-9"}), + # A recorded demo is proof, never activity: even of a merged PR. + "demo": _system(cockpit, "Demo PASS", {"type": "demo", "ref": "!9102", "id": "d" * 32}), } said = {m["id"]: m for m in (await _feed(cockpit, "conversation"))["data"]} @@ -299,7 +301,8 @@ async def test_live_cards_stay_while_they_need_the_reader(cockpit: Cockpit) -> N assert said[rows["proposal"]]["ask"] == applying.ref and said[rows["proposal"]]["needs_you"] assert said[rows["pr"]]["ask"] == "merge:main" assert said[rows["offer"]]["needs_you"] is True - assert {rows[k] for k in ("why", "note", "undo")} <= said.keys() + assert {rows[k] for k in ("why", "note", "undo", "demo")} <= said.keys() + assert "needs_you" not in said[rows["demo"]] assert moved[rows["merged"]]["activity"] == {"kind": "pr", "closed": True} assert moved[rows["answered"]]["activity"] == {"kind": "decision", "closed": True} assert moved[rows["closed_alarm"]]["activity"]["closed"] is True diff --git a/tests/server/factory/test_node_proof.py b/tests/server/factory/test_node_proof.py new file mode 100644 index 00000000000..3ef132abf91 --- /dev/null +++ b/tests/server/factory/test_node_proof.py @@ -0,0 +1,373 @@ +""" +Proof on the node pages (``node_proof``, calm rule 10: "Proof is never hidden"). + +Over the 13:40 sample: a change and its task show the demo of the PR's current +head (not a newer one at an older commit), a project its newest across its PRs, +a feature its acceptance run's recording; Home's feature rows carry a "▶" chip; +"n more demos" lists the subtree newest first, in pages; PARTIAL and FAIL say +what did not pass in one line. Over a real store: the page and the Demos list +come from the route, and a viewer gets only the demos of the PRs they may read. +Every name is sample data. +""" + +from __future__ import annotations + +import dataclasses +import time +from collections.abc import AsyncIterator +from typing import Any + +import httpx +import pytest +import pytest_asyncio + +from omnigent.server.auth import LEVEL_READ +from omnigent.server.factory import events, initiatives, node_proof, nodes, snapshot +from omnigent.server.factory.node_views import NodeDemosView, validate, validate_page +from omnigent.server.factory.nodes import Tree, assert_levels +from omnigent.stores.factory_store import sqlalchemy_store as sql_store +from tests.server.factory.cockpit_helpers import Cockpit, error_code +from tests.server.factory.conftest import FactoryWorld +from tests.server.factory.node_fixtures import ( + AT_0655, + AT_1322, + CORE_HEAD, + FID, + demos_1340, + factory_1340, + hex_id, +) +from tests.server.factory.projects_helpers import REPO, header_cockpit, project, propose + + +@pytest.fixture() +def tree() -> Tree: + return assert_levels(Tree(factory_1340())) + + +def _proof(tree: Tree, ref: str) -> dict[str, Any] | None: + page = tree.page(ref) + assert page is not None, ref + return validate_page(page).get("proof") + + +# ── Which demo a page shows ──────────────────────────────────── + + +def test_a_change_and_its_task_show_the_demo_of_the_current_head(tree: Tree) -> None: + change = _proof(tree, "PR-9876") + task = _proof(tree, "T-15") + + assert change is not None and task == change + latest = change["latest"] + # The FAIL at an earlier commit is older history; the PASS ran at the head that merged. + assert (latest["verdict"], latest["duration"], latest["pr_tag"]) == ("PASS", "0:14", "#9876") + assert latest["title"] == "Sessions, tool registry, flagged chat API" + assert latest["steps"] == {"passed": 5, "total": 5} and "partial_note" not in latest + assert latest["poster_url"] == f"/v1/factory/demos/{hex_id(906)}/files/poster6" + assert latest["video_url"] == f"/v1/factory/demos/{hex_id(906)}/files/video6" + assert change["count"] == 2 + + +def test_a_newer_demo_at_an_older_commit_does_not_hide_the_current_one() -> None: + facts = factory_1340() + newer_old_head = dataclasses.replace( + next(d for d in facts.demos if d.status == "FAIL"), created_at=AT_0655 + 60 + ) + demos = sorted( + [*(d for d in facts.demos if d.id != newer_old_head.id), newer_old_head], + key=lambda d: d.created_at, + reverse=True, + ) + tree = Tree(dataclasses.replace(facts, demos=demos)) + + latest = (_proof(tree, "PR-9876") or {})["latest"] + + assert (latest["verdict"], latest.get("stale")) == ("PASS", None) + + +def test_a_change_with_no_demo_at_its_head_shows_its_newest_as_stale() -> None: + facts = factory_1340() + prs = [dataclasses.replace(p, head_sha="f" * 40) if p.number == 9876 else p for p in facts.prs] + tree = Tree(dataclasses.replace(facts, prs=prs)) + + latest = (_proof(tree, "PR-9876") or {})["latest"] + + assert (latest["verdict"], latest["stale"]) == ("PASS", True) + + +def test_a_project_shows_its_newest_across_its_prs(tree: Tree) -> None: + drafts = _proof(tree, "build-tier-drafts-3") + core = _proof(tree, "assistant-core") + + assert drafts is not None and core is not None + assert (drafts["latest"]["pr_number"], drafts["latest"]["title"], drafts["count"]) == ( + 9857, + "Part 2", + 2, + ) + assert core["latest"]["pr_number"] == 9876 and core["latest"]["verdict"] == "PASS" + + +def test_a_feature_shows_its_acceptance_run_and_what_was_partial(tree: Tree) -> None: + feature = _proof(tree, "I-1") + + assert feature is not None + latest = feature["latest"] + assert latest["title"] == "Acceptance run 1" and latest["acceptance_run"] == 1 + assert (latest["verdict"], latest["duration"], latest["ref"]) == ("PARTIAL", "0:42", "I-1") + assert latest["partial_note"] == "Step 6: live search fails on the base env" + assert "pr_number" not in latest + # The feature's demos: its acceptance run and its projects' (assistant-core's two, + # connectors-core's and skill-discovery's). + assert feature["count"] == 5 + + +def test_a_feature_with_no_acceptance_run_shows_its_newest_project_demo() -> None: + facts = factory_1340() + tree = Tree(dataclasses.replace(facts, demos=[d for d in facts.demos if d.pr_number])) + + latest = (_proof(tree, "I-1") or {})["latest"] + + assert (latest["pr_number"], latest["title"]) == (9882, "Connector core") + + +def test_levels_above_the_feature_show_no_row_but_count_their_demos(tree: Tree) -> None: + product = _proof(tree, "product") + plain = Tree(dataclasses.replace(factory_1340(), demos=[])) + + assert product == {"count": len(demos_1340())} + assert _proof(tree, "fx") is None + assert _proof(plain, "I-1") is None and _proof(plain, "PR-9876") is None + + +def test_home_rows_carry_one_play_chip_per_feature(tree: Tree) -> None: + home = tree.level_view("product", "feature") + assert home is not None + chips = {i["ref"]: i.get("demo") for g in home["groups"] for i in g["items"]} + feature = tree.page("I-1") + assert feature is not None + + assert chips["I-1"] == {"id": hex_id(901), "verdict": "PARTIAL", "duration": "0:42"} + # A project shown in place of its folded implicit feature carries its own. + assert chips["build-tier-drafts-3"] == { + "id": hex_id(904), + "verdict": "PASS", + "duration": "0:11", + } + assert chips["feature:from-C-13"] is None + # Rows below a feature stay one line: no chip on projects. + assert all("demo" not in k for k in feature["children"]["items"]) + + +# ── The Demos list ───────────────────────────────────────────── + + +def test_the_demos_list_is_the_subtree_newest_first_in_pages(tree: Tree) -> None: + node = tree.lookup("I-1") + assert node is not None + + first = validate(NodeDemosView, node_proof.demos_page(tree, node, limit=2)) + rest = validate(NodeDemosView, node_proof.demos_page(tree, node, cursor=first["next"])) + + assert first["total"] == 5 and first["latest"] == hex_id(901) + assert [d["id"] for d in first["items"]] == [hex_id(901), hex_id(902)] + assert [d["id"] for d in rest["items"]] == [hex_id(903), hex_id(906), hex_id(907)] + assert "next" not in rest + assert rest["items"][-1]["partial_note"] == "Step 4: 404 on /chat" + with pytest.raises(ValueError, match="cursor"): + node_proof.demos_page(tree, node, cursor="99") + + +def test_a_partial_note_names_what_did_not_pass() -> None: + demo = demos_1340()[0] + skipped = dataclasses.replace( + demo, + status="PARTIAL", + manifest={ + "steps": [ + {"n": 1, "title": "Open", "status": "pass"}, + {"n": 2, "title": "Pay", "status": "skipped", "note": "no test card"}, + ] + }, + ) + bare = dataclasses.replace(demo, status="FAIL", manifest={}, summary="Login broke. Rest ok.") + + assert node_proof.partial_note(skipped) == "Step 2 could not run: no test card" + assert node_proof.partial_note(bare) == "Login broke" + assert node_proof.partial_note(dataclasses.replace(demo, status="PASS")) is None + assert node_proof.duration_words(74.6) == "1:15" + + +def test_demos_of_projects_the_viewer_cannot_read_are_left_out() -> None: + tree = Tree(factory_1340(), hidden={"assistant-core"}) + + feature = _proof(tree, "I-1") + + assert feature is not None and feature["count"] == 3 + assert tree.lookup("PR-9876") is None + + +def test_only_demos_whose_files_the_viewer_may_read_are_loaded() -> None: + mine = dataclasses.replace(demos_1340()[1], session_id=hex_id(701)) + theirs = dataclasses.replace(demos_1340()[2], session_id=hex_id(702)) + root = demos_1340()[3] + + kept = node_proof.readable_demos([theirs, root, mine], [hex_id(701)], FID) + + assert [d.id for d in kept] == [mine.id, root.id] + assert len(node_proof.readable_demos([theirs, root, mine], None, FID)) == 3 + + +# ── Over a real store ────────────────────────────────────────── + + +@pytest_asyncio.fixture() +async def cockpit( + world: FactoryWorld, db_uri: str, monkeypatch: pytest.MonkeyPatch +) -> AsyncIterator[Cockpit]: + initiatives.register() + nodes.reset_cache() + snapshot.reset_cache() + async for value in header_cockpit(world, db_uri, monkeypatch): + yield value + nodes.reset_cache() + snapshot.reset_cache() + + +async def _get(cockpit: Cockpit, path: str, user: str = "alice", **params: Any) -> httpx.Response: + return await cockpit.client.get( + f"/v1/factory/factories/{cockpit.fid}/n/{path}", + params=params, + headers={"X-Test-User": user}, + ) + + +def _demo(cockpit: Cockpit, at: int, number: int, slug: str, **extra: Any) -> str: + """Record a demo at epoch second *at* (the list orders by when each was recorded).""" + with pytest.MonkeyPatch.context() as patch: + patch.setattr(sql_store, "now_s", lambda: at) + row = cockpit.store.create_demo( + factory_id=cockpit.fid, + project_slug=slug, + repo=REPO, + pr_number=number, + head_sha=extra.pop("head_sha", "a" * 40), + session_id=extra.pop("session_id", cockpit.fid), + created_by_session=cockpit.world.id("lead_a"), + files=[ + {"file_id": "p1", "name": "poster.png", "kind": "poster", "bytes": 1}, + {"file_id": "v1", "name": "demo.webm", "kind": "video", "bytes": 1}, + ], + **extra, + ) + return row.id + + +async def test_the_page_and_the_demos_list_over_a_real_store(cockpit: Cockpit) -> None: + message = cockpit.store.append_chat( + cockpit.fid, + thread="factory", + author_kind="human", + author="alice", + author_role="owner", + body="Build an in-app helper (sample).", + ) + resp = await propose( + cockpit, + {"op": "create", "project": project("core")}, + reply_to=f"chat_{message.id}", + thread="factory", + analysis={ + "initiative": { + "title": "Sample helper", + "acceptance": [{"step": "Open the panel", "expect": "it opens", "owner": "core"}], + "skeleton": "core", + "user_facing": ["core"], + } + }, + ) + assert resp.json()["status"] == "applied", resp.text + pr, _ = cockpit.store.upsert_pr( + cockpit.fid, REPO, 9001, project_slug="core", state="open", title="Core API" + ) + cockpit.store.update_pr(pr.id, head_sha="a" * 40) + now = int(time.time()) + old = _demo(cockpit, now - 400, 9001, "core", status="FAIL", title="Core API, first try") + new = _demo( + cockpit, + now - 300, + 9001, + "core", + title="Core API answers", + manifest={"duration_s": 14.2}, + pr_id=pr.id, + ) + run = _demo( + cockpit, + now - 200, + 0, + "core", + status="PARTIAL", + title="Acceptance I-1 run 1", + manifest={ + "acceptance": {"initiative": "I-1", "run": 1}, + "steps": [{"n": 1, "title": "Open the panel", "status": "fail"}], + }, + ) + hidden = _demo( + cockpit, + now - 100, + 9002, + "api-version", + title="Version route", + session_id=cockpit.world.id("pl"), + ) + cockpit.store.upsert_pr(cockpit.fid, REPO, 9002, project_slug="api-version") + nodes.reset_cache() + + core = (await _get(cockpit, "core")).json() + feature = validate_page((await _get(cockpit, "I-1")).json()) + listed = await _get(cockpit, "product/demos", limit=2) + more = await _get(cockpit, "product/demos", cursor=listed.json().get("next")) + missing = await _get(cockpit, "T-404/demos") + bad = await _get(cockpit, "product/demos", cursor="x") + cockpit.permissions.ensure_user("dave") + cockpit.permissions.grant("dave", cockpit.fid, LEVEL_READ) + reader = await _get(cockpit, "product/demos", user="dave") + + assert validate_page(core)["proof"]["latest"]["id"] == new + assert core["proof"]["latest"]["duration"] == "0:14" and core["proof"]["count"] == 2 + assert feature["proof"]["latest"]["id"] == run + assert feature["proof"]["latest"]["partial_note"] == "Step 1: Open the panel" + assert listed.status_code == 200, listed.text + page = validate(NodeDemosView, listed.json()) + assert [d["id"] for d in page["items"]] == [hidden, run] and page["total"] == 4 + assert [d["id"] for d in validate(NodeDemosView, more.json())["items"]] == [new, old] + assert (missing.status_code, error_code(missing)) == (404, "unknown_node") + assert (bad.status_code, error_code(bad)) == (400, "bad_cursor") + # The reader cannot read api-version's lead: its PRs and their demos are left out. + assert reader.status_code == 200 + assert hidden not in [d["id"] for d in reader.json()["items"]] + assert reader.json()["total"] == 3 + + +async def test_a_new_demo_shows_on_the_next_read(cockpit: Cockpit) -> None: + cockpit.store.upsert_pr(cockpit.fid, REPO, 9003, project_slug="api-version", title="Route") + before = validate_page((await _get(cockpit, "api-version")).json()) + _demo(cockpit, int(time.time()), 9003, "api-version", title="Route answers") + await events.emit( + cockpit.app.state, cockpit.fid, "demo.published", subject="pr:9003", summary="demo PASS" + ) + after = validate_page((await _get(cockpit, "api-version")).json()) + + assert "proof" not in before + assert after["proof"]["latest"]["title"] == "Route answers" + + +def test_the_sample_heads_are_what_the_fixture_says() -> None: + facts = factory_1340() + core = next(p for p in facts.prs if p.number == 9876) + + assert core.head_sha == CORE_HEAD + assert max(d.created_at for d in facts.demos) == AT_1322 diff --git a/web/src/components/factory/__fixtures__/nodeLevels.server.json b/web/src/components/factory/__fixtures__/nodeLevels.server.json index 997ff76c6fa..b6bfd555ba3 100644 --- a/web/src/components/factory/__fixtures__/nodeLevels.server.json +++ b/web/src/components/factory/__fixtures__/nodeLevels.server.json @@ -216,6 +216,11 @@ { "burn": 2.5, "cost": 51.13, + "demo": { + "duration": "0:42", + "id": "00000000000000000000000000000385", + "verdict": "PARTIAL" + }, "grandchildren": { "by_state": { "done": 2, @@ -340,6 +345,11 @@ { "base": "main", "cost": 38.4, + "demo": { + "duration": "0:11", + "id": "00000000000000000000000000000388", + "verdict": "PASS" + }, "folded_from": ["feature:from-C-12", "need:from-C-12"], "full_title": "build-tier-drafts-3", "grandchildren": { @@ -767,6 +777,11 @@ { "base": "main", "cost": 38.4, + "demo": { + "duration": "0:11", + "id": "00000000000000000000000000000388", + "verdict": "PASS" + }, "folded_from": ["feature:from-C-12", "need:from-C-12"], "full_title": "build-tier-drafts-3", "grandchildren": { diff --git a/web/src/components/factory/__fixtures__/nodePages.server.json b/web/src/components/factory/__fixtures__/nodePages.server.json index 98893fe39b9..2fa0d5540cc 100644 --- a/web/src/components/factory/__fixtures__/nodePages.server.json +++ b/web/src/components/factory/__fixtures__/nodePages.server.json @@ -530,6 +530,11 @@ }, "burn": 2.5, "cost": 51.13, + "demo": { + "duration": "0:42", + "id": "00000000000000000000000000000385", + "verdict": "PARTIAL" + }, "grandchildren": { "by_state": { "done": 2, @@ -655,6 +660,27 @@ "try_ready": true, "why_html": "An assistant inside the app that helps users build" }, + "proof": { + "count": 5, + "latest": { + "acceptance_run": 1, + "at": "2026-11-03T13:22:00Z", + "duration": "0:42", + "duration_s": 42.0, + "full_title": "Acceptance I-1 run 1 PARTIAL", + "id": "00000000000000000000000000000385", + "partial_note": "Step 6: live search fails on the base env", + "poster_url": "/v1/factory/demos/00000000000000000000000000000385/files/poster1", + "ref": "I-1", + "steps": { + "passed": 2, + "total": 3 + }, + "title": "Acceptance run 1", + "verdict": "PARTIAL", + "video_url": "/v1/factory/demos/00000000000000000000000000000385/files/video1" + } + }, "results": { "in_base_env": 3, "merged": 3, @@ -1061,6 +1087,26 @@ "title": "Assistant core", "why_html": "" }, + "proof": { + "count": 2, + "latest": { + "at": "2026-11-03T06:45:00Z", + "duration": "0:14", + "duration_s": 14.0, + "id": "0000000000000000000000000000038a", + "poster_url": "/v1/factory/demos/0000000000000000000000000000038a/files/poster6", + "pr_number": 9876, + "pr_tag": "#9876", + "ref": "PR-9876", + "steps": { + "passed": 5, + "total": 5 + }, + "title": "Sessions, tool registry, flagged chat API", + "verdict": "PASS", + "video_url": "/v1/factory/demos/0000000000000000000000000000038a/files/video6" + } + }, "results": { "in_base_env": 1, "merged": 1, @@ -1416,6 +1462,11 @@ { "burn": 2.5, "cost": 51.13, + "demo": { + "duration": "0:42", + "id": "00000000000000000000000000000385", + "verdict": "PARTIAL" + }, "grandchildren": { "by_state": { "done": 2, @@ -1707,6 +1758,9 @@ "title": "An assistant in the app", "why_html": "“I want an assistant in the app that helps with building, and later with discovery and sales too.”" }, + "proof": { + "count": 5 + }, "results": { "in_base_env": 3, "merged": 3, @@ -2305,6 +2359,26 @@ "why_html": "The shared core the panel, connectors and skills build on Wave 1 of Agent assistant (sample).", "working": [] }, + "proof": { + "count": 2, + "latest": { + "at": "2026-11-03T06:45:00Z", + "duration": "0:14", + "duration_s": 14.0, + "id": "0000000000000000000000000000038a", + "poster_url": "/v1/factory/demos/0000000000000000000000000000038a/files/poster6", + "pr_number": 9876, + "pr_tag": "#9876", + "ref": "PR-9876", + "steps": { + "passed": 5, + "total": 5 + }, + "title": "Sessions, tool registry, flagged chat API", + "verdict": "PASS", + "video_url": "/v1/factory/demos/0000000000000000000000000000038a/files/video6" + } + }, "results": { "in_base_env": 1, "merged": 1, @@ -2999,6 +3073,11 @@ "items": [ { "base": "main", + "demo": { + "duration": "0:09", + "id": "00000000000000000000000000000389", + "verdict": "PASS" + }, "folded_from": ["adhoc:00000000000000000000000000002680"], "full_title": "build-tier-drafts-3, part 1", "glyph": "PR", @@ -3063,6 +3142,11 @@ }, { "base": "main", + "demo": { + "duration": "0:11", + "id": "00000000000000000000000000000388", + "verdict": "PASS" + }, "folded_from": ["adhoc:00000000000000000000000000002681"], "full_title": "build-tier-drafts-3, part 2", "glyph": "PR", @@ -3160,6 +3244,11 @@ "node": { "base": "main", "cost": 38.4, + "demo": { + "duration": "0:11", + "id": "00000000000000000000000000000388", + "verdict": "PASS" + }, "folded_from": ["feature:from-C-12", "need:from-C-12"], "full_title": "build-tier-drafts-3", "grandchildren": { @@ -3238,6 +3327,22 @@ "title": "Build tier drafts 3", "why_html": "" }, + "proof": { + "count": 2, + "latest": { + "at": "2026-11-03T11:55:00Z", + "duration": "0:11", + "duration_s": 11.0, + "id": "00000000000000000000000000000388", + "poster_url": "/v1/factory/demos/00000000000000000000000000000388/files/poster4", + "pr_number": 9857, + "pr_tag": "#9857", + "ref": "PR-9857", + "title": "Part 2", + "verdict": "PASS", + "video_url": "/v1/factory/demos/00000000000000000000000000000388/files/video4" + } + }, "results": { "in_base_env": 0, "merged": 0, @@ -5303,6 +5408,11 @@ { "base": "main", "cost": 38.4, + "demo": { + "duration": "0:11", + "id": "00000000000000000000000000000388", + "verdict": "PASS" + }, "folded_from": ["feature:from-C-12", "need:from-C-12"], "full_title": "build-tier-drafts-3", "grandchildren": { @@ -5612,6 +5722,9 @@ "title": "Sample app (sample)", "why_html": "Builds sample-org/sample-app into sample-main. No goals are set yet, so that level is hidden and needs hang off the product." }, + "proof": { + "count": 7 + }, "results": { "in_base_env": 7, "merged": 7, diff --git a/web/src/components/factory/review/DemoPanel.tsx b/web/src/components/factory/review/DemoPanel.tsx index 59a5eb1d3c5..7e6b31b7dfe 100644 --- a/web/src/components/factory/review/DemoPanel.tsx +++ b/web/src/components/factory/review/DemoPanel.tsx @@ -65,7 +65,8 @@ function formatMs(ms: number | null): string | null { // ── Media ────────────────────────────────────────────────────── -function DemoMedia({ demo }: { demo: DemoView }) { +/** A demo's recording inline (or its still when it has none); the v3 proof rows play it too. */ +export function DemoMedia({ demo }: { demo: DemoView }) { const video = demoVideo(demo); const poster = demoPoster(demo); if (video) { diff --git a/web/src/components/factory/v3/NodeContext.tsx b/web/src/components/factory/v3/NodeContext.tsx index 41d84b99762..67b6dacd313 100644 --- a/web/src/components/factory/v3/NodeContext.tsx +++ b/web/src/components/factory/v3/NodeContext.tsx @@ -6,8 +6,8 @@ import { type ReactNode, createContext, useContext, useMemo } from "react"; import type { DecisionAction, DecisionRef, VerbArgs, VerbAvail } from "./nodeTypes"; import type { Above } from "./statusModel"; -/** A sheet a page part can open (`?peek=`): Needs you, the base env, Try it, the keys, a lineage. */ -export type SheetKind = "needs" | "env" | "try" | "keys" | "lineage"; +/** A sheet a page part can open (`?peek=`): Needs you, the base env, Try it, the keys, a lineage, demos. */ +export type SheetKind = "needs" | "env" | "try" | "keys" | "lineage" | "demos"; export interface NodeActions { factoryId: string; @@ -21,8 +21,8 @@ export interface NodeActions { onOpenDecision?: (decision: DecisionRef) => void; /** A verb button, a quick verb or words in the chat (with what they named). */ onVerb?: (verb: VerbAvail, args?: VerbArgs) => void; - /** Open a sheet; `at` names the node (Try it). */ - onSheet?: (sheet: SheetKind, at?: string) => void; + /** Open a sheet; `at` names the node (Try it, Demos); `play` a demo the Demos sheet plays. */ + onSheet?: (sheet: SheetKind, at?: string, play?: string) => void; /** The verb whose preview is open on this page (its button reads as pressed). */ openVerb?: string | null; } diff --git a/web/src/components/factory/v3/NodePage.test.tsx b/web/src/components/factory/v3/NodePage.test.tsx index cf497bf8e6f..83205d65621 100644 --- a/web/src/components/factory/v3/NodePage.test.tsx +++ b/web/src/components/factory/v3/NodePage.test.tsx @@ -61,6 +61,7 @@ describe("cockpit v3 · Your move, Details and answers", () => { expect(within(status).queryByTestId("v3-answer")).toBeNull(); }); + // A sweep over every page of the sample (as the calm gate's): over 5 s on a busy runner. it("never places one decision's answer in two places, on any node page", async () => { for (const ref of sampleEngine().refs()) { const view = renderNode(ref); @@ -79,7 +80,7 @@ describe("cockpit v3 · Your move, Details and answers", () => { expect({ ref, id, places: where.size }).toEqual({ ref, id, places: 1 }); view.unmount(); } - }); + }, 60_000); it("answers on a click, never on Enter", async () => { const onAnswer = vi.fn(); diff --git a/web/src/components/factory/v3/NodePage.tsx b/web/src/components/factory/v3/NodePage.tsx index 1584ede4c10..9a3a99c0d46 100644 --- a/web/src/components/factory/v3/NodePage.tsx +++ b/web/src/components/factory/v3/NodePage.tsx @@ -1,6 +1,7 @@ // One node's page in cockpit v3, calm: the level strip (or the phone's level -// row), the short title with Details and the ⋯ verbs, Your move, then its -// children one line each (or one level under it, grouped under parents). +// row), the short title with Details and the ⋯ verbs, Your move, its latest +// demo (proof is never hidden), then its children one line each (or one level +// under it, grouped under parents). // Nothing is drawn between nodes. The node's chat is the page's right column. import { @@ -36,6 +37,7 @@ import { useMarkSeen, useVisitSince } from "./nodeSteer"; import { DEFAULT_OVERLAYS, ParentNameContext } from "./NodeTile"; import { movedAsks, nodeName } from "./calm/calmModel"; import { YourMove, useYourMove } from "./calm/YourMove"; +import { ProofRow } from "./proof/ProofRow"; import { LensMenu } from "./calm/NodeMenu"; import type { ChildPage, NodePage as Page, TimelineView, VerbArgs, VerbAvail } from "./nodeTypes"; import { timelineSub } from "./lenses/TimelineLens"; @@ -400,6 +402,7 @@ function LoadedNodePage({ )} + {!level && } {!level && } diff --git a/web/src/components/factory/v3/NodeTile.tsx b/web/src/components/factory/v3/NodeTile.tsx index 24aa9ce3825..d8f3c986571 100644 --- a/web/src/components/factory/v3/NodeTile.tsx +++ b/web/src/components/factory/v3/NodeTile.tsx @@ -1,13 +1,15 @@ // A child, calm (rule 3): its short name, its ring or state dot, and at most -// one status phrase, shown only when it is not moving or needs someone. As a -// row (one line) or a tile (the Map lens; its own children as dots). Titles -// are real links: Enter or `l` steps in, middle-click opens a tab. No answer -// buttons here: the viewer's asks are the page's Your move. +// one status phrase, shown only when it is not moving or needs someone; a +// feature also carries its latest demo's "▶" (rule 10). As a row (one line) +// or a tile (the Map lens; its own children as dots). Titles are real links: +// Enter or `l` steps in, middle-click opens a tab. No answer buttons here: +// the viewer's asks are the page's Your move. import { createContext, useContext } from "react"; import { cn } from "@/lib/utils"; import { type Phrase, childPhrase, fullTitle, nameUnder } from "./calm/calmModel"; import { useNodeActions } from "./NodeContext"; +import { DemoChipButton } from "./proof/DemosSheet"; import { Glyph, NodeLink, Ring, StateBar, StateDot, hasRing } from "./nodeParts"; import { AMBER, STATE_ORDER } from "./nodeTokens"; import type { NodeState, NodeSummary } from "./nodeTypes"; @@ -168,6 +170,7 @@ export function NodeTile({ > {name} + {(phrase || node.grandchildren) && (
@@ -209,6 +212,7 @@ export function NodeRow({ > {name} + {phrase && } ); diff --git a/web/src/components/factory/v3/calm/calmGate.test.tsx b/web/src/components/factory/v3/calm/calmGate.test.tsx index d5bd707da9f..d1fc8c9cacc 100644 --- a/web/src/components/factory/v3/calm/calmGate.test.tsx +++ b/web/src/components/factory/v3/calm/calmGate.test.tsx @@ -7,7 +7,8 @@ import { type SampleOptions, sampleEngine } from "../fixture/sampleNodeSource"; import { TITLE_WORDS_MAX, holdsSlug, words } from "./calmModel"; // The calm rules as a gate, over every node page of the sample factory: a -// default page says little (about 120 words outside the chat), every title is +// default page says little (about 120 words outside the chat, besides its one +// short proof row), every title is // a short name (never a slug), no ask shows twice, Your move names what it // unblocks by name (never a slug or a bare id), and the chat holds the // conversation only. @@ -23,8 +24,14 @@ afterEach(() => { fake = null; }); -/** Words a default page may show outside its chat. */ +/** Words a default page may show outside its chat (the proof row aside). */ const PAGE_WORDS_MAX = 120; +/** + * Rule 10 puts one proof row on a page that has demos; it is held to its own + * few words (verdict, length, title, what did not pass, "n more demos"). + */ +const PROOF_WORDS_MAX = 24; +const PROOF = '[data-testid="v3-proof"]'; const CHAT = '[data-testid="v3-chat"], [data-testid="v3-chat-pinned"]'; @@ -38,6 +45,8 @@ const TITLES = [ interface CalmFindings { words: number; + proofRows: number; + proofWords: number; longTitles: string[]; slugTitles: string[]; repeatedAsks: string[]; @@ -55,6 +64,9 @@ function text(el: Element): string { function calmFindings(page: Element): CalmFindings { const copy = page.cloneNode(true) as Element; for (const chat of copy.querySelectorAll(CHAT)) chat.remove(); + const proofs = [...copy.querySelectorAll(PROOF)]; + const proofWords = Math.max(0, ...proofs.map((p) => words(text(p)).length)); + for (const proof of proofs) proof.remove(); const titles = [...copy.querySelectorAll(TITLES)].map(text); const longTitles = titles.filter((t) => words(t).length > TITLE_WORDS_MAX); const slugTitles = titles.filter(holdsSlug); @@ -65,7 +77,15 @@ function calmFindings(page: Element): CalmFindings { const repeatedAsks = [...page.querySelectorAll('[data-testid="v3-move-row"]')] .map((row) => row.getAttribute("data-ask") ?? "") .filter((ask) => ask && whole.split(ask).length - 1 > 1); - return { words: words(text(copy)).length, longTitles, slugTitles, repeatedAsks, machineMoves }; + return { + words: words(text(copy)).length, + proofRows: proofs.length, + proofWords, + longTitles, + slugTitles, + repeatedAsks, + machineMoves, + }; } async function settle(container: HTMLElement) { @@ -88,6 +108,15 @@ async function sweep(options: SampleOptions) { ref, words: "ok", }); + // One proof row at most, and a short one (rule 10). + expect({ ref, proofRows: found.proofRows <= 1 ? "ok" : found.proofRows }).toEqual({ + ref, + proofRows: "ok", + }); + expect({ + ref, + proofWords: found.proofWords <= PROOF_WORDS_MAX ? "ok" : found.proofWords, + }).toEqual({ ref, proofWords: "ok" }); expect({ ref, longTitles: found.longTitles }).toEqual({ ref, longTitles: [] }); expect({ ref, slugTitles: found.slugTitles }).toEqual({ ref, slugTitles: [] }); expect({ ref, machineMoves: found.machineMoves }).toEqual({ ref, machineMoves: [] }); @@ -149,7 +178,9 @@ describe("cockpit v3 · calm gate", () => { const system = [...list.querySelectorAll(':scope > li[data-kind="system"]')].filter( (li) => !li.querySelector( - '[data-testid="v3-why"], [data-testid="v3-offer"], [data-testid="v3-applied"]', + '[data-testid="v3-why"], [data-testid="v3-offer"], [data-testid="v3-applied"], ' + + // A recorded demo is proof: conversation, never Activity (rule 10). + '[data-testid^="demo-card-"]', ), ); expect({ ref, system: system.map((li) => li.textContent) }).toEqual({ ref, system: [] }); diff --git a/web/src/components/factory/v3/chat/chatLanes.test.ts b/web/src/components/factory/v3/chat/chatLanes.test.ts index d111956479f..985d6d0746f 100644 --- a/web/src/components/factory/v3/chat/chatLanes.test.ts +++ b/web/src/components/factory/v3/chat/chatLanes.test.ts @@ -66,6 +66,13 @@ describe("calm chat · conversation and activity", () => { ).toBe("activity"); }); + it("keeps a recorded demo in the conversation: proof is never activity", () => { + const demo = msg({ card: { type: "demo", ref: "!9876", id: "d1" } as ChatMessageView["card"] }); + expect(messageLane(demo, ctx)).toBe("conversation"); + // The server's lane wins when it sends one. + expect(messageLane({ ...demo, lane: "conversation" }, ctx)).toBe("conversation"); + }); + it("keeps live cards that need the viewer, and never repeats an ask Your move shows", () => { const card = (type: string, ref: string, id: string) => msg({ card: { type, ref, id } as ChatMessageView["card"] }); diff --git a/web/src/components/factory/v3/chat/chatLanes.ts b/web/src/components/factory/v3/chat/chatLanes.ts index aac57ea3bbe..99f658b4b3e 100644 --- a/web/src/components/factory/v3/chat/chatLanes.ts +++ b/web/src/components/factory/v3/chat/chatLanes.ts @@ -1,9 +1,10 @@ // The node chat is a conversation (calm rule 5): people's and agents' -// messages, plus live cards that need this viewer. System events (base moved, -// a member spawned, incidents, resolved or closed items, proposal diffs) fold -// into one "Activity" line. An ask the page's Your move already shows is not -// repeated here at all. The server marks each message's lane when it can -// (`lane`); older servers' messages are sorted here. +// messages, live cards that need this viewer and recorded demos (rule 10: +// proof is never hidden). System events (base moved, a member spawned, +// incidents, resolved or closed items, proposal diffs) fold into one +// "Activity" line. An ask the page's Your move already shows is not repeated +// here at all. The server marks each message's lane when it can (`lane`); +// older servers' messages are sorted here. import type { ChatMessageView, @@ -73,6 +74,8 @@ function liveCard(message: ChatMessageView, ctx: LaneContext): boolean { switch (card.type) { case "why": case "offer": + case "demo": + // A recorded demo is proof: always conversation, never Activity. return true; case "preview_applied": { const meta = card.meta as PreviewAppliedMeta | null | undefined; diff --git a/web/src/components/factory/v3/fixture/sampleData.ts b/web/src/components/factory/v3/fixture/sampleData.ts index 0d3fb225b15..31292816459 100644 --- a/web/src/components/factory/v3/fixture/sampleData.ts +++ b/web/src/components/factory/v3/fixture/sampleData.ts @@ -3,6 +3,8 @@ // Atlas proposal waiting on a human merge, and the factory tree beside it. // Names are neutral; ids are node refs as the API addresses them. +import { type RawDemo, sampleDemos } from "./sampleDemos"; + export type RawTree = "product" | "factory"; export interface RawWait { @@ -155,6 +157,8 @@ export interface RawFactory { nodes: RawNode[]; decisions: RawDecision[]; unread: Record; + /** The demos recorded so far, newest first (calm rule 10). */ + demos?: RawDemo[]; laterNote: Record; alarmsClosed: number; /** Who approves each verb, in words (a level's own words under `add`). */ @@ -282,6 +286,7 @@ export function sampleFactory(): RawFactory { }, nodes: sampleNodes(), decisions: sampleDecisions(), + demos: sampleDemos(), unread: { "I-1": 2, "assistant-core": 1, diff --git a/web/src/components/factory/v3/fixture/sampleDemos.ts b/web/src/components/factory/v3/fixture/sampleDemos.ts new file mode 100644 index 00000000000..bfe81f52e51 --- /dev/null +++ b/web/src/components/factory/v3/fixture/sampleDemos.ts @@ -0,0 +1,167 @@ +// (sample) The demos the sample factory recorded by 13:40, each placed on its +// node: #9876's PASS (0:14, after a FAIL at an earlier commit), #9881, #9882, +// the drafts' two PRs, one shortlist fix, and Agent assistant's acceptance +// run 1 (PARTIAL: step 6, live search on the base env). Posters are drawn +// here (an app frame, no external file); the recordings' URLs answer nothing. + +/** One sample demo; `at` is the node it belongs to (a change, or a feature for a run). */ +export interface RawDemo { + id: string; + at: string; + verdict: "PASS" | "PARTIAL" | "FAIL" | "ERROR"; + title: string; + full?: string; + seconds: number; + when: string; + pr?: number; + run?: number; + stale?: boolean; + steps?: [number, number]; + note?: string; + /** The poster's accent (the screen the demo ends on). */ + accent: string; +} + +function svg(accent: string, variant: number): string { + const rows = [0, 1, 2, 3, 4] + .map( + (i) => + ``, + ) + .join(""); + const cards = [0, 1, 2] + .map((i) => { + const y = 52 + i * 38; + const w = 128 - ((i + variant) % 3) * 22; + return ( + `` + + `` + + `` + ); + }) + .join(""); + return ( + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `${rows}` + + `${cards}` + + `` + + `` + + `` + + `` + + `` + + `` + ); +} + +/** A poster for a sample demo: an app frame drawn inline (a data URL). */ +export function samplePoster(accent: string, variant = 0): string { + return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg(accent, variant))}`; +} + +const DAY = "2026-11-03"; + +/** The sample's demos, newest first. */ +export function sampleDemos(): RawDemo[] { + return [ + { + id: "demo-accept-1", + at: "I-1", + verdict: "PARTIAL", + title: "Acceptance run 1", + full: "Acceptance I-1 run 1 on the base env", + seconds: 42, + when: `${DAY}T13:22:00Z`, + run: 1, + steps: [2, 3], + note: "Step 6: live search fails on the base env", + accent: "#c58a1a", + }, + { + id: "demo-9882", + at: "PR-9882", + verdict: "PASS", + title: "Connector core", + seconds: 18, + when: `${DAY}T13:21:00Z`, + pr: 9882, + steps: [4, 4], + accent: "#3b82f6", + }, + { + id: "demo-9826", + at: "PR-9826", + verdict: "PASS", + title: "Env badge copy", + seconds: 9, + when: `${DAY}T13:12:00Z`, + pr: 9826, + steps: [2, 2], + accent: "#8b5cf6", + }, + { + id: "demo-9881", + at: "PR-9881", + verdict: "PASS", + title: "Discovery skill", + seconds: 21, + when: `${DAY}T13:01:00Z`, + pr: 9881, + steps: [3, 3], + accent: "#0ea5a4", + }, + { + id: "demo-9857", + at: "PR-9857", + verdict: "PASS", + title: "Part 2", + full: "build-tier-drafts-3, part 2", + seconds: 11, + when: `${DAY}T11:55:00Z`, + pr: 9857, + steps: [3, 3], + accent: "#ef6c3a", + }, + { + id: "demo-9856", + at: "PR-9856", + verdict: "PASS", + title: "Part 1", + full: "build-tier-drafts-3, part 1", + seconds: 9, + when: `${DAY}T11:54:00Z`, + pr: 9856, + steps: [3, 3], + accent: "#ef6c3a", + }, + { + id: "demo-9876", + at: "PR-9876", + verdict: "PASS", + title: "Sessions, tool registry, flagged chat API", + seconds: 14, + when: `${DAY}T06:45:00Z`, + pr: 9876, + steps: [5, 5], + accent: "#16a34a", + }, + { + id: "demo-9876-first", + at: "PR-9876", + verdict: "FAIL", + title: "Sessions and tool registry", + seconds: 12, + when: `${DAY}T06:12:00Z`, + pr: 9876, + stale: true, + steps: [3, 4], + note: "Step 4: 404 on /chat", + accent: "#dc2626", + }, + ]; +} diff --git a/web/src/components/factory/v3/fixture/sampleEngine.ts b/web/src/components/factory/v3/fixture/sampleEngine.ts index a14637b0beb..aedbc119c38 100644 --- a/web/src/components/factory/v3/fixture/sampleEngine.ts +++ b/web/src/components/factory/v3/fixture/sampleEngine.ts @@ -10,11 +10,15 @@ import type { ClosedAlarm, Crumb, DecisionRef, + DemoChip, + DemoRef, LevelCount, LevelGroup, LevelView, + NodeDemos, NodeFull, NodePage, + NodeProof, NodeState, NodeSummary, Progress, @@ -26,6 +30,7 @@ import type { WhyItem, } from "../nodeTypes"; import type { RawDecision, RawFactory, RawLevel, RawNode, RawTree, RawWait } from "./sampleData"; +import { type RawDemo, samplePoster } from "./sampleDemos"; type RawState = NodeState | "held"; @@ -1045,6 +1050,7 @@ export class SampleEngine { ? this.kids(n.id).find((k) => k.lvl === "change") : undefined; const kindName = this.data.kinds[n.kind]; + const demo = this.demoChip(n); return { ref: n.id, level: this.levelKey(n), @@ -1077,6 +1083,86 @@ export class SampleEngine { ...(owner ? { owner: { name: owner, working: n.lvl === "project" && s === "moving" } } : {}), ...(team ? { working: team.working, idle: team.idle } : {}), ...(chg?.tag ? { change: { ref: chg.id, label: chg.tag } } : {}), + ...(demo ? { demo } : {}), + }; + } + + // ── Proof: recorded demos (calm rule 10) ───────────────────── + + /** The demos of a node's subtree, newest first. */ + protected demosUnder(id: string): RawDemo[] { + return (this.data.demos ?? []) + .filter((d) => d.at === id || this.ancestors(d.at).some((a) => a.id === id)) + .sort((a, b) => b.when.localeCompare(a.when)); + } + + protected demoRef(d: RawDemo): DemoRef { + const variant = [...d.id].reduce((sum, c) => sum + c.charCodeAt(0), 0) % 4; + return { + id: d.id, + verdict: d.verdict, + title: d.title, + ref: d.at, + at: d.when, + ...(d.full ? { full_title: d.full } : {}), + duration_s: d.seconds, + duration: `${Math.floor(d.seconds / 60)}:${String(d.seconds % 60).padStart(2, "0")}`, + poster_url: samplePoster(d.accent, variant), + video_url: `/v1/factory/demos/${d.id}/files/recording`, + ...(d.pr ? { pr_number: d.pr, pr_tag: `#${d.pr}` } : {}), + ...(d.run != null ? { acceptance_run: d.run } : {}), + ...(d.stale ? { stale: true } : {}), + ...(d.steps ? { steps: { passed: d.steps[0], total: d.steps[1] } } : {}), + ...(d.note ? { partial_note: d.note } : {}), + }; + } + + /** What a page shows: a feature its acceptance run, else the newest at its PRs' current heads. */ + protected latestDemo(n: RawNode): RawDemo | undefined { + if (!["feature", "project", "task", "change"].includes(n.lvl)) return undefined; + const demos = this.demosUnder(n.id); + const runs = demos.filter((d) => d.run != null); + if (n.lvl === "feature" && runs.length) return runs[0]; + const prs = demos.filter((d) => d.run == null); + return prs.find((d) => !d.stale) ?? prs[0]; + } + + protected proofOf(n: RawNode): NodeProof | undefined { + const count = this.demosUnder(n.id).length; + if (!count) return undefined; + const latest = this.latestDemo(n); + return { count, ...(latest ? { latest: this.demoRef(latest) } : {}) }; + } + + /** A feature row's "▶" (and a node shown in place of its folded feature). */ + protected demoChip(n: RawNode): DemoChip | undefined { + const feature = + n.lvl === "feature" || + this.ancestors(n.id).some((a) => a.lvl === "feature" && this.isFolded(a)); + const latest = feature ? this.latestDemo(n) : undefined; + if (!latest) return undefined; + const ref = this.demoRef(latest); + return { + id: ref.id, + verdict: ref.verdict, + ...(ref.duration ? { duration: ref.duration } : {}), + }; + } + + /** `GET …/n/{ref}/demos`: the subtree's demos, newest first, `size` a page. */ + demos(ref: string, cursor?: string, size = 20): NodeDemos | null { + const raw = this.N(ref); + if (!raw || raw.closed) return null; + const n = this.fold(raw); + const all = this.demosUnder(n.id); + const offset = cursor ? Number(cursor) || 0 : 0; + const latest = this.latestDemo(n); + return { + ref: n.id, + items: all.slice(offset, offset + size).map((d) => this.demoRef(d)), + total: all.length, + ...(latest ? { latest: latest.id } : {}), + ...(offset + size < all.length ? { next: String(offset + size) } : {}), }; } @@ -1403,6 +1489,7 @@ export class SampleEngine { }; const chips = this.chipsOf(n); const responder = this.responder(n); + const proof = this.proofOf(n); return { node, breadcrumb: this.ancestors(n.id).map((a) => this.crumb(a)), @@ -1422,6 +1509,7 @@ export class SampleEngine { }, version: `sample-${this.version}`, ...(n.lvl === "root" ? { home: this.data.trees[n.tree].home } : {}), + ...(proof ? { proof } : {}), }; } diff --git a/web/src/components/factory/v3/fixture/sampleNodeSource.ts b/web/src/components/factory/v3/fixture/sampleNodeSource.ts index 7a7b9a3bd9c..93fa250e493 100644 --- a/web/src/components/factory/v3/fixture/sampleNodeSource.ts +++ b/web/src/components/factory/v3/fixture/sampleNodeSource.ts @@ -66,6 +66,7 @@ export function sampleNodeSource( lineage: async (_fid, ref) => engine.lineage(ref), timeline: async (_fid, ref) => engine.timeline(ref), money: async (_fid, ref) => engine.money(ref), + demos: async (_fid, ref, _signal, cursor) => engine.demos(ref, cursor), levels: async () => engine.levelsInfo(), team: async (_fid, ref) => engine.teamSheet(ref), }; diff --git a/web/src/components/factory/v3/nodeSource.ts b/web/src/components/factory/v3/nodeSource.ts index 3a6c4385ba0..7339dd9ff93 100644 --- a/web/src/components/factory/v3/nodeSource.ts +++ b/web/src/components/factory/v3/nodeSource.ts @@ -1,6 +1,6 @@ // Where v3 pages read nodes from: the server's node API -// (`GET /v1/factory/factories/{fid}/n/{ref}`, `…/n/{ref}/level/{level}` and -// the lenses `…/n/{ref}/lineage|timeline|money`). +// (`GET /v1/factory/factories/{fid}/n/{ref}`, `…/n/{ref}/level/{level}`, the +// lenses `…/n/{ref}/lineage|timeline|money` and the demos `…/n/{ref}/demos`). // Stories and tests pass their own source (the sample factory) through // `NodeSourceProvider`; the sample never ships in the live path. // @@ -24,6 +24,7 @@ import type { LineageView, MoneyView, NeedsYouList, + NodeDemos, NodePage, TeamSheet, TimelineView, @@ -103,6 +104,13 @@ export interface NodeSource { timeline?: (factoryId: string, ref: string, signal?: AbortSignal) => Promise; /** The children's cost against their budgets (`GET …/n/{ref}/money`). */ money?: (factoryId: string, ref: string, signal?: AbortSignal) => Promise; + /** The subtree's recorded demos, newest first (`GET …/n/{ref}/demos`; `cursor`: a page's `next`). */ + demos?: ( + factoryId: string, + ref: string, + signal?: AbortSignal, + cursor?: string, + ) => Promise; /** Both trees' levels as the definition names them (`GET …/levels`). */ levels?: (factoryId: string, signal?: AbortSignal) => Promise; /** @@ -236,6 +244,10 @@ export const liveNodeSource: NodeSource = { ); return view && normalizeMoney(view); }, + demos: async (fid, ref, signal, cursor) => { + const url = `${nodePath(fid, ref)}/demos${query("cursor", cursor)}`; + return readOrNull(await authenticatedFetch(url, { signal })); + }, levels: async (fid, signal) => { const res = await authenticatedFetch(`${factoryPath(fid)}/levels`, { signal }); if (!res.ok) throw await factoryErrorFromResponse(res); diff --git a/web/src/components/factory/v3/nodeTypes.ts b/web/src/components/factory/v3/nodeTypes.ts index 2615da1b324..8a70ca5ab8b 100644 --- a/web/src/components/factory/v3/nodeTypes.ts +++ b/web/src/components/factory/v3/nodeTypes.ts @@ -245,6 +245,8 @@ export interface NodeSummary { idle?: number; /** The node's one change, shown inline in a task row ("#9876 · merged"). (client) */ change?: { ref: string; label: string }; + /** A feature row's "▶" (calm rule 10): its latest demo, played from the Demos sheet. */ + demo?: DemoChip; } export interface AcceptanceStep { @@ -416,6 +418,8 @@ export interface NodePage { * Absent on older servers: the page derives it from the status line and Needs you. */ your_move?: YourMove; + /** The subtree's recorded demos (calm rule 10): the one the page shows and how many. */ + proof?: NodeProof; } // ── Your move (calm rule 1) ───────────────────────────────────── @@ -715,3 +719,61 @@ export interface LevelsInfo { fold_single_child?: string; version: number; } + +// ── Proof: recorded demos (calm rule 10) ────────────────────────── + +/** A demo's verdict, as the demo agent recorded it. */ +export type DemoVerdict = "PASS" | "PARTIAL" | "FAIL" | "ERROR"; + +/** One recorded demo as a page row or a Demos list line shows it. */ +export interface DemoRef { + id: string; + verdict: DemoVerdict; + /** A few words ("Sessions, tool registry, flagged chat API", "Acceptance run 1"). */ + title: string; + /** The node it belongs to: its change, its feature (an acceptance run) or its project. */ + ref: string; + /** When it was recorded (ISO). */ + at: string; + full_title?: string; + duration_s?: number; + /** "0:14". */ + duration?: string; + /** The demo file routes (authenticated); absent when the demo recorded none. */ + poster_url?: string; + video_url?: string; + pr_number?: number; + /** "#9876". */ + pr_tag?: string; + /** The acceptance run it records. */ + acceptance_run?: number; + /** Recorded at an earlier commit than its PR's head. */ + stale?: boolean; + steps?: { passed: number; total: number }; + /** What did not pass, in one line (PARTIAL, FAIL). */ + partial_note?: string; +} + +/** `NodePage.proof`: the latest demo (where the level shows one) and the subtree's count. */ +export interface NodeProof { + count: number; + latest?: DemoRef; +} + +/** `NodeSummary.demo`: a feature row's "▶". */ +export interface DemoChip { + id: string; + verdict: DemoVerdict; + duration?: string; +} + +/** `GET …/n/{ref}/demos`: the subtree's demos, newest first. */ +export interface NodeDemos { + ref: string; + items: DemoRef[]; + total: number; + /** The id of the demo the page shows as its row. */ + latest?: string; + /** Cursor of the next page. */ + next?: string; +} diff --git a/web/src/components/factory/v3/proof/DemosSheet.tsx b/web/src/components/factory/v3/proof/DemosSheet.tsx new file mode 100644 index 00000000000..47893f92135 --- /dev/null +++ b/web/src/components/factory/v3/proof/DemosSheet.tsx @@ -0,0 +1,164 @@ +// The Demos sheet (`?peek=demos&at=`, `&demo=` to play one): every +// recorded demo of a node's subtree, newest first, one line each (verdict, +// title, length, PR), each playing in place. "n more demos" on a page and the +// "▶" chip on Home's feature rows open it. + +import { useState } from "react"; +import { PlayIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { DemoStatusTag } from "../../board/DemoThumb"; +import { useFactoryNow } from "../../factoryClock"; +import { Peek } from "../../ui/Peek"; +import { nodeName } from "../calm/calmModel"; +import { ago } from "../envWords"; +import { useNodeActions } from "../NodeContext"; +import { useNode } from "../nodeQueries"; +import type { DemoChip, DemoRef, NodeSummary } from "../nodeTypes"; +import { DemoWords, NOTE_TONE, ProofPlayer, ProofThumb } from "./ProofRow"; +import { chipLabel, notPassed, playLabel, useNodeDemos } from "./proofModel"; + +/** One demo: verdict, title, length and PR on one line; a click plays it in place. */ +export function DemoLine({ demo, open: first = false }: { demo: DemoRef; open?: boolean }) { + const [open, setOpen] = useState(first); + const now = useFactoryNow(); + const toggle = () => setOpen((on) => !on); + const when = ago(demo.at, now); + return ( +
  • +
    + +
    +
    + {" "} + {" "} + {demo.duration && ( + + {demo.duration} + + )} +
    {" "} + {(demo.pr_tag || demo.pr_number || demo.stale) && ( +

    + +

    + )} + {notPassed(demo.verdict) && demo.partial_note && ( +

    + {demo.partial_note} +

    + )} +
    +
    + {open && ( +
    + +
    + )} +
  • + ); +} + +export function DemosSheet({ + factoryId, + open, + at, + play, + onClose, +}: { + factoryId: string; + open: boolean; + at: string; + /** The demo to play as the sheet opens (a feature row's "▶"). */ + play?: string | null; + onClose: () => void; +}) { + const page = useNode(open ? at : "", factoryId); + const list = useNodeDemos(at, open, factoryId); + const name = page.data ? nodeName(page.data.node) : null; + const total = list.total; + return ( + !next && onClose()} + wide + title={name ? `Demos of ${name}` : "Demos"} + description={total ? `${total} recorded, newest first` : undefined} + > + {list.error ? ( +

    + The demos could not be read:{" "} + {list.error instanceof Error ? list.error.message : String(list.error)} +

    + ) : list.pending ? ( +
    + ) : list.missing ? ( +

    + No node has the ref {at} here. +

    + ) : !list.items.length ? ( +

    No demo is recorded here yet.

    + ) : ( +
    +
      + {list.items.map((demo) => ( + + ))} +
    + {list.more && ( + + )} +
    + )} + + ); +} + +/** A feature row's "▶" (Home links each feature's newest demo): opens the Demos sheet playing it. */ +export function DemoChipButton({ node, name }: { node: NodeSummary; name: string }) { + const { onSheet } = useNodeActions(); + const chip: DemoChip | undefined = node.demo; + if (!chip || !onSheet) return null; + const failed = notPassed(chip.verdict); + return ( + + ); +} diff --git a/web/src/components/factory/v3/proof/Proof.stories.tsx b/web/src/components/factory/v3/proof/Proof.stories.tsx new file mode 100644 index 00000000000..8c521ad2efb --- /dev/null +++ b/web/src/components/factory/v3/proof/Proof.stories.tsx @@ -0,0 +1,63 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { userEvent, within } from "storybook/test"; +import { type V3StoryOptions, V3PageStory } from "../fixture/v3StoryKit"; + +// Proof is never hidden (calm rule 10), over the sample factory: a project, a +// change and a feature open with "Latest demo · PASS · 0:14 ▶" and the poster; +// the feature's acceptance run is PARTIAL and says what failed; Home links +// each feature's newest demo from its row ("▶"); "n more demos" opens the +// Demos list for the subtree; a click plays a demo in place. + +const meta = { + title: "Factory/Cockpit v3/Proof", + component: V3PageStory, + parameters: { layout: "fullscreen" }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const page = (args: V3StoryOptions): Story => ({ args }); +const phone = (args: V3StoryOptions): Story => ({ + args, + globals: { viewport: { value: "mobile2" } }, +}); + +/** A project: its newest demo across its PRs (#9876 PASS 0:14), and "1 more demo". */ +export const Project = page({ nodeRef: "assistant-core" }); +export const ProjectDark = page({ nodeRef: "assistant-core", theme: "dark" }); +export const ProjectPhone = phone({ nodeRef: "assistant-core" }); + +/** A feature: its acceptance run's recording, PARTIAL, with the step that failed. */ +export const FeaturePartial = page({ nodeRef: "I-1" }); +export const FeaturePartialDark = page({ nodeRef: "I-1", theme: "dark" }); +export const FeaturePartialPhone = phone({ nodeRef: "I-1", theme: "dark" }); + +/** A change: the demo of its PR's current head (the FAIL at an earlier commit is history). */ +export const Change = page({ nodeRef: "PR-9876" }); + +/** Home: one "▶" on each feature row that has a demo, PARTIAL said in words. */ +export const HomeChips = page({}); +export const HomeChipsDark = page({ theme: "dark" }); +export const HomeChipsPhone = phone({}); + +/** "4 more demos": the feature's subtree, newest first, one line each. */ +export const DemosList = page({ nodeRef: "I-1", search: "?peek=demos&at=I-1" }); +export const DemosListDark = page({ nodeRef: "I-1", search: "?peek=demos&at=I-1", theme: "dark" }); +export const DemosListPhone = phone({ nodeRef: "I-1", search: "?peek=demos&at=I-1" }); + +/** Home's "▶" on Agent assistant: the list opens with its acceptance run playing. */ +export const PlayFromHome = page({ search: "?peek=demos&at=I-1&demo=demo-accept-1" }); + +/** A click on the row plays the demo in place (the PR page's player). */ +export const PlayingInPlace: Story = { + args: { nodeRef: "assistant-core" }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(await canvas.findByTestId("v3-proof-play", {}, { timeout: 5000 })); + }, +}; +export const PlayingInPlaceDark: Story = { + ...PlayingInPlace, + args: { ...PlayingInPlace.args, theme: "dark" }, +}; diff --git a/web/src/components/factory/v3/proof/ProofRow.tsx b/web/src/components/factory/v3/proof/ProofRow.tsx new file mode 100644 index 00000000000..838bf7193a2 --- /dev/null +++ b/web/src/components/factory/v3/proof/ProofRow.tsx @@ -0,0 +1,224 @@ +// Proof is never hidden (calm rule 10): a change, its task, a project and a +// feature open with one row under Your move, "Latest demo · PASS · 0:14 ▶", +// with the poster. A click plays it in place (the PR page's player); a PARTIAL +// or FAIL says what did not pass in one line; "n more demos" opens the Demos +// sheet for the subtree. + +import { Fragment, type ReactNode, useEffect, useRef, useState } from "react"; +import { ClapperboardIcon, PlayIcon, XIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { DemoStatusTag } from "../../board/DemoThumb"; +import { DemoMedia } from "../../review/DemoPanel"; +import { useNodeActions } from "../NodeContext"; +import { NodeLink } from "../nodeParts"; +import type { DemoRef, NodePage } from "../nodeTypes"; +import { asDemoView, moreDemos, moreWords, notPassed, playLabel } from "./proofModel"; + +/** The tone of a verdict's one-line note: amber when partial, red when it failed. */ +export const NOTE_TONE: Record = { + PASS: "text-muted-foreground", + PARTIAL: "text-status-yellow-fg", + FAIL: "text-status-red-fg", + ERROR: "text-status-red-fg", +}; + +/** + * The poster as a small 16:9 button, with ▶ when there is a recording. The + * words beside it play the same demo, so the poster is a pointer target only. + */ +export function ProofThumb({ + demo, + onPlay, + className, +}: { + demo: DemoRef; + onPlay: () => void; + className?: string; +}) { + const [broken, setBroken] = useState(false); + const still = demo.poster_url && !broken; + return ( + + ); +} + +/** The recording, playing as soon as it opens (a click asked for it), else its still. */ +export function ProofPlayer({ demo }: { demo: DemoRef }) { + const box = useRef(null); + useEffect(() => { + const video = box.current?.querySelector("video"); + try { + const started = video?.play?.(); + if (started && typeof started.catch === "function") started.catch(() => undefined); + } catch { + // A browser that refuses to start it keeps its controls. + } + }, []); + return ( +
    + +
    + ); +} + +/** + * "Sessions, tool registry, flagged chat API · #9876 · earlier commit" (without + * the title: "#9876"); the PR links to its change unless the page is that change. + */ +export function DemoWords({ + demo, + title = true, + here, +}: { + demo: DemoRef; + title?: boolean; + /** The page's own node (a link to it would go nowhere). */ + here?: string; +}) { + const tag = demo.pr_tag ?? (demo.pr_number ? `#${demo.pr_number}` : null); + const own = demo.ref !== here && (demo.ref.startsWith("PR-") || demo.ref.startsWith("commit:")); + const shown = [title && "title", tag && "tag", demo.stale && "stale"].filter( + (key): key is string => typeof key === "string", + ); + const part = (key: string): ReactNode => { + if (key === "title") { + return ( + + {demo.title} + + ); + } + if (key === "stale") return earlier commit; + return own ? ( + + {tag} + + ) : ( + {tag} + ); + }; + return ( + <> + {shown.map((key, i) => ( + + {i > 0 && · } + {part(key)} + + ))} + + ); +} + +export function ProofRow({ page }: { page: NodePage }) { + const { onSheet } = useNodeActions(); + const [playing, setPlaying] = useState(false); + const latest = page.proof?.latest; + if (!latest) return null; + const more = moreDemos(page); + const play = () => setPlaying((on) => !on); + return ( +
    +
    +
    + +
    + +

    + +

    + {notPassed(latest.verdict) && latest.partial_note && ( +

    + {latest.partial_note} +

    + )} +
    + {more > 0 && onSheet && ( + + )} +
    + {playing && ( +
    + +
    + )} +
    +
    + ); +} diff --git a/web/src/components/factory/v3/proof/proof.test.tsx b/web/src/components/factory/v3/proof/proof.test.tsx new file mode 100644 index 00000000000..fbc69c12f87 --- /dev/null +++ b/web/src/components/factory/v3/proof/proof.test.tsx @@ -0,0 +1,226 @@ +import { screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { renderNode } from "../fixture/renderNode"; +import { sampleNodeSource } from "../fixture/sampleNodeSource"; +import { liveNodeSource } from "../nodeSource"; +import { asDemoView, moreDemos, moreWords } from "./proofModel"; + +// Calm rule 10 over the sample: the change, its task, the project and the +// feature each open with one "Latest demo" row that plays in place; Home links +// each feature's newest demo from its row; "n more demos" lists the subtree. + +let played: ReturnType; +let fetched: string[] = []; + +vi.mock("@/lib/identity", async (original) => ({ + ...(await original>()), + authenticatedFetch: async (path: string) => { + fetched.push(path); + return new Response(JSON.stringify({ ref: "I-1", items: [], total: 0 }), { status: 200 }); + }, +})); + +beforeEach(() => { + played = vi.fn(() => Promise.resolve()); + Object.defineProperty(HTMLMediaElement.prototype, "play", { + configurable: true, + value: played, + }); + fetched = []; +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +async function proofOf(ref: string) { + const view = renderNode(ref); + await waitFor(() => + expect(screen.getByTestId("v3-node-page").getAttribute("data-ref")).toBe(ref), + ); + const row = await screen.findByTestId("v3-proof", {}, { timeout: 4000 }); + return Object.assign(row, { unmount: view.unmount }); +} + +function words(el: Element): string { + return (el.textContent ?? "").replace(/\s+/g, " ").trim(); +} + +describe("cockpit v3 · proof is never hidden", () => { + it("a project opens with its latest demo: verdict, length, poster, its PR", async () => { + const row = await proofOf("assistant-core"); + + expect(row.dataset.verdict).toBe("PASS"); + expect(words(within(row).getByTestId("v3-proof-play"))).toBe("Latest demo · PASS · 0:14"); + expect(row).toHaveTextContent("Sessions, tool registry, flagged chat API · #9876"); + expect(within(row).getByRole("link", { name: "#9876" })).toHaveAttribute( + "href", + expect.stringContaining("/n/PR-9876"), + ); + const poster = within(row).getByTestId("v3-proof-thumb").querySelector("img"); + expect(poster?.getAttribute("src")).toMatch(/^data:image\/svg\+xml/); + // The FAIL at an earlier commit is the one more demo; a PASS has no note. + expect(within(row).getByTestId("v3-proof-more")).toHaveTextContent("1 more demo ›"); + expect(within(row).queryByTestId("v3-proof-note")).toBeNull(); + }); + + it("plays in place: the recording opens under the row and starts", async () => { + const row = await proofOf("PR-9876"); + const user = userEvent.setup(); + + await user.click(within(row).getByTestId("v3-proof-thumb")); + + const player = await within(row).findByTestId("v3-proof-player"); + const video = player.querySelector("video"); + expect(video?.getAttribute("src")).toBe("/v1/factory/demos/demo-9876/files/recording"); + expect(video?.getAttribute("poster")).toMatch(/^data:image\/svg\+xml/); + expect(played).toHaveBeenCalled(); + await user.click(within(row).getByTestId("v3-proof-play")); + expect(within(row).queryByTestId("v3-proof-player")).toBeNull(); + }); + + it("a task shows its change's demo; a feature its acceptance run, PARTIAL with what failed", async () => { + const task = await proofOf("T-15"); + expect(task.dataset.demo).toBe("demo-9876"); + task.unmount(); + + const feature = await proofOf("I-1"); + expect(feature.dataset.verdict).toBe("PARTIAL"); + expect(words(within(feature).getByTestId("v3-proof-play"))).toBe( + "Latest demo · PARTIAL · 0:42", + ); + expect(feature).toHaveTextContent("Acceptance run 1"); + expect(within(feature).getByTestId("v3-proof-note")).toHaveTextContent( + "Step 6: live search fails on the base env", + ); + expect(within(feature).getByTestId("v3-proof-note").className).toContain( + "text-status-yellow-fg", + ); + expect(within(feature).getByTestId("v3-proof-more")).toHaveTextContent("4 more demos ›"); + }); + + it("n more demos opens the Demos list for the subtree, newest first, each playing in place", async () => { + const row = await proofOf("I-1"); + const view = screen.getByTestId("v3-node-page"); + const user = userEvent.setup(); + + await user.click(within(row).getByTestId("v3-proof-more")); + + const sheet = await screen.findByTestId("v3-demos-sheet", {}, { timeout: 4000 }); + const lines = within(sheet).getAllByTestId("v3-demo-line"); + expect(lines.map((l) => l.dataset.demo)).toEqual([ + "demo-accept-1", + "demo-9882", + "demo-9881", + "demo-9876", + "demo-9876-first", + ]); + // One line each: verdict, title, length, PR. + expect(words(lines[1]!)).toBe("PASS Connector core 0:18 #9882"); + const failed = lines[4]!; + expect(failed).toHaveTextContent("FAIL"); + expect(failed).toHaveTextContent("#9876 · earlier commit"); + expect(within(failed).getByTestId("v3-demo-note")).toHaveTextContent("Step 4: 404 on /chat"); + expect(within(sheet).queryByTestId("v3-proof-player")).toBeNull(); + await user.click( + within(lines[1]!).getByRole("button", { name: "Play the PASS demo (0:18): Connector core" }), + ); + expect(within(lines[1]!).getByTestId("v3-proof-player").querySelector("video")).not.toBeNull(); + expect(view).toBeInTheDocument(); + }); + + it("Home links each feature's newest demo from its row; the ▶ opens it playing", async () => { + const view = renderNode("product"); + const user = userEvent.setup(); + await screen.findByTestId("v3-node-page"); + await waitFor(() => expect(screen.getAllByTestId("v3-demo-chip").length).toBeGreaterThan(0)); + + // Home has no row of its own: each feature row carries one ▶. + expect(screen.queryByTestId("v3-proof")).toBeNull(); + const chips = screen.getAllByTestId("v3-demo-chip"); + const assistant = chips.find((c) => c.closest("[data-ref='I-1']"))!; + expect(words(assistant)).toBe("PARTIAL 0:42"); + expect(assistant).toHaveAccessibleName( + "Play the PARTIAL demo (0:42) of Agent assistant (sample)", + ); + const drafts = chips.find((c) => c.closest("[data-ref='build-tier-drafts-3']"))!; + expect(drafts.dataset.verdict).toBe("PASS"); + expect(words(drafts)).toBe("0:11"); + for (const chip of chips) expect(chip.closest("[data-ref]")).not.toBeNull(); + + await user.click(assistant); + + const sheet = await screen.findByTestId("v3-demos-sheet", {}, { timeout: 4000 }); + expect(view.search()).toContain("peek=demos"); + expect(view.search()).toContain("demo=demo-accept-1"); + const first = within(sheet).getAllByTestId("v3-demo-line")[0]!; + expect(first.dataset.demo).toBe("demo-accept-1"); + expect(within(first).getByTestId("v3-proof-player")).toBeInTheDocument(); + }); + + it("a page with no demos shows no row; levels with no row of their own show none", async () => { + renderNode("assistant-panel-ux"); + await waitFor(() => + expect(screen.getByTestId("v3-node-page").getAttribute("data-ref")).toBe( + "assistant-panel-ux", + ), + ); + expect(screen.queryByTestId("v3-proof")).toBeNull(); + expect(screen.queryByTestId("v3-demo-chip")).toBeNull(); + }); + + it("projects and tasks below a feature stay one line: no ▶ chip on them", async () => { + renderNode("I-1"); + await screen.findByTestId("v3-proof", {}, { timeout: 4000 }); + const rows = screen.getAllByTestId(/^v3-(row|tile)$/); + expect(rows.length).toBeGreaterThan(0); + expect(rows.some((r) => within(r).queryByTestId("v3-demo-chip"))).toBe(false); + }); +}); + +describe("cockpit v3 · proof model and sources", () => { + it("counts the demos past the row and names them", () => { + expect(moreDemos({ proof: { count: 5, latest: undefined } })).toBe(5); + expect(moreDemos({})).toBe(0); + expect(moreWords(1)).toBe("1 more demo"); + expect(moreWords(4)).toBe("4 more demos"); + }); + + it("hands the PR page's player a demo with its poster and recording", () => { + const view = asDemoView({ + id: "d1", + verdict: "FAIL", + title: "Short", + full_title: "A longer title", + ref: "PR-1", + at: "2026-11-03T13:22:00Z", + poster_url: "/p", + video_url: "/v", + duration_s: 12, + stale: true, + }); + expect(view.files.map((f) => [f.kind, f.url])).toEqual([ + ["poster", "/p"], + ["video", "/v"], + ]); + expect([view.status, view.title, view.is_current_head, view.duration_s]).toEqual([ + "FAIL", + "A longer title", + false, + 12, + ]); + expect(view.created_at).toBe(Date.parse("2026-11-03T13:22:00Z") / 1000); + }); + + it("the sample pages its demos; the live source reads …/n/{ref}/demos with the cursor", async () => { + const sample = sampleNodeSource(); + const first = await sample.demos!("f", "product", undefined, undefined); + expect(first?.total).toBe(8); + expect(first?.items.map((d) => d.id).slice(0, 2)).toEqual(["demo-accept-1", "demo-9882"]); + expect(await sample.demos!("f", "T-404")).toBeNull(); + + await liveNodeSource.demos!("fid-1", "I-1", undefined, "20"); + expect(fetched).toEqual(["/v1/factory/factories/fid-1/n/I-1/demos?cursor=20"]); + }); +}); diff --git a/web/src/components/factory/v3/proof/proofModel.ts b/web/src/components/factory/v3/proof/proofModel.ts new file mode 100644 index 00000000000..6578d2d2167 --- /dev/null +++ b/web/src/components/factory/v3/proof/proofModel.ts @@ -0,0 +1,110 @@ +// Proof (calm rule 10): a node's recorded demos as the page shows them. The +// server picks the one the page shows (a change's current head, a project's +// newest, a feature's acceptance run) and counts the subtree; the page renders +// that row, the "▶" chip on Home's feature rows and the Demos sheet, and plays +// each demo in place with the PR page's player (`DemoMedia`). + +import { useCallback } from "react"; +import { useInfiniteQuery } from "@tanstack/react-query"; +import type { DemoFileView, DemoView } from "@/lib/factoryApi"; +import { useOptionalFactoryShell } from "../../shell/FactoryShellContext"; +import { NODE_STALE_MS, nodeQueryKeys } from "../nodeQueries"; +import { useNodeSource } from "../nodeSource"; +import type { DemoChip, DemoRef, DemoVerdict, NodeDemos, NodePage } from "../nodeTypes"; + +/** How many demos "n more demos" names: the subtree's, less the one the row shows. */ +export function moreDemos(page: Pick): number { + const proof = page.proof; + if (!proof) return 0; + return Math.max(0, proof.count - (proof.latest ? 1 : 0)); +} + +/** "n more demos", "1 more demo". */ +export function moreWords(n: number): string { + return `${n} more demo${n === 1 ? "" : "s"}`; +} + +/** A verdict that did not pass (labelled with what did not). */ +export function notPassed(verdict: DemoVerdict): boolean { + return verdict !== "PASS"; +} + +/** What a play button says: "Play the PASS demo (0:14)". */ +export function playLabel(demo: Pick, what = "demo"): string { + return `Play the ${demo.verdict} ${what}${demo.duration ? ` (${demo.duration})` : ""}`; +} + +/** What a "▶" chip on a feature row says to a screen reader. */ +export function chipLabel(chip: DemoChip, name: string): string { + return `${playLabel(chip, "demo")} of ${name}`; +} + +function file(id: string, kind: "poster" | "video", url: string): DemoFileView { + return { + id, + name: kind === "video" ? "demo.webm" : "poster.png", + kind, + caption: null, + content_type: kind === "video" ? "video/webm" : "image/png", + bytes: 0, + size: 0, + url, + step: null, + }; +} + +/** A demo row as the PR page's player reads it: its poster and its recording, by URL. */ +export function asDemoView(demo: DemoRef): DemoView { + const files = [ + ...(demo.poster_url ? [file("poster", "poster", demo.poster_url)] : []), + ...(demo.video_url ? [file("video", "video", demo.video_url)] : []), + ]; + return { + id: demo.id, + status: demo.verdict, + head_sha: "", + is_current_head: !demo.stale, + title: demo.full_title ?? demo.title, + summary: "", + plan_source: null, + duration_s: demo.duration_s ?? null, + console_errors: null, + steps: [], + files, + video_file_id: demo.video_url ? "video" : null, + poster_file_id: demo.poster_url ? "poster" : null, + preview_url: null, + created_at: Math.floor(Date.parse(demo.at) / 1000) || 0, + by: null, + }; +} + +/** The Demos sheet's list: the subtree's demos, newest first, a page at a time. */ +export function useNodeDemos(ref: string, open: boolean, factoryId?: string) { + const shell = useOptionalFactoryShell(); + const fid = factoryId ?? shell?.factoryId ?? ""; + const source = useNodeSource(); + const query = useInfiniteQuery({ + queryKey: [...nodeQueryKeys.node(fid, source?.kind ?? "pending", ref), "demos"], + queryFn: ({ pageParam, signal }) => + source?.demos ? source.demos(fid, ref, signal, pageParam || undefined) : null, + initialPageParam: "", + getNextPageParam: (last: NodeDemos | null) => last?.next || undefined, + enabled: Boolean(open && source?.demos && fid && ref), + staleTime: NODE_STALE_MS, + }); + const pages = query.data?.pages ?? []; + const first = pages[0] ?? null; + const { fetchNextPage } = query; + const more = useCallback(() => void fetchNextPage(), [fetchNextPage]); + return { + items: pages.flatMap((p) => p?.items ?? []), + total: first?.total ?? 0, + latest: first?.latest, + missing: query.isSuccess && first === null, + pending: query.isPending && open, + error: query.error, + more: query.hasNextPage ? more : undefined, + loading: query.isFetchingNextPage, + }; +} diff --git a/web/src/components/factory/v3/sheets/V3Sheets.tsx b/web/src/components/factory/v3/sheets/V3Sheets.tsx index 00af57e712c..bbb7a5df376 100644 --- a/web/src/components/factory/v3/sheets/V3Sheets.tsx +++ b/web/src/components/factory/v3/sheets/V3Sheets.tsx @@ -4,8 +4,9 @@ // `?peek=env` (the base env: what it runs, its health, how to sign in), // `?peek=try&at=` (what can be tried there: the passing steps by name, // the flag and the sign-in note) and `?peek=lineage&at=` (where a node -// came from, the phone's Lineage). Keys are `KeyboardHelp`; Find is the ⌘K -// palette. A sheet never draws a line between nodes. +// came from, the phone's Lineage) and `?peek=demos&at=` (the subtree's +// recorded demos, `&demo=` playing one). Keys are `KeyboardHelp`; Find is +// the ⌘K palette. A sheet never draws a line between nodes. import { ExternalLinkIcon } from "lucide-react"; import { KeyboardHelp } from "../../cmdk/KeyboardHelp"; @@ -16,6 +17,7 @@ import { useLocation, useNavigate, useSearchParams } from "@/lib/routing"; import { cn } from "@/lib/utils"; import { ago, envLines } from "../envWords"; import { LineageBody } from "../lenses/LensBodies"; +import { DemosSheet } from "../proof/DemosSheet"; import { type NodeActions, NodeContextProvider, type SheetKind } from "../NodeContext"; import { NodeLink } from "../nodeParts"; import { useNode } from "../nodeQueries"; @@ -31,6 +33,8 @@ import { MoveRowItem } from "../calm/YourMove"; export const SHEET_PARAM = "peek"; export const SHEET_AT_PARAM = "at"; export const SHEET_TREE_PARAM = "tree"; +/** The demo the Demos sheet plays as it opens. */ +export const SHEET_PLAY_PARAM = "demo"; /** The history state of an entry a sheet pushed (its close goes back over it). */ const SHEET_PUSHED = "v3SheetPushed"; @@ -46,13 +50,13 @@ function pushedBySheet(state: unknown): boolean { * entry (or replaces the address when the top bar opened it in place), so * Back after Esc never reopens it. */ -export function useOpenSheet(): (sheet: SheetKind | null, at?: string) => void { +export function useOpenSheet(): (sheet: SheetKind | null, at?: string, play?: string) => void { const [, setParams] = useSearchParams(); const { state } = useLocation(); const navigate = useNavigate(); const pushed = pushedBySheet(state); return useCallback( - (sheet, at) => { + (sheet, at, play) => { if (!sheet && pushed) { navigate(-1); return; @@ -60,9 +64,11 @@ export function useOpenSheet(): (sheet: SheetKind | null, at?: string) => void { setParams( (prev) => { const next = new URLSearchParams(prev); - for (const key of [SHEET_PARAM, SHEET_AT_PARAM, SHEET_TREE_PARAM]) next.delete(key); + for (const key of [SHEET_PARAM, SHEET_AT_PARAM, SHEET_TREE_PARAM, SHEET_PLAY_PARAM]) + next.delete(key); if (sheet) next.set(SHEET_PARAM, sheet); if (sheet && at) next.set(SHEET_AT_PARAM, at); + if (sheet && play) next.set(SHEET_PLAY_PARAM, play); return next; }, sheet ? { state: { [SHEET_PUSHED]: true } } : { replace: true }, @@ -425,6 +431,15 @@ export function V3Sheets({ {source?.lineage && ( )} + {source?.demos && ( + + )} {keys && ( !next && close()} nodes /> )} @@ -435,5 +450,10 @@ export function V3Sheets({ /** What a node page needs to open a sheet from a badge or a chip. */ export function useSheetActions(): Pick { const open = useOpenSheet(); - return useMemo(() => ({ onSheet: (sheet: SheetKind, at?: string) => open(sheet, at) }), [open]); + return useMemo( + () => ({ + onSheet: (sheet: SheetKind, at?: string, play?: string) => open(sheet, at, play), + }), + [open], + ); } From 4a9a3e02d15db41d5e0d1c19496f56ca5b8eaf77 Mon Sep 17 00:00:00 2001 From: Luka Skugor Date: Fri, 6 Nov 2026 10:49:05 +0100 Subject: [PATCH 2/2] cockpit: one cockpit for everyone, a sidebar, Home and Demos - no cockpit switch: v3 is the cockpit at /factory/ (?cockpit= dropped); older addresses (/projects/, /prs/, /initiatives/, /room, ?review=, ?peek=needs) land on the place that shows the same thing now - a sidebar always in view (Home, Demos, Chats, Decisions, Search, then an outline of features opening to their projects, the page's node marked); a bottom tab bar on phones; node pages keep a plain breadcrumb, the level strip is gone - Home: what needs you (3), features one line each with a play chip for the latest demo, recently shipped changes with their demo - Demos: every demo newest first, poster, verdict, what was partial, where it sits, length; plays in place and scrolls into view; filters by feature and verdict (server: demos list items carry feature, project, merged_at) - Chats list (server: threads carry mine and last), Decisions page - the chat's demo card plays in place, no PR number or commit on it - proof rows from #224 (node demo fields, Demos sheet) Co-Authored-By: Claude Opus 5.5 --- docs/FACTORY.md | 18 +- omnigent/server/factory/api/chat.py | 34 ++ omnigent/server/factory/node_proof.py | 32 +- omnigent/server/factory/node_views.py | 13 + omnigent/stores/factory_store/__init__.py | 21 + .../stores/factory_store/sqlalchemy_store.py | 31 + tests/e2e_ui/factory_v3/checks.py | 3 +- tests/e2e_ui/factory_v3/conftest.py | 22 +- tests/e2e_ui/factory_v3/seed.py | 2 +- tests/e2e_ui/factory_v3/test_v3_calm.py | 28 +- tests/e2e_ui/factory_v3/test_v3_live_api.py | 2 +- tests/e2e_ui/factory_v3/test_v3_proof.py | 69 ++- tests/e2e_ui/factory_v3/test_v3_steering.py | 15 +- tests/server/factory/test_node_proof.py | 18 + tests/server/factory/test_node_threads.py | 20 + .../factory/FactoryRoomBanner.test.tsx | 4 +- .../components/factory/FactoryStrip.test.tsx | 8 +- .../factory/alerts/runbookModel.test.ts | 2 +- .../factory/board/ProjectCard.test.tsx | 9 +- web/src/components/factory/cards/DemoCard.tsx | 101 +-- web/src/components/factory/cmdk/cmdk.test.ts | 10 +- .../factory/cockpit/ChatThreadPage.tsx | 14 + .../components/factory/cockpit/CockpitNav.tsx | 494 +++++++++++++++ web/src/components/factory/cockpit/chats.ts | 113 ++++ .../factory/cockpit/cockpitRoutes.ts | 117 ++++ web/src/components/factory/cockpit/demos.tsx | 189 ++++++ web/src/components/factory/factoryRoutes.ts | 31 +- .../improvements/improvements.test.tsx | 2 +- .../overview/__fixtures__/nowFixtures.ts | 6 +- .../factory/project/projectModel.test.ts | 6 +- .../components/factory/review/PrPage.test.tsx | 2 +- .../components/factory/room/RoomText.test.tsx | 2 +- .../factory/shell/FactoryTopBar.tsx | 2 +- web/src/components/factory/shell/cockpitV2.ts | 187 +----- .../factory/shell/shellRoutes.test.ts | 20 +- .../factory/team/TeamEditor.test.tsx | 95 +-- .../components/factory/team/teamStaging.ts | 4 +- web/src/components/factory/v3/Breadcrumb.tsx | 58 ++ .../components/factory/v3/NodePage.test.tsx | 76 +-- web/src/components/factory/v3/NodePage.tsx | 19 +- .../components/factory/v3/calm/YourMove.tsx | 17 +- .../factory/v3/calm/calmGate.test.tsx | 2 +- .../factory/v3/chat/NodeChat.test.tsx | 18 +- .../components/factory/v3/chat/chatLayout.ts | 14 +- .../factory/v3/lenses/Lenses.test.tsx | 10 - .../components/factory/v3/levelsRule.test.tsx | 2 +- web/src/components/factory/v3/nodeSource.ts | 10 +- web/src/components/factory/v3/nodeTypes.ts | 5 + .../factory/v3/nodeWire.server.test.tsx | 11 +- .../components/factory/v3/proof/ProofRow.tsx | 12 +- .../components/factory/v3/sheets/V3Sheets.tsx | 4 +- web/src/lib/factoryApi.ts | 13 + web/src/pages/factory/ChatsPage.tsx | 179 ++++++ web/src/pages/factory/Cockpit.test.tsx | 261 ++++++++ web/src/pages/factory/Cockpit.tsx | 247 ++++++++ web/src/pages/factory/CockpitDecisions.tsx | 50 ++ web/src/pages/factory/CockpitHome.tsx | 337 ++++++++++ web/src/pages/factory/CockpitShell.test.tsx | 515 ---------------- web/src/pages/factory/CockpitShell.tsx | 423 ------------- web/src/pages/factory/CockpitV3.test.tsx | 505 --------------- web/src/pages/factory/DemosPage.tsx | 174 ++++++ web/src/pages/factory/DiscoveryPage.test.tsx | 2 +- web/src/pages/factory/FactoryLayout.tsx | 343 ++--------- web/src/pages/factory/FactoryPage.test.tsx | 472 -------------- web/src/pages/factory/FactoryPage.tsx | 43 +- web/src/pages/factory/HomePage.test.tsx | 524 ---------------- web/src/pages/factory/InitiativePage.test.tsx | 186 ------ web/src/pages/factory/LearnPages.test.tsx | 576 +----------------- web/src/pages/factory/NewFactoryPage.test.tsx | 2 +- web/src/pages/factory/OpsPages.test.tsx | 192 ------ web/src/pages/factory/ProjectPage.test.tsx | 266 -------- web/src/pages/factory/ProjectPageV2.test.tsx | 358 ----------- web/src/pages/factory/ProjectsPageV2.test.tsx | 184 ------ web/src/pages/factory/RoomPage.test.tsx | 440 ------------- web/src/pages/factory/TeamsPage.test.tsx | 38 -- web/src/pages/factory/cockpitV2Routes.tsx | 11 - 76 files changed, 2820 insertions(+), 5525 deletions(-) create mode 100644 web/src/components/factory/cockpit/ChatThreadPage.tsx create mode 100644 web/src/components/factory/cockpit/CockpitNav.tsx create mode 100644 web/src/components/factory/cockpit/chats.ts create mode 100644 web/src/components/factory/cockpit/cockpitRoutes.ts create mode 100644 web/src/components/factory/cockpit/demos.tsx create mode 100644 web/src/components/factory/v3/Breadcrumb.tsx create mode 100644 web/src/pages/factory/ChatsPage.tsx create mode 100644 web/src/pages/factory/Cockpit.test.tsx create mode 100644 web/src/pages/factory/Cockpit.tsx create mode 100644 web/src/pages/factory/CockpitDecisions.tsx create mode 100644 web/src/pages/factory/CockpitHome.tsx delete mode 100644 web/src/pages/factory/CockpitShell.test.tsx delete mode 100644 web/src/pages/factory/CockpitShell.tsx delete mode 100644 web/src/pages/factory/CockpitV3.test.tsx create mode 100644 web/src/pages/factory/DemosPage.tsx delete mode 100644 web/src/pages/factory/FactoryPage.test.tsx delete mode 100644 web/src/pages/factory/HomePage.test.tsx delete mode 100644 web/src/pages/factory/InitiativePage.test.tsx delete mode 100644 web/src/pages/factory/ProjectPage.test.tsx delete mode 100644 web/src/pages/factory/ProjectPageV2.test.tsx delete mode 100644 web/src/pages/factory/ProjectsPageV2.test.tsx delete mode 100644 web/src/pages/factory/RoomPage.test.tsx diff --git a/docs/FACTORY.md b/docs/FACTORY.md index b5b7dd9f33c..bccb9a9fe7c 100644 --- a/docs/FACTORY.md +++ b/docs/FACTORY.md @@ -54,7 +54,7 @@ SERVER: FactorySupervisor — keepalive, heartbeats, alerts, reply notices | `OMNIGENT_FACTORY_POST_RATE_PER_HOUR` | `60,20` | Posts per hour per sender, and per (sender, target) pair. A single number sets the sender limit and a pair limit of a third of it. | | `OMNIGENT_FACTORY_MERGE_REPOS` | empty | `owner/repo` list (comma or space separated) the merge gate may merge in. Empty means the gate merges nowhere. Read by the gate inside the sandbox. | | `OMNIGENT_FACTORY_ARTIFACT_LOCATION` | unset | Where demo files (video, screenshots, `api.json`, HAR, traces) are written: `s3://[/]` (credentials from the standard AWS chain, IRSA included; `AWS_REGION` must be set for AWS S3; `AWS_ENDPOINT_URL_S3` for MinIO or R2) or a directory. Unset keeps them session files of the PL in the server's artifact store, shown in its Files panel. Each demo file records the location it was written to and is always read from there, so demos stored before a change keep playing. Files are still served only through `/v1/factory/demos/…` behind omnigent auth (a Range is a ranged read of the store, at most 4 MiB per response; no presigned links). A store that fails (unreachable, throttled, denied) answers 503 `artifact_store_unavailable` with `Retry-After`. A malformed location fails server start. | -| `OMNIGENT_FACTORY_COCKPIT_V2` | unset | `1` lets each person switch to the new cockpit (Teams and the agent panel; `?cockpit=v2`, remembered per browser). Unset, every page and API behaves as before. | +| `OMNIGENT_FACTORY_COCKPIT_V2` | unset | `1` turns on the team editor's staged changes and the agent panel's routes. The web cockpit no longer reads it: everyone sees the one cockpit (Home, Demos, Chats, Decisions and the node pages); `?cockpit=` links are dropped. | | `OMNIGENT_FACTORY_DISCOVERY` | unset | `1` runs discovery on this server: the discovery ledger's routes, Room hunches as team signals, the ledger's upkeep (retention, daily score snapshots), the definition preview's re-rank, readouts and the prediction ledger, and trials of discovery's method on discovery guards. A factory also needs its own `discovery.enabled` (owners, off by default); until both are on every discovery route answers `404 discovery_off`. | | `OMNIGENT_FACTORY_WEB_SEARCH` | unset | The keyless search backend of `web` sources: `keenable` (its public, keyless endpoint; no key, no account). Unset or `none`, `web` sources fetch only (their `search` tool is not offered). Turn one on only where the server can reach it. | | `OMNIGENT_FACTORY_STATE_SEGMENTS` | follows `OMNIGENT_FACTORY_COCKPIT_V2` | `1` records each agent's working and waiting stretches (the agent panel's strip); any other value turns recording off. Segments older than 30 days are pruned. One server process is assumed to see all of a session's status edges. | @@ -1063,8 +1063,8 @@ under their task's PR; a task with runs and no PR yet has no run page viewer (`filter_snapshot`: members, team chips, decisions, cost and burn, the base env), memoized for 5 s per factory and set of sessions the viewer may read, and dropped on any recorded write; open asks come from one -batched read of the messages live nodes quote. Cockpit v3 (`?cockpit=v3`) -always reads these pages; its sample factory lives only in Storybook and +batched read of the messages live nodes quote. The cockpit always reads +these pages; its sample factory lives only in Storybook and the web tests. `GET …/n/{ref}/level/{level}` lists one level under a node grouped under each item's parent (Home lists the features under the needs they came from), all-done groups folded, 60 a group with a cursor each; @@ -1072,6 +1072,18 @@ they came from), all-done groups folded, 60 a group with a cursor each; holds the words (`state=later` or `ghost` lists the held or proposed nodes as the items); a group with nothing to show is left out. +**The cockpit** is one shell for everyone: a sidebar with Home, Demos, +Chats, Decisions and Search, then an outline of the work (features opening +to their projects, the page's node marked); a phone has the places in a +bottom tab bar. Home is what needs you (three asks), the features one line +each with a ▶ for their latest demo, and what shipped lately. Demos reads +`GET …/n/product/demos?limit=100`, whose items also say where each sits +(`feature`, `project`, `merged_at`). Chats reads `GET …/chat/threads?nodes=1`, +whose threads also say whether the reader wrote there (`mine`) and the newest +thing a person or agent said (`last`). Older addresses (`/projects/`, +`/prs/`, `/initiatives/`, `/room`, `?review=`, `?peek=needs`) land +on the place that shows the same thing now. + **Lenses** (`node_lenses.py`) are the other ways to read one node. `GET …/n/{ref}/lineage` reads top to bottom: each ancestor with its one line (a need with its evidence, a feature with the proposal that approved diff --git a/omnigent/server/factory/api/chat.py b/omnigent/server/factory/api/chat.py index ea4b3d8fba5..ef703b8c7a5 100644 --- a/omnigent/server/factory/api/chat.py +++ b/omnigent/server/factory/api/chat.py @@ -232,6 +232,8 @@ def thread_views( if thread.project not in hidden: threads.append(thread) members = chat.members_of(deps, root, threads) + said = store.chat_thread_said(root.id, user_id) + names = _author_names(deps, [m for m, _ in said.values()]) views: list[dict[str, Any]] = [] for thread in threads: key = thread.key @@ -252,11 +254,43 @@ def thread_views( "last_at": stat.last_at if stat else None, "messages": stat.messages if stat else 0, **_unread(deps, root.id, key, reads.get(key, 0), stat, user_id), + **_said(said.get(key), names, user_id), } ) return views +LAST_LINE_CHARS = 160 +"""How much of a thread's newest line the Chats list shows.""" + + +def _author_names(deps: FactoryDeps, rows: list[Any]) -> dict[str, str]: + """Message author -> the name a list shows (an agent's session title, a person's id).""" + out: dict[str, str] = {} + for row, view in zip(rows, chat.message_views(deps, rows), strict=True): + out[row.author] = str(view.get("author_title") or row.author) + return out + + +def _said(found: Any, names: Mapping[str, str], user_id: str | None) -> dict[str, Any]: + """:returns: ``{mine, last}``: whether the reader posted here, and the newest line said.""" + if found is None: + return {"mine": False, "last": None} + row, mine = found + line = " ".join(row.body.split()) + return { + "mine": mine, + "last": { + "who": "You" + if user_id and row.author == user_id + else names.get(row.author, row.author), + "kind": row.author_kind, + "text": line[:LAST_LINE_CHARS], + "at": row.created_at, + }, + } + + def _unread( deps: FactoryDeps, factory_id: str, thread: str, last_read: int, stat: Any, user_id: str | None ) -> dict[str, Any]: diff --git a/omnigent/server/factory/node_proof.py b/omnigent/server/factory/node_proof.py index 3f2f4c28b89..b3b04d5e15d 100644 --- a/omnigent/server/factory/node_proof.py +++ b/omnigent/server/factory/node_proof.py @@ -295,8 +295,32 @@ def _media(demo: FactoryDemo) -> tuple[str | None, str | None]: ) -def view(demo: Demo) -> dict[str, Any]: - """A ``DemoRef``: one demo as a page row or a list line shows it.""" +def _where(tree: Tree, demo: Demo) -> dict[str, Any]: + """ + Where a demo sits, for the factory's Demos list: its feature and project + (``{ref, title}``, short names) and, for a PR demo, whether the PR merged + (``merged_at``, epoch seconds). + """ + out: dict[str, Any] = {} + home = tree.nodes.get(demo.home) + chain = [*tree.ancestors(demo.home), *([home] if home is not None else [])] + for level in ("feature", "project"): + hit = next((n for n in reversed(chain) if n.level == level), None) + if hit is not None: + shown = tree.fold(hit) + out[level] = {"ref": shown.ref, "title": shown.title} + pr = tree.pr_rows.get(_bare(demo.row.pr_id) or "") + if pr is not None and pr.state == "merged" and pr.merged_at: + out["merged_at"] = int(pr.merged_at) + return out + + +def view(demo: Demo, tree: Tree | None = None) -> dict[str, Any]: + """ + A ``DemoRef``: one demo as a page row or a list line shows it. + + :param tree: When given, also where it sits (:func:`_where`), for the Demos list. + """ row = demo.row manifest = row.manifest or {} seconds = manifest.get("duration_s") @@ -333,6 +357,8 @@ def view(demo: Demo) -> dict[str, Any]: out["steps"] = {"passed": steps.count("pass"), "total": len(steps)} if note := partial_note(row): out["partial_note"] = note + if tree is not None: + out.update(_where(tree, demo)) return out @@ -384,7 +410,7 @@ def demos_page( shown = demos[offset : offset + size] out: dict[str, Any] = { "ref": node.ref, - "items": [view(d) for d in shown], + "items": [view(d, tree) for d in shown], "total": len(demos), } if (shown_latest := latest(tree, node)) is not None: diff --git a/omnigent/server/factory/node_views.py b/omnigent/server/factory/node_views.py index 026eaee81b5..ebd8524c596 100644 --- a/omnigent/server/factory/node_views.py +++ b/omnigent/server/factory/node_views.py @@ -147,6 +147,13 @@ class DemoStepsView(_View): total: int +class NodeNameView(_View): + """A node by its ref and short name.""" + + ref: str + title: str + + class DemoRefView(_View): """One recorded demo as a page row or a Demos list line shows it (:mod:`.node_proof`).""" @@ -172,6 +179,12 @@ class DemoRefView(_View): steps: DemoStepsView | None = None partial_note: str | None = None """What did not pass, in one line (PARTIAL, FAIL).""" + feature: NodeNameView | None = None + """The feature it sits under (the Demos list; ``…/demos`` only).""" + project: NodeNameView | None = None + """The project it sits under (the Demos list; ``…/demos`` only).""" + merged_at: int | None = None + """Its PR merged then (epoch seconds; ``…/demos`` only).""" class ProofView(_View): diff --git a/omnigent/stores/factory_store/__init__.py b/omnigent/stores/factory_store/__init__.py index 79589f7515c..52dc1421b68 100644 --- a/omnigent/stores/factory_store/__init__.py +++ b/omnigent/stores/factory_store/__init__.py @@ -1154,6 +1154,27 @@ def chat_thread_stats(self, factory_id: str) -> builtins.list[ChatThreadStats]: Consumer: R3-08 ``GET …/chat/threads``. """ + def chat_thread_said( + self, factory_id: str, user_id: str | None + ) -> builtins.dict[str, tuple[FactoryChatMessage, bool]]: + """ + Per thread, the newest message a person or an agent said (system lines + and empty bodies left out) and whether *user_id* ever posted there. + + Consumer: the cockpit's Chats list (``GET …/chat/threads``: ``last``, ``mine``). + The default reads each thread's messages; stores override it with one query. + + :returns: Thread key -> ``(newest said, user posted there)``. + """ + out: dict[str, tuple[FactoryChatMessage, bool]] = {} + for stat in self.chat_thread_stats(factory_id): + rows = self.list_chat(factory_id, stat.thread, limit=200) + said = [r for r in rows if r.author_kind in ("human", "agent") and r.body.strip()] + if said: + mine = bool(user_id) and any(r.author == user_id for r in rows) + out[stat.thread] = (said[-1], mine) + return out + @abstractmethod def upsert_chat_read( self, factory_id: str, user_id: str, thread: str, last_seq: int diff --git a/omnigent/stores/factory_store/sqlalchemy_store.py b/omnigent/stores/factory_store/sqlalchemy_store.py index 7f7c515e138..2c723f42087 100644 --- a/omnigent/stores/factory_store/sqlalchemy_store.py +++ b/omnigent/stores/factory_store/sqlalchemy_store.py @@ -1786,6 +1786,37 @@ def chat_thread_stats(self, factory_id: str) -> builtins.list[ChatThreadStats]: for t, seq, n, at in rows ] + def chat_thread_said( + self, factory_id: str, user_id: str | None + ) -> builtins.dict[str, tuple[FactoryChatMessage, bool]]: + model = SqlFactoryChatMessage + ws, fid = current_workspace_id(), norm_id(factory_id) + scope = (model.workspace_id == ws, model.factory_id == fid) + said = ( + select(model.thread, func.max(model.seq).label("seq")) + .where( + *scope, + model.author_kind.in_( + [encode_factory_chat_author_kind(k) for k in ("human", "agent")] + ), + ) + .group_by(model.thread) + .subquery() + ) + stmt = select(model).join(said, and_(*scope, model.seq == said.c.seq)) + with self._session("list_factory_chat_said") as session: + rows = [_chat(r) for r in session.execute(stmt).scalars().all()] + mine: set[str] = set() + if user_id: + mine = set( + session.execute( + select(model.thread).where(*scope, model.author == user_id).distinct() + ) + .scalars() + .all() + ) + return {r.thread: (r, r.thread in mine) for r in rows} + def upsert_chat_read( self, factory_id: str, user_id: str, thread: str, last_seq: int ) -> FactoryChatRead: diff --git a/tests/e2e_ui/factory_v3/checks.py b/tests/e2e_ui/factory_v3/checks.py index d8eaff4b1f2..a484087da11 100644 --- a/tests/e2e_ui/factory_v3/checks.py +++ b/tests/e2e_ui/factory_v3/checks.py @@ -87,7 +87,7 @@ const proofs = [...node.querySelectorAll('[data-testid="v3-proof"]')]; const proofText = proofs.map((el) => el.innerText).join(' '); const outside = [ - node.querySelector('[data-testid="v3-level-strip"], [data-testid="v3-phone-levels"]'), + node.querySelector('[data-testid="v3-breadcrumb"]'), node.querySelector('[data-testid="v3-main"]'), ].filter(Boolean).map((el) => el.innerText).join(' '); const titleSel = [ @@ -95,7 +95,6 @@ '[data-testid="v3-row"] a[data-node-link]', '[data-testid="v3-tile"] a[data-node-link]', '[data-testid="v3-group-head"] a', - '[data-testid="v3-level-strip"] [data-cv]', ].join(', '); const titles = [...node.querySelectorAll(titleSel)] .filter((el) => !el.closest(chatSel)) diff --git a/tests/e2e_ui/factory_v3/conftest.py b/tests/e2e_ui/factory_v3/conftest.py index 8b9204a2d9a..c1af3cb1053 100644 --- a/tests/e2e_ui/factory_v3/conftest.py +++ b/tests/e2e_ui/factory_v3/conftest.py @@ -86,16 +86,17 @@ class Cockpit: errors: list[str] = field(default_factory=list) def open(self, ref: str, level: str | None = None) -> None: - """Open node *ref* (or one *level* under it) in cockpit v3 and wait for its page.""" - path = f"/factory/{self.factory.fid}" - if level: - self.page.goto(f"{path}/n/{ref}/{level}?cockpit=v3") - elif ref == "product": - self.page.goto(f"{path}?cockpit=v3") - else: - self.page.goto(f"{path}/n/{ref}?cockpit=v3") + """Open node *ref*'s page (or one *level* under it) and wait for it.""" + path = f"/factory/{self.factory.fid}/n/{ref}" + self.page.goto(f"{path}/{level}" if level else path) self.wait_for(ref) + def open_place(self, place: str = "") -> None: + """Open one of the cockpit's places: Home (``""``), ``demos``, ``chats``, ``decisions``.""" + path = f"/factory/{self.factory.fid}" + self.page.goto(f"{path}/{place}" if place else path) + expect(self.page.get_by_test_id("factory-shell")).to_be_visible(timeout=LOAD_MS) + def wait_for(self, ref: str) -> None: """Wait for node *ref*'s page, its children and its chat to have loaded.""" node = self.page.locator(f'[data-testid="v3-node-page"][data-ref="{ref}"]') @@ -124,10 +125,7 @@ def make(width: int, height: int, theme: Theme) -> Cockpit: viewport={"width": width, "height": height}, color_scheme=theme, ) - context.add_init_script( - f"localStorage.setItem('web-theme', '{theme}');" - "localStorage.setItem('omnigent.factory.cockpit', 'v3');" - ) + context.add_init_script(f"localStorage.setItem('web-theme', '{theme}');") contexts.append(context) page = context.new_page() cockpit = Cockpit(page=page, factory=factory) diff --git a/tests/e2e_ui/factory_v3/seed.py b/tests/e2e_ui/factory_v3/seed.py index 20d0b67155d..5018741de6e 100644 --- a/tests/e2e_ui/factory_v3/seed.py +++ b/tests/e2e_ui/factory_v3/seed.py @@ -1032,7 +1032,7 @@ def main() -> None: parser.add_argument("--owner", required=True, help="the X-Forwarded-Email that owns it") args = parser.parse_args() seeded = seed_factory_1340(args.db, args.base_url, args.owner) - print(f"{args.base_url}/factory/{seeded.fid}?cockpit=v3") + print(f"{args.base_url}/factory/{seeded.fid}") print(f"apply {seeded.apply_decision} ({seeded.proposal}); held task {seeded.held_task}") diff --git a/tests/e2e_ui/factory_v3/test_v3_calm.py b/tests/e2e_ui/factory_v3/test_v3_calm.py index 1fb80c7181d..6efd04afd00 100644 --- a/tests/e2e_ui/factory_v3/test_v3_calm.py +++ b/tests/e2e_ui/factory_v3/test_v3_calm.py @@ -154,19 +154,16 @@ def test_needs_you_is_your_move_for_the_whole_tree( ) -> None: cockpit = open_cockpit(1440, 900, "light") page = cockpit.page - cockpit.open("product") - _settled(page, "product") - pill = page.get_by_test_id("cockpit-needs-you") - expect(pill).to_have_text(re.compile(r"^\d+ need you$"), timeout=LOAD_MS) - total = int(pill.inner_text().split()[0]) - pill.click() + cockpit.open_place() + count = page.get_by_test_id("cockpit-nav-decisions-count") + expect(count).to_have_text(re.compile(r"^\d+$"), timeout=LOAD_MS) + total = int(count.inner_text()) + page.get_by_test_id("cockpit-nav-decisions").click() sheet = page.get_by_test_id("v3-needs-sheet") items = sheet.get_by_test_id("v3-needs-item") expect(items.first).to_have_attribute("data-decision", factory.apply_decision, timeout=LOAD_MS) - # One count: the pill is this tree's rows and the other tree's. - other = sheet.get_by_test_id("v3-needs-other-tree") - more = int(other.inner_text().split()[0]) if other.count() else 0 - assert items.count() + more == total, (items.count(), more, total) + # One count: the sidebar's is the product's rows. + expect(items).to_have_count(total) # Rows as Your move draws them: one filled answer, short words, no closed alarms. filled = sheet.locator('[data-testid="v3-answer"].bg-status-yellow') expect(filled).to_have_count(1) @@ -231,13 +228,10 @@ def test_the_phone_chat_is_a_conversation_too( expect(full.get_by_test_id("v3-chat-activity")).to_be_visible() _shot(page, "chat-390-dark") page.keyboard.press("Escape") - # The bottom bar never leaves v3: its two trees and the chat. + # The bottom bar holds the four places and Search, and fits the phone. tabs = page.get_by_test_id("cockpit-phone-tabs") - expect(tabs).to_have_attribute("data-cockpit", "v3") - expect(tabs.get_by_test_id("cockpit-phone-discovery")).to_have_count(0) - # The top bar fits: search, the theme and you wait in one menu. - bar = page.get_by_test_id("cockpit-topbar") - expect(bar.get_by_test_id("cockpit-more")).to_be_visible() - box = bar.bounding_box() + for place in ("home", "demos", "chats", "decisions", "search"): + expect(tabs.get_by_test_id(f"cockpit-tab-{place}")).to_be_visible() + box = tabs.bounding_box() assert box is not None and box["x"] + box["width"] <= 390, box assert cockpit.errors == [], cockpit.errors diff --git a/tests/e2e_ui/factory_v3/test_v3_live_api.py b/tests/e2e_ui/factory_v3/test_v3_live_api.py index c5ef1c94c30..fe5cdf9cd00 100644 --- a/tests/e2e_ui/factory_v3/test_v3_live_api.py +++ b/tests/e2e_ui/factory_v3/test_v3_live_api.py @@ -1,5 +1,5 @@ """ -Cockpit v3 on a real server: ``?cockpit=v3`` reads the node API, not a sample. +Cockpit v3 on a real server: the cockpit reads the node API, not a sample. The server holds a factory shaped like a real one at 13:40 (``seed.py``): a person's ask planned as the feature I-1, ``assistant-core`` holding T-16 for diff --git a/tests/e2e_ui/factory_v3/test_v3_proof.py b/tests/e2e_ui/factory_v3/test_v3_proof.py index 4895a1e5fdd..6da6bb08e4f 100644 --- a/tests/e2e_ui/factory_v3/test_v3_proof.py +++ b/tests/e2e_ui/factory_v3/test_v3_proof.py @@ -17,13 +17,25 @@ from collections.abc import Callable -from playwright.sync_api import Page, expect +import pytest +from playwright.sync_api import Locator, Page, expect from tests.e2e_ui.factory_v3.checks import CALM_CHECKS, api, assert_calm, node from tests.e2e_ui.factory_v3.conftest import LOAD_MS, Cockpit, Theme, V3Server from tests.e2e_ui.factory_v3.seed import Seeded +def _in_view(page: Page, player: Locator) -> None: + """The player sits in the viewport, clear of a bar fixed at the bottom (a phone's tabs).""" + page.wait_for_timeout(700) # the smooth scroll ends + box = player.bounding_box() + viewport = page.viewport_size + assert box is not None and viewport is not None + bottom_bar = 56 if viewport["width"] < 768 else 0 + assert box["y"] >= 0, box + assert box["y"] + min(box["height"], 120) <= viewport["height"] - bottom_bar, (box, viewport) + + def _poster_loads(page: Page, row_selector: str) -> None: """The row's poster is an image the demo file route served (not a broken one).""" poster = page.locator(f"{row_selector} [data-testid='v3-proof-thumb'] img").first @@ -83,18 +95,13 @@ def test_home_links_each_features_newest_demo_and_the_chat_keeps_the_card( cockpit = open_cockpit(1440, 900, "dark") page = cockpit.page - cockpit.open("product") - home = node(page, "product") - expect(home.get_by_test_id("v3-proof")).to_have_count(0) - chip = home.locator(f'[data-ref="{factory.feature}"] [data-testid="v3-demo-chip"]') - expect(chip).to_have_text("PARTIAL 0:42") + cockpit.open_place() + row = page.locator(f'[data-testid="cockpit-home-feature"][data-ref="{factory.feature}"]') + chip = row.get_by_test_id("cockpit-home-play") + expect(chip).to_contain_text("0:42", timeout=LOAD_MS) chip.click() - sheet = page.get_by_test_id("v3-demos-sheet") - expect(sheet).to_be_visible(timeout=LOAD_MS) - expect( - sheet.get_by_test_id("v3-demo-line").first.get_by_test_id("v3-proof-player") - ).to_be_visible() - page.keyboard.press("Escape") + expect(row.get_by_test_id("v3-proof-player")).to_be_visible(timeout=LOAD_MS) + _in_view(page, row.get_by_test_id("v3-proof-player")) cockpit.open("PR-9876") expect(node(page, "PR-9876").get_by_test_id("v3-proof")).to_have_attribute( @@ -104,3 +111,41 @@ def test_home_links_each_features_newest_demo_and_the_chat_keeps_the_card( expect(chat.locator('[data-testid="demo-card-9876"]')).to_be_visible(timeout=LOAD_MS) assert_calm(node(page, "PR-9876").evaluate(CALM_CHECKS), "change with its demo card") assert cockpit.errors == [], cockpit.errors + + +@pytest.mark.parametrize(("width", "height"), [(1440, 900), (390, 844)], ids=["1440", "390"]) +def test_the_demos_page_lists_every_demo_and_plays_it_in_view( + open_cockpit: Callable[[int, int, Theme], Cockpit], + v3_server: V3Server, + factory: Seeded, + width: int, + height: int, +) -> None: + cockpit = open_cockpit(width, height, "light") + page = cockpit.page + demos = api(v3_server.base_url, factory.fid, "/n/product/demos", limit=100) + cockpit.open_place() + nav = "cockpit-tab-demos" if width < 768 else "cockpit-nav-demos" + page.get_by_test_id(nav).click() + cards = page.get_by_test_id("cockpit-demo") + expect(cards).to_have_count(demos["total"], timeout=LOAD_MS) + expect(cards.first).to_have_attribute("data-verdict", "PARTIAL") + expect(cards.first.get_by_test_id("cockpit-demo-note")).to_have_text( + "Step 6: live search fails on the base env" + ) + # Where each sits, in short names; no PR numbers or commits on the card. + expect(cards.nth(1)).to_contain_text("Agent assistant (sample)") + expect(page.get_by_test_id("cockpit-demos")).not_to_contain_text("#98") + page.get_by_role("button", name="Failed").click() + expect(cards).to_have_count(sum(d["verdict"] in ("FAIL", "ERROR") for d in demos["items"])) + page.get_by_role("button", name="All", exact=True).click() + last = cards.last + last.get_by_test_id("cockpit-demo-play").click() + player = last.get_by_test_id("v3-proof-player") + expect(player).to_be_visible(timeout=LOAD_MS) + _in_view(page, player) + if all(d.get("video_url") for d in demos["items"]): + page.wait_for_function( + "(v) => v.currentTime > 0.3", arg=player.locator("video").element_handle() + ) + assert cockpit.errors == [], cockpit.errors diff --git a/tests/e2e_ui/factory_v3/test_v3_steering.py b/tests/e2e_ui/factory_v3/test_v3_steering.py index 1a91482b61e..e2e23ad3a93 100644 --- a/tests/e2e_ui/factory_v3/test_v3_steering.py +++ b/tests/e2e_ui/factory_v3/test_v3_steering.py @@ -193,8 +193,8 @@ def test_apply_on_a_factory_tree_rule_makes_a_version_with_no_undo( ) -> None: cockpit = open_cockpit(1440, 900, "light") page = cockpit.page - cockpit.open("product") - page.get_by_test_id("cockpit-needs-you").click() + cockpit.open_place() + page.get_by_test_id("cockpit-nav-decisions").click() sheet = page.get_by_test_id("v3-needs-sheet") expect(sheet).to_be_visible(timeout=LOAD_MS) sheet.get_by_test_id("v3-needs-other-tree").click() @@ -436,8 +436,8 @@ def test_needs_you_ranks_each_ask_by_what_it_holds( ) -> None: cockpit = open_cockpit(1440, 900, "light") page = cockpit.page - cockpit.open("product") - page.get_by_test_id("cockpit-needs-you").click() + cockpit.open_place() + page.get_by_test_id("cockpit-nav-decisions").click() sheet = page.get_by_test_id("v3-needs-sheet") expect(sheet).to_be_visible(timeout=LOAD_MS) items = sheet.get_by_test_id("v3-needs-item") @@ -476,7 +476,7 @@ def test_find_opens_nodes_by_task_change_and_proposal_refs( ("#9876", "PR-9876"), (factory.proposal, factory.project), ): - page.get_by_test_id("cockpit-search").click() + page.get_by_test_id("cockpit-nav-search").click() page.get_by_test_id("factory-palette-input").fill(query) hit = page.get_by_test_id(f"factory-palette-node-{ref}") expect(hit).to_be_visible(timeout=LOAD_MS) @@ -499,7 +499,7 @@ def test_the_factory_tree_shows_the_base_env_and_the_closed_alarms( cockpit = open_cockpit(1440, 900, "dark") page = cockpit.page cockpit.open("product") - page.get_by_test_id("cockpit-tree-factory").click() + page.get_by_test_id("cockpit-nav-factory").click() cockpit.wait_for("fx") _clean(page, "fx", "factory root") node(page, "fx").locator('[data-node-link="fx:environments"]').first.click() @@ -516,9 +516,6 @@ def test_the_factory_tree_shows_the_base_env_and_the_closed_alarms( for alarm in ("D-46", "D-47", "D-48"): expect(alarms).to_contain_text(alarm) _clean(page, "fx:health", "health") - # The root's roll-up says where the product's changes are: all in the base env. - cockpit.open("product") - expect(node(page, "product").get_by_test_id("v3-results")).to_contain_text("all in base env") assert cockpit.errors == [], cockpit.errors diff --git a/tests/server/factory/test_node_proof.py b/tests/server/factory/test_node_proof.py index 3ef132abf91..1a61cd04707 100644 --- a/tests/server/factory/test_node_proof.py +++ b/tests/server/factory/test_node_proof.py @@ -179,6 +179,24 @@ def test_the_demos_list_is_the_subtree_newest_first_in_pages(tree: Tree) -> None node_proof.demos_page(tree, node, cursor="99") +def test_the_demos_list_says_where_each_demo_sits(tree: Tree) -> None: + node = tree.lookup("product") + assert node is not None + + page = validate(NodeDemosView, node_proof.demos_page(tree, node, limit=100)) + + by_id = {d["id"]: d for d in page["items"]} + accepted = by_id[hex_id(901)] + assert accepted["feature"]["ref"] == "I-1" and "project" not in accepted + core = next(d for d in page["items"] if d.get("pr_number") == 9876) + assert core["feature"]["ref"] == "I-1" + title = tree.nodes["assistant-core"].title + assert core["project"] == {"ref": "assistant-core", "title": title} + assert core["merged_at"] > 0 + # A page's own row carries none of it (only the list does). + assert "feature" not in (_proof(tree, "assistant-core") or {}).get("latest", {}) + + def test_a_partial_note_names_what_did_not_pass() -> None: demo = demos_1340()[0] skipped = dataclasses.replace( diff --git a/tests/server/factory/test_node_threads.py b/tests/server/factory/test_node_threads.py index 09b6a290858..f042ac694ef 100644 --- a/tests/server/factory/test_node_threads.py +++ b/tests/server/factory/test_node_threads.py @@ -512,6 +512,26 @@ async def test_the_thread_list_names_each_threads_node(env: RoomEnv) -> None: ) +async def test_the_thread_list_says_who_wrote_where_and_the_last_line(env: RoomEnv) -> None: + await _post(env, "task:T-1", "A question about the route (sample)", user="alice") + await _post(env, "factory", "Something for the whole factory (sample)", user="bob") + base = f"/v1/factory/factories/{env.cockpit.fid}/chat/threads" + + data = ( + await env.cockpit.client.get(base, params={"nodes": "1"}, headers={"X-Test-User": "alice"}) + ).json()["data"] + + by_key = {t["key"]: t for t in data} + task, factory = by_key["task:T-1"], by_key["factory"] + assert task["mine"] is True and factory["mine"] is False + assert task["last"]["who"] == "You" and task["last"]["kind"] == "human" + assert task["last"]["text"] == "A question about the route (sample)" + assert factory["last"]["who"] == "bob" + # A thread nobody said anything in has no last line. + assert by_key["project:api-version"]["last"] is None + assert by_key["project:api-version"]["mine"] is False + + async def test_the_thread_list_caps_the_node_threads_at_the_newest( env: RoomEnv, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/web/src/components/factory/FactoryRoomBanner.test.tsx b/web/src/components/factory/FactoryRoomBanner.test.tsx index a12a3f477a2..42475178632 100644 --- a/web/src/components/factory/FactoryRoomBanner.test.tsx +++ b/web/src/components/factory/FactoryRoomBanner.test.tsx @@ -38,7 +38,7 @@ describe("FactoryRoomBanner", () => { expect(banner).toHaveTextContent(`This is ${pl.title}’s machine channel.`); expect(screen.getByRole("link", { name: /Talk to the factory in the Room/ })).toHaveAttribute( "href", - `/factory/${LIVE_RUN_FACTORY_ID}/room/project%3Aapi-version`, + `/factory/${LIVE_RUN_FACTORY_ID}/chats/project%3Aapi-version`, ); }); @@ -46,7 +46,7 @@ describe("FactoryRoomBanner", () => { renderBanner(atlasSession()); expect(screen.getByRole("link", { name: /Talk to the factory/ })).toHaveAttribute( "href", - `/factory/${LIVE_RUN_FACTORY_ID}/room/factory`, + `/factory/${LIVE_RUN_FACTORY_ID}/chats/factory`, ); }); diff --git a/web/src/components/factory/FactoryStrip.test.tsx b/web/src/components/factory/FactoryStrip.test.tsx index 1fac865b5b5..76cc8ff3cca 100644 --- a/web/src/components/factory/FactoryStrip.test.tsx +++ b/web/src/components/factory/FactoryStrip.test.tsx @@ -64,7 +64,7 @@ describe("FactoryStrip", () => { const strip = await screen.findByTestId("factory-strip"); await waitFor(() => expect(strip).toHaveAttribute("data-live", "true")); - expect(strip).toHaveAttribute("href", `/factory/${LIVE_RUN_FACTORY_ID}/projects/api-version`); + expect(strip).toHaveAttribute("href", `/factory/${LIVE_RUN_FACTORY_ID}/n/api-version`); expect(strip).toHaveTextContent("Atlas factory"); expect(strip).toHaveTextContent("api-version"); const stage = within(strip).getByTestId("factory-strip-stage"); @@ -80,7 +80,7 @@ describe("FactoryStrip", () => { expect(strip).toHaveTextContent("$7.10"); expect(strip).toHaveTextContent("Open cockpit"); const mobile = screen.getByTestId("factory-strip-mobile"); - expect(mobile).toHaveAttribute("href", `/factory/${LIVE_RUN_FACTORY_ID}/projects/api-version`); + expect(mobile).toHaveAttribute("href", `/factory/${LIVE_RUN_FACTORY_ID}/n/api-version`); // The same 44 px touch target as the header's other glass pills. expect(mobile).toHaveClass("size-11"); }); @@ -92,7 +92,7 @@ describe("FactoryStrip", () => { const strip = await screen.findByTestId("factory-strip"); await waitFor(() => expect(strip).toHaveAttribute("data-live", "true")); - expect(strip).toHaveAttribute("href", `/factory/${LIVE_RUN_FACTORY_ID}/projects/api-version`); + expect(strip).toHaveAttribute("href", `/factory/${LIVE_RUN_FACTORY_ID}/n/api-version`); expect(strip).toHaveTextContent("api-version"); expect(strip).toHaveTextContent("ic_codex:version-endpoint"); expect(strip).toHaveTextContent("PR #9630"); @@ -172,7 +172,7 @@ describe("FactoryStrip", () => { const strip = await screen.findByTestId("factory-strip"); await waitFor(() => expect(strip).toHaveAttribute("data-live", "true")); - expect(strip).toHaveAttribute("href", `/factory/${LIVE_RUN_FACTORY_ID}/projects/api-version`); + expect(strip).toHaveAttribute("href", `/factory/${LIVE_RUN_FACTORY_ID}/n/api-version`); }); it("renders nothing for a session outside any factory", () => { diff --git a/web/src/components/factory/alerts/runbookModel.test.ts b/web/src/components/factory/alerts/runbookModel.test.ts index 93455dd31bf..16d85d15182 100644 --- a/web/src/components/factory/alerts/runbookModel.test.ts +++ b/web/src/components/factory/alerts/runbookModel.test.ts @@ -128,7 +128,7 @@ const EXPECTED: Record = { "claim.mismatch": [`link:/factory/${FID}/prs/${PR_9631}`, "intervene:message→PL · db-version"], "ci.flaky": [], "ask.unneeded": [`link:/factory/${FID}/improvements?tab=findings`], - "task.unplanned": [`link:/factory/${FID}/projects/db-version#tasks`], + "task.unplanned": [`link:/factory/${FID}/n/db-version`], "heartbeat.waste": [`link:/factory/${FID}/improvements?tab=findings`], "human.report": [`link:/factory/${FID}/agents/${IC_ROUTE_R2}`], }; diff --git a/web/src/components/factory/board/ProjectCard.test.tsx b/web/src/components/factory/board/ProjectCard.test.tsx index 758f87f73f1..2559a1546cf 100644 --- a/web/src/components/factory/board/ProjectCard.test.tsx +++ b/web/src/components/factory/board/ProjectCard.test.tsx @@ -303,7 +303,7 @@ describe("ProjectCard", () => { it("shows team, dev env, demos, cost and elapsed in the footer and links to the page", () => { const { card, snapshot } = renderCard("ready"); - const page = `/factory/${snapshot.factory.id}/projects/api-version`; + const page = `/factory/${snapshot.factory.id}/n/api-version`; expect(within(card).getByTestId("team-strip")).toHaveTextContent("PL"); expect(within(card).getByTestId("dev-env-pill")).toHaveTextContent("main@8c1d2e0"); expect(card).toHaveTextContent("$7.10"); @@ -311,7 +311,7 @@ describe("ProjectCard", () => { expect(within(card).getByTestId("project-details-api-version")).toHaveAttribute("href", page); const demos = within(card).getByTestId("project-demos-api-version"); expect(demos).toHaveTextContent("2 demos"); - expect(demos).toHaveAttribute("href", `${page}#demos`); + expect(demos).toHaveAttribute("href", page); }); it("leaves the demos link out while no PR has a demo", () => { @@ -324,9 +324,6 @@ describe("ProjectCard", () => { renderBoard(); const row = screen.getByTestId("project-card-docs-factory-envs"); expect(row).toHaveTextContent("merged #9632 by @lena"); - expect(row).toHaveAttribute( - "href", - `/factory/${snapshot.factory.id}/projects/docs-factory-envs`, - ); + expect(row).toHaveAttribute("href", `/factory/${snapshot.factory.id}/n/docs-factory-envs`); }); }); diff --git a/web/src/components/factory/cards/DemoCard.tsx b/web/src/components/factory/cards/DemoCard.tsx index 23836d274c9..c2043a6169b 100644 --- a/web/src/components/factory/cards/DemoCard.tsx +++ b/web/src/components/factory/cards/DemoCard.tsx @@ -1,22 +1,44 @@ /** - * A recorded demo as a Room card: its poster (play badge when there is a - * video), status, what it showed and its steps, the commit it ran at, and - * Watch → to the PR page where it plays inline. + * A recorded demo as a chat card: its poster (play badge when there is a + * video), its verdict, what it showed and its steps. A click plays it right + * here, scrolled into view; "Open the change" goes to the change it records. */ -import { ArrowRightIcon, ClapperboardIcon } from "lucide-react"; -import { Button } from "@/components/ui/button"; +import { useEffect, useRef, useState } from "react"; +import { ClapperboardIcon, XIcon } from "lucide-react"; import type { DemoView, PrView } from "@/lib/factoryApi"; -import { Link, useNavigate } from "@/lib/routing"; +import { Link } from "@/lib/routing"; import { cn } from "@/lib/utils"; import { DemoStatusTag, DemoThumb } from "../board/DemoThumb"; -import { prPageHref } from "../factoryRoutes"; +import { DemoMedia } from "../review/DemoPanel"; +import { nodeHref } from "../v3/nodeRoutes"; function stepsLine(demo: DemoView): string | null { if (demo.steps.length === 0) return null; const pass = demo.steps.filter((s) => s.status === "pass").length; const fail = demo.steps.filter((s) => s.status === "fail").length; - return `${demo.steps.length} steps · ${pass} pass${fail ? ` · ${fail} fail` : ""}`; + return `${demo.steps.length} steps · ${pass} passed${fail ? ` · ${fail} failed` : ""}`; +} + +/** The recording, started and scrolled into view (clear of the composer below). */ +function Player({ demo }: { demo: DemoView }) { + const box = useRef(null); + useEffect(() => { + const el = box.current; + const reduce = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false; + el?.scrollIntoView?.({ behavior: reduce ? "auto" : "smooth", block: "center" }); + try { + const started = el?.querySelector("video")?.play?.(); + if (started && typeof started.catch === "function") started.catch(() => undefined); + } catch { + // A browser that refuses to start it keeps its controls. + } + }, []); + return ( +
    + +
    + ); } export function DemoCard({ @@ -30,57 +52,68 @@ export function DemoCard({ demo: DemoView; className?: string; }) { - const navigate = useNavigate(); - const href = prPageHref(factoryId, pr.id); + const [playing, setPlaying] = useState(false); const steps = stepsLine(demo); + const toggle = () => setPlaying((on) => !on); return (
    - navigate(href)} - className="w-full max-w-60 @sm:w-32" - /> + {!playing && ( + + )}

    Demo - !{pr.number} - - {demo.head_sha.slice(0, 7)} - {!demo.is_current_head && " · older head"} - + {!demo.is_current_head && ( + an earlier version + )}

    {demo.title || demo.summary}

    {steps &&

    {steps}

    } - + - Watch on the PR page - + Open the change - +

    + {playing && }
    ); } diff --git a/web/src/components/factory/cmdk/cmdk.test.ts b/web/src/components/factory/cmdk/cmdk.test.ts index 81f08198a3e..570a9453499 100644 --- a/web/src/components/factory/cmdk/cmdk.test.ts +++ b/web/src/components/factory/cmdk/cmdk.test.ts @@ -39,13 +39,13 @@ describe("jumpItems", () => { const kinds = new Set(items.map((i) => i.kind)); expect([...kinds]).toEqual(["page", "project", "pr", "task", "decision", "agent"]); expect(items.find((i) => i.id === "project:db-version")!.href).toBe( - `/factory/${FID}/projects/db-version`, + `/factory/${FID}/n/db-version`, ); const pr = items.find((i) => i.ref === "!9631")!; expect(pr.href).toBe(`/factory/${FID}/prs/${snapshot.prs.find((p) => p.number === 9631)!.id}`); expect(items.some((i) => i.kind === "task" && i.ref?.startsWith("T-"))).toBe(true); const decision = items.find((i) => i.ref === "D-2")!; - expect(decision.href).toBe(`/factory/${FID}/projects/db-version?decision=D-2`); + expect(decision.href).toBe(`/factory/${FID}/n/db-version?decision=D-2`); expect(items.filter((i) => i.kind === "decision").every((i) => i.ref !== "D-1")).toBe(true); expect(items.filter((i) => i.kind === "agent")).toHaveLength(snapshot.members.length); }); @@ -205,15 +205,13 @@ describe("hitItems", () => { expect(hitTarget(TFID, hit("goal", "G-1", "x"), { discoveryOn: false })).toEqual({ href: `/factory/${TFID}/steer/strategy`, }); - expect(href(hit("project", "#csv-export", "x", { slug: "csv-export" }))).toBe( - "/projects/csv-export", - ); + expect(href(hit("project", "#csv-export", "x", { slug: "csv-export" }))).toBe("/n/csv-export"); expect(href(hit("pr", "!9631", "x", { id: "abc" }))).toBe("/prs/abc"); expect(href(hit("agent", "@rex", "Rex", { id: "s1", handle: "rex" }))).toBe("/agents/rex"); expect(href(hit("bet", "C-31", "x"))).toBe("/steer/versions?peek=C-31"); expect(href(hit("proposal", "C-32", "x"))).toBe("/steer/versions?peek=C-32"); expect(href(hit("decision", "D-2", "x", { slug: "csv-export" }))).toBe( - "/projects/csv-export?decision=D-2", + "/n/csv-export?decision=D-2", ); expect(href(hit("task", "T-7", "x", { slug: "csv-export", pr_id: "abc" }))).toBe("/prs/abc"); expect(href(hit("incident", "INC-4", "x"))).toBe("/debug/incidents/4"); diff --git a/web/src/components/factory/cockpit/ChatThreadPage.tsx b/web/src/components/factory/cockpit/ChatThreadPage.tsx new file mode 100644 index 00000000000..31058809fd1 --- /dev/null +++ b/web/src/components/factory/cockpit/ChatThreadPage.tsx @@ -0,0 +1,14 @@ +// One conversation (`/chats/`): for now, the chat of the node the +// thread is about, opened on that node's page. + +import { Navigate } from "react-router-dom"; +import { useFactoryShell } from "../shell/FactoryShellContext"; +import { CHAT_PARAM } from "../v3/chat/chatLayout"; +import { chatNodeRef } from "../v3/chat/nodeThread"; +import { nodeHref } from "../v3/nodeRoutes"; + +export function ChatThreadPage({ thread }: { thread: string }) { + const { factoryId, snapshot } = useFactoryShell(); + const ref = chatNodeRef(thread, { snapshot, sample: false }); + return ; +} diff --git a/web/src/components/factory/cockpit/CockpitNav.tsx b/web/src/components/factory/cockpit/CockpitNav.tsx new file mode 100644 index 00000000000..578a6418e7f --- /dev/null +++ b/web/src/components/factory/cockpit/CockpitNav.tsx @@ -0,0 +1,494 @@ +// The cockpit's one way around, always in view: a sidebar with the four +// places (Home, Demos, Chats, Decisions) and Search, then an outline of the +// work (features, each opening to its projects) with the page's node marked. +// On a phone the places sit in a bottom tab bar and the outline is Home's +// feature list. Counts are what needs this person: unread chats, open asks. + +import { type ReactNode, useEffect, useMemo, useState } from "react"; +import { + ChevronRightIcon, + ClapperboardIcon, + CommandIcon, + FactoryIcon, + HomeIcon, + MessagesSquareIcon, + MoonIcon, + SearchIcon, + SettingsIcon, + SunIcon, + VoteIcon, + WrenchIcon, +} from "lucide-react"; +import { useTheme } from "next-themes"; +import { useResolvedThemeMode } from "@/components/theme/useResolvedThemeMode"; +import type { FactorySnapshot } from "@/lib/factoryApi"; +import { isMacPlatform } from "@/lib/hotkeys"; +import { Link } from "@/lib/routing"; +import { cn } from "@/lib/utils"; +import { debugPageHref, factoryPageHref, settingsPageHref } from "../factoryRoutes"; +import { FactorySwitcher } from "../shell/FactoryTopBar"; +import { FOCUS_RING } from "../ui/statusTokens"; +import { nodeName } from "../v3/calm/calmModel"; +import { StateDot } from "../v3/nodeParts"; +import { useLevel, useNode, usePrefetchNode } from "../v3/nodeQueries"; +import { FX_REF, PRODUCT_REF, nodeHref } from "../v3/nodeRoutes"; +import type { NodeSummary } from "../v3/nodeTypes"; +import { type CockpitPlace, chatsHref, decisionsHref, demosHref } from "./cockpitRoutes"; + +export interface NavCounts { + /** Asks waiting on this person (Decisions). */ + decisions: number; + /** Unread messages across the threads (Chats). */ + unread: number; +} + +const PLACES: { + place: CockpitPlace; + label: string; + icon: typeof HomeIcon; + href: (fid: string) => string; +}[] = [ + { place: "home", label: "Home", icon: HomeIcon, href: factoryPageHref }, + { place: "demos", label: "Demos", icon: ClapperboardIcon, href: (fid) => demosHref(fid) }, + { place: "chats", label: "Chats", icon: MessagesSquareIcon, href: (fid) => chatsHref(fid) }, + { place: "decisions", label: "Decisions", icon: VoteIcon, href: decisionsHref }, +]; + +function Badge({ n, tone, testId }: { n: number; tone: "amber" | "blue"; testId?: string }) { + if (n <= 0) return null; + return ( + + {n > 99 ? "99+" : n} + + ); +} + +function countOf(place: CockpitPlace, counts: NavCounts): { n: number; tone: "amber" | "blue" } { + if (place === "decisions") return { n: counts.decisions, tone: "amber" }; + if (place === "chats") return { n: counts.unread, tone: "blue" }; + return { n: 0, tone: "blue" }; +} + +const ROW = + "flex h-8 min-w-0 items-center gap-2.5 rounded-md px-2 text-[13.5px] transition-colors hover:bg-muted"; + +function SearchButton({ onSearch }: { onSearch: () => void }) { + const mac = isMacPlatform(); + return ( + + ); +} + +function ThemeRow() { + const { setTheme } = useTheme(); + const mode = useResolvedThemeMode(); + const next = mode === "dark" ? "light" : "dark"; + return ( + + ); +} + +// ── The outline of the work ───────────────────────────────────── + +/** Where the page's node sits: its feature and project refs (from its breadcrumb). */ +function useHere(nodeRef: string | null): { feature?: string; project?: string; ref?: string } { + const page = useNode(nodeRef ?? "").data; + return useMemo(() => { + if (!nodeRef || !page) return nodeRef ? { ref: nodeRef } : {}; + const chain = [...page.breadcrumb, { ref: page.node.ref, level: page.node.level }]; + return { + ref: page.node.ref, + feature: chain.find((c) => c.level === "feature")?.ref, + project: chain.find((c) => c.level === "project")?.ref, + }; + }, [nodeRef, page]); +} + +function OutlineLink({ + fid, + node, + depth, + current, + children, +}: { + fid: string; + node: NodeSummary; + depth: 0 | 1; + current: boolean; + children?: ReactNode; +}) { + const prefetch = usePrefetchNode(fid); + const needs = node.needs?.open ?? 0; + return ( + prefetch(node.ref)} + onFocus={() => prefetch(node.ref)} + title={node.full_title ?? node.title} + className={cn( + "flex h-7 min-w-0 flex-1 items-center gap-2 rounded-md pr-2 text-[13px] hover:bg-muted", + depth === 0 ? "pl-1" : "pl-7", + current ? "bg-muted font-semibold text-foreground" : "text-muted-foreground", + node.state === "done" && !current && "text-foreground-tertiary", + FOCUS_RING, + )} + > + {children} + + {nodeName(node)} + {needs > 0 && ( + + {needs} + + )} + + ); +} + +function FeatureProjects({ + fid, + feature, + here, +}: { + fid: string; + feature: string; + here: string | undefined; +}) { + const page = useNode(feature, fid); + const items = page.data?.children.items ?? []; + if (page.isPending) { + return
    ; + } + if (!items.length) { + return

    Nothing planned yet

    ; + } + return ( +
      + {items.map((p) => ( +
    • + +
    • + ))} +
    + ); +} + +function FeatureRow({ + fid, + node, + here, +}: { + fid: string; + node: NodeSummary; + here: { feature?: string; project?: string; ref?: string }; +}) { + const inside = here.feature === node.ref || here.project === node.ref; + const [open, setOpen] = useState(inside); + useEffect(() => { + if (inside) setOpen(true); + }, [inside]); + // A project shown in place of its implicit feature has no projects to list. + const opens = node.level === "feature"; + return ( +
  • +
    + {opens ? ( + + ) : ( + + )} + +
    + {opens && open && ( + + )} +
  • + ); +} + +/** Features (the product's home level), the open ones first, each opening to its projects. */ +export function WorkOutline({ fid, nodeRef }: { fid: string; nodeRef: string | null }) { + const level = useLevel(PRODUCT_REF, "feature", fid); + const here = useHere(nodeRef); + const [showDone, setShowDone] = useState(false); + const all = useMemo(() => (level.data?.groups ?? []).flatMap((g) => g.items), [level.data]); + const open = all.filter((n) => n.state !== "done"); + const done = all.filter((n) => n.state === "done"); + const doneHere = done.some((n) => n.ref === here.feature || n.ref === here.project); + return ( + + ); +} + +// ── The sidebar and the phone's tab bar ───────────────────────── + +export function CockpitSidebar({ + snapshot, + place, + nodeRef, + counts, + factoryAsks, + productName, + onSearch, + onShare, + onAddProject, + style, +}: { + snapshot: FactorySnapshot; + place: CockpitPlace | null; + /** The node page's ref (the outline marks it), else null. */ + nodeRef: string | null; + counts: NavCounts; + /** Asks in the factory tree (how the factory runs). */ + factoryAsks: number; + productName: string | null; + onSearch: () => void; + onShare: () => void; + onAddProject: () => void; + style?: React.CSSProperties; +}) { + const fid = snapshot.factory.id; + const inFx = nodeRef === FX_REF || nodeRef?.startsWith("fx:"); + return ( + + ); +} + +/** The four places and Search on a phone, at the bottom of the screen. */ +export function CockpitTabBar({ + fid, + place, + counts, + onSearch, +}: { + fid: string; + place: CockpitPlace | null; + counts: NavCounts; + onSearch: () => void; +}) { + return ( + + ); +} diff --git a/web/src/components/factory/cockpit/chats.ts b/web/src/components/factory/cockpit/chats.ts new file mode 100644 index 00000000000..6175c61918d --- /dev/null +++ b/web/src/components/factory/cockpit/chats.ts @@ -0,0 +1,113 @@ +// The Chats list: every thread of the factory (its main chat, one per project, +// per PR and per node that has messages), the ones this person wrote in +// first, then the newest; each named in plain words, with its unread count +// and the last thing someone said there. + +import { useMemo } from "react"; +import type { FactorySnapshot, ThreadView } from "@/lib/factoryApi"; +import { useLevel } from "../v3/nodeQueries"; +import { PRODUCT_REF } from "../v3/nodeRoutes"; + +/** Node ref → short name, from the product's feature and project levels (two reads, cached). */ +export function useNodeNames(fid: string): Map { + const features = useLevel(PRODUCT_REF, "feature", fid).data; + const projects = useLevel(PRODUCT_REF, "project", fid).data; + return useMemo(() => { + const out = new Map([[PRODUCT_REF, "Factory chat"]]); + for (const view of [features, projects]) { + for (const g of view?.groups ?? []) for (const n of g.items) out.set(n.ref, n.title); + } + return out; + }, [features, projects]); +} + +/** A thread as the Chats list shows it. */ +export interface ChatRow { + key: string; + /** Plain words: "Factory chat", "Assistant core", "Connector core (PR)". */ + name: string; + /** What kind of place it is, in a word ("Project", "Change"). */ + kind: string; + /** Who answers there. */ + with: string | null; + unread: number; + /** Messages in the thread, updates included. */ + messages: number; + mine: boolean; + last: ThreadView["last"]; + /** Epoch seconds of the newest message (0: none yet). */ + at: number; + /** The node the thread is about. */ + nodeRef: string | null; +} + +const KIND_WORDS: Record = { + factory: "Factory", + improvements: "Factory", + discovery: "Discovery", + project: "Project", + pr: "Change", + task: "Task", + feature: "Feature", + need: "Ask", + study: "Study", +}; + +/** "#assistant-core" → "Assistant core"; "!9876" → "#9876". */ +function fromKey(title: string): string { + const bare = title.replace(/^[#!]/, "").replace(/[-_]+/g, " ").trim(); + return bare ? bare[0]!.toUpperCase() + bare.slice(1) : title; +} + +/** A PR title without its conventional prefix ("feat(x): add y" → "Add y"). */ +export function plainPrTitle(title: string): string { + const bare = title.replace(/^[a-z]+(\([^)]*\))?!?:\s*/i, "").trim(); + return bare ? bare[0]!.toUpperCase() + bare.slice(1) : title; +} + +/** + * How a thread is named: the factory's own chat, a node by its short name + * (`names`: ref → title, from the outline's reads), a PR by its title. + */ +export function chatName( + thread: ThreadView, + snapshot: Pick, + names: ReadonlyMap, +): string { + if (thread.key === "factory") return "Factory chat"; + if (thread.key === "improvements") return "Improving the factory"; + if (thread.key === "discovery") return "Discovery"; + const node = thread.node_ref ? names.get(thread.node_ref) : undefined; + if (node) return node; + if (thread.pr_number != null) { + const pr = snapshot.prs.find((p) => p.number === thread.pr_number); + if (pr) return plainPrTitle(pr.title); + } + return fromKey(thread.title); +} + +export function chatRows( + threads: readonly ThreadView[], + snapshot: Pick, + names: ReadonlyMap, +): ChatRow[] { + const rows = threads.map((t) => ({ + key: t.key, + name: chatName(t, snapshot, names), + kind: KIND_WORDS[t.kind] ?? KIND_WORDS[t.key.split(":")[0] ?? ""] ?? "Chat", + with: t.responder_title, + unread: t.unread, + messages: t.messages, + mine: Boolean(t.mine), + last: t.last ?? null, + at: t.last?.at ?? t.last_at ?? 0, + nodeRef: t.node_ref ?? null, + })); + return rows.sort( + (a, b) => + Number(b.mine) - Number(a.mine) || + Number(b.at > 0) - Number(a.at > 0) || + b.at - a.at || + a.name.localeCompare(b.name), + ); +} diff --git a/web/src/components/factory/cockpit/cockpitRoutes.ts b/web/src/components/factory/cockpit/cockpitRoutes.ts new file mode 100644 index 00000000000..3e7b435a55d --- /dev/null +++ b/web/src/components/factory/cockpit/cockpitRoutes.ts @@ -0,0 +1,117 @@ +// The cockpit's places and where an older address lands in them. The cockpit +// is one thing for everyone (no `?cockpit=` switch): Home, Demos, Chats, +// Decisions and the node pages (`/n/`). Addresses of the earlier +// cockpits (a project page, a PR page, an initiative, the Room, Needs you) +// open the place that shows the same thing now; the earlier pages that have +// no place here stay reachable from Debug. + +import type { PrView } from "@/lib/factoryApi"; +import { factoryPageHref } from "../factoryRoutes"; +import { factoryLocation, findPr } from "../shell/shellRoutes"; +import { nodeHref } from "../v3/nodeRoutes"; + +/** The four places the sidebar (and a phone's tab bar) lists, Search aside. */ +export type CockpitPlace = "home" | "demos" | "chats" | "decisions"; + +export function demosHref(factoryId: string, params?: Record): string { + const qs = params ? new URLSearchParams(params).toString() : ""; + return `${factoryPageHref(factoryId)}/demos${qs ? `?${qs}` : ""}`; +} + +export function chatsHref(factoryId: string, thread?: string | null): string { + const base = `${factoryPageHref(factoryId)}/chats`; + return thread ? `${base}/${encodeURIComponent(thread)}` : base; +} + +export function decisionsHref(factoryId: string): string { + return `${factoryPageHref(factoryId)}/decisions`; +} + +/** Which place an address is in (a node page is in none: the outline marks it). */ +export function placeOf(rest: string | undefined): CockpitPlace | null { + const first = (rest ?? "").split("/").find(Boolean) ?? ""; + if (first === "") return "home"; + if (first === "demos" || first === "chats" || first === "decisions") return first; + return null; +} + +/** Parameters an older cockpit used that mean nothing here (dropped on the way). */ +const DROPPED = ["cockpit", "room", "peek", "tab", "section"] as const; + +function carry(path: string, params: URLSearchParams, drop: readonly string[] = []): string { + const rest = new URLSearchParams(params); + for (const key of [...DROPPED, ...drop]) rest.delete(key); + const qs = rest.toString(); + return qs ? `${path}?${qs}` : path; +} + +/** `PR-`: the change node of a PR, by the number the snapshot knows it by. */ +function prNode(factoryId: string, prs: readonly PrView[], id: string): string { + const pr = findPr(prs, id); + return nodeHref(factoryId, pr ? `PR-${pr.number}` : /^\d+$/.test(id) ? `PR-${id}` : id); +} + +/** Where to go: a path, "wait for the PR list", or nowhere (the address belongs here). */ +export type CockpitTarget = { to: string } | "wait" | null; + +/** + * Where an address of an earlier cockpit opens now. + * + * - `/projects/[/]` → the project's node page (its team editor, + * `/projects//team`, stays); `/projects` → Home. + * - `/prs/`, `?review=`, `?pr=` → the PR's change node (`/n/PR-`); + * a PR id is resolved against `prs`, so the answer is "wait" until they are in. + * - `/initiatives/` → the feature's node page. + * - `/room[/]` and `?room=` → Chats on that thread. + * - `?peek=needs` → Decisions; `?project=` → the project's node page. + * - `?cockpit=` is dropped wherever it shows. + */ +export function cockpitTarget( + factoryId: string, + rest: string | undefined, + params: URLSearchParams, + prs: readonly PrView[] | undefined, +): CockpitTarget { + const location = factoryLocation(rest); + const review = params.get("review"); + const prParam = location.page === "pr" ? null : params.get("pr"); + if (review || prParam || location.page === "pr") { + const id = review ?? prParam ?? location.id ?? ""; + if (!prs) return "wait"; + return { to: carry(prNode(factoryId, prs, id), params, ["review", "pr", "preview"]) }; + } + const project = params.get("project"); + if (project) return { to: carry(nodeHref(factoryId, project), params, ["project"]) }; + const room = params.get("room"); + if (room != null && location.page !== "room") { + return { to: carry(chatsHref(factoryId, room || null), params) }; + } + if (params.get("peek") === "needs") return { to: carry(decisionsHref(factoryId), params) }; + switch (location.page) { + case "project": + // The team editor keeps its address (the Team verb opens it). + if ((rest ?? "").split("/").filter(Boolean)[2] === "team") break; + return { to: carry(nodeHref(factoryId, location.id ?? ""), params) }; + case "projects": + return { to: carry(factoryPageHref(factoryId), params) }; + case "room": + return { to: carry(chatsHref(factoryId, location.id), params) }; + case "other": + if (location.id === "initiatives") { + const ref = (rest ?? "").split("/").filter(Boolean)[1]; + return { + to: carry( + ref ? nodeHref(factoryId, decodeURIComponent(ref)) : factoryPageHref(factoryId), + params, + ), + }; + } + if (location.id === "overview") return { to: carry(factoryPageHref(factoryId), params) }; + break; + default: + break; + } + if (params.has("cockpit")) + return { to: carry(`${factoryPageHref(factoryId)}${rest ? `/${rest}` : ""}`, params) }; + return null; +} diff --git a/web/src/components/factory/cockpit/demos.tsx b/web/src/components/factory/cockpit/demos.tsx new file mode 100644 index 00000000000..ed069ca2e46 --- /dev/null +++ b/web/src/components/factory/cockpit/demos.tsx @@ -0,0 +1,189 @@ +// Demos, one of the cockpit's main places: what the factory recorded of what +// it built, newest first. A demo is a card (poster, title, verdict, what did +// not pass, where it sits, length) that plays in place with one click; the +// player scrolls into view above any sticky bar. + +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { ChevronRightIcon, PlayIcon, XIcon } from "lucide-react"; +import { Link } from "@/lib/routing"; +import { cn } from "@/lib/utils"; +import { DemoStatusTag } from "../board/DemoThumb"; +import { useFactoryNow } from "../factoryClock"; +import { ago } from "../v3/envWords"; +import { NODE_STALE_MS, nodeQueryKeys } from "../v3/nodeQueries"; +import { PRODUCT_REF, nodeHref } from "../v3/nodeRoutes"; +import { useNodeSource } from "../v3/nodeSource"; +import type { DemoRef } from "../v3/nodeTypes"; +import { NOTE_TONE, ProofPlayer, ProofThumb } from "../v3/proof/ProofRow"; +import { notPassed, playLabel } from "../v3/proof/proofModel"; + +/** How many demos the Demos page reads (the server's page maximum). */ +export const DEMOS_READ = 100; + +export const factoryDemosKey = (fid: string, kind: string) => + [...nodeQueryKeys.node(fid, kind as "live", PRODUCT_REF), "demos", "all"] as const; + +/** Every demo of the product, newest first (one read of up to 100). */ +export function useFactoryDemos(fid: string) { + const source = useNodeSource(); + return useQuery({ + queryKey: factoryDemosKey(fid, source?.kind ?? "pending"), + queryFn: async ({ signal }) => + (await source!.demos!(fid, PRODUCT_REF, signal, undefined, DEMOS_READ)) ?? { + ref: PRODUCT_REF, + items: [], + total: 0, + }, + enabled: Boolean(fid && source?.demos), + staleTime: NODE_STALE_MS * 6, + }); +} + +/** "Agent assistant › Assistant core" (the project alone when it stands for its feature). */ +export function DemoPlace({ demo, fid }: { demo: DemoRef; fid: string }) { + const { feature, project } = demo; + const parts = [feature, project && project.ref !== feature?.ref ? project : null].filter( + (p): p is { ref: string; title: string } => Boolean(p), + ); + if (!parts.length) return null; + return ( + + {parts.map((p, i) => ( + + {i > 0 && } + e.stopPropagation()} + className="truncate hover:text-foreground hover:underline" + > + {p.title} + + + ))} + + ); +} + +/** + * One demo as a card: the poster, its title and verdict, what did not pass, + * where it sits and how long it runs. One click (poster or title) plays it + * here; a second closes it. + */ +export function DemoCard({ + demo, + fid, + open: first = false, + compact = false, +}: { + demo: DemoRef; + fid: string; + open?: boolean; + /** Home's shorter row: a smaller poster, no place line. */ + compact?: boolean; +}) { + const [open, setOpen] = useState(first); + const now = useFactoryNow(); + const when = ago(demo.at, now); + const toggle = () => setOpen((on) => !on); + return ( +
  • +
    + +
    + + {notPassed(demo.verdict) && demo.partial_note && ( +

    + {demo.partial_note} +

    + )} +

    + {!compact && } + {!compact && (demo.feature || demo.project) && ( + + · + + )} + {demo.duration && {demo.duration}} + {when && ( + <> + + · + + {when} + + )} + {demo.stale && ( + <> + + · + + an earlier version + + )} +

    +
    +
    + {open && ( +
    + +
    + )} +
  • + ); +} + +export function DemoListSkeleton({ rows = 4 }: { rows?: number }) { + return ( +
      + {Array.from({ length: rows }, (_, i) => ( +
    • +
      +
      +
      +
      +
      +
    • + ))} +
    + ); +} diff --git a/web/src/components/factory/factoryRoutes.ts b/web/src/components/factory/factoryRoutes.ts index b9d389b47d2..1ae377f9857 100644 --- a/web/src/components/factory/factoryRoutes.ts +++ b/web/src/components/factory/factoryRoutes.ts @@ -127,33 +127,40 @@ export function projectsPageHref(factoryId: string): string { return `${factoryPageHref(factoryId)}/projects`; } -/** `/factory//projects/[/]`: a tab of the new cockpit's project page. */ +/** + * A project's page: its node page (`/factory//n/`). The earlier + * cockpit's tabs are gone; the tab is ignored. + */ export function projectTabHref( factoryId: string, slug: string, - tab: ProjectTab = "overview", + _tab: ProjectTab = "overview", params?: Record, ): string { - const tail = tab === "overview" ? "" : `/${tab}`; - return withQuery(`${projectsPageHref(factoryId)}/${enc(slug)}${tail}`, params); + return withQuery(`${factoryPageHref(factoryId)}/n/${enc(slug)}`, params); } /** - * `/factory//projects/`, scrolled to `section` (the top when - * omitted), with any extra query parameters (e.g. `decision`). + * A project's page: its node page (`/factory//n/`), with any + * extra query parameters (e.g. `decision`). The earlier page's sections are + * gone; `section` is ignored. */ export function projectPageHref( factoryId: string, slug: string, options: { section?: ProjectSection; params?: Record } = {}, ): string { - const path = withQuery(`${projectsPageHref(factoryId)}/${enc(slug)}`, options.params); - return options.section && options.section !== "overview" ? `${path}#${options.section}` : path; + return withQuery(`${factoryPageHref(factoryId)}/n/${enc(slug)}`, options.params); +} + +/** `/factory//projects//team`: a project's team editor. */ +export function teamEditorPageHref(factoryId: string, slug: string): string { + return `${projectsPageHref(factoryId)}/${enc(slug)}/team`; } -/** `/factory//initiatives/`: one outcome several projects build. */ +/** A feature's page: its node page (`/factory//n/`). */ export function initiativePageHref(factoryId: string, ref: string): string { - return `${factoryPageHref(factoryId)}/initiatives/${enc(ref)}`; + return `${factoryPageHref(factoryId)}/n/${enc(ref)}`; } /** `/factory//prs/` (a PR number also resolves there). */ @@ -278,9 +285,9 @@ export function debugPageHref( return withQuery(`${factoryPageHref(factoryId)}/debug/${view}${tail}`, options.params); } -/** `/factory//room/` (the Room page; the thread is optional). */ +/** `/factory//chats/` (Chats; the thread is optional). */ export function roomPageHref(factoryId: string, thread?: string | null): string { - return `${factoryPageHref(factoryId)}/room${thread ? `/${enc(thread)}` : ""}`; + return `${factoryPageHref(factoryId)}/chats${thread ? `/${enc(thread)}` : ""}`; } /** `/factory//decisions` (open first; `?decision=D-7` highlights one). */ diff --git a/web/src/components/factory/improvements/improvements.test.tsx b/web/src/components/factory/improvements/improvements.test.tsx index f53d41d5e21..4d3ec1b03f4 100644 --- a/web/src/components/factory/improvements/improvements.test.tsx +++ b/web/src/components/factory/improvements/improvements.test.tsx @@ -154,7 +154,7 @@ describe("improvements model", () => { expect(evidenceHref(FID, "D-2")).toBe(`/factory/${FID}/decisions?decision=D-2`); expect(evidenceHref(FID, "C-11")).toBe(`/factory/${FID}/improvements/C-11`); expect(evidenceHref(FID, "!9631")).toBe(`/factory/${FID}/prs/9631`); - expect(evidenceHref(FID, "#db-version")).toBe(`/factory/${FID}/projects/db-version`); + expect(evidenceHref(FID, "#db-version")).toBe(`/factory/${FID}/n/db-version`); expect(evidenceHref(FID, "span:abc")).toBeNull(); }); diff --git a/web/src/components/factory/overview/__fixtures__/nowFixtures.ts b/web/src/components/factory/overview/__fixtures__/nowFixtures.ts index bb440389a4d..44df75921b1 100644 --- a/web/src/components/factory/overview/__fixtures__/nowFixtures.ts +++ b/web/src/components/factory/overview/__fixtures__/nowFixtures.ts @@ -36,9 +36,13 @@ import { factoryPageHref, insightsPageHref, prPageHref, - projectPageHref, } from "../../factoryRoutes"; +/** The project link the server's status summary writes (its older address; the shell redirects it). */ +function projectPageHref(fid: string, slug: string): string { + return `${factoryPageHref(fid)}/projects/${encodeURIComponent(slug)}`; +} + const TEXT_MAX = 200; const NOTE_MAX = 140; const LABEL_MAX = 100; diff --git a/web/src/components/factory/project/projectModel.test.ts b/web/src/components/factory/project/projectModel.test.ts index a6024808f21..444fff4d110 100644 --- a/web/src/components/factory/project/projectModel.test.ts +++ b/web/src/components/factory/project/projectModel.test.ts @@ -123,10 +123,10 @@ describe("demoGroupsFrom (the server's per-PR groups)", () => { describe("project page links", () => { it("encodes the factory and slug; a section is an anchor, Overview is the top", () => { - expect(projectPageHref("f 1", "a/b")).toBe("/factory/f%201/projects/a%2Fb"); - expect(projectPageHref("f1", "api", { section: "overview" })).toBe("/factory/f1/projects/api"); + expect(projectPageHref("f 1", "a/b")).toBe("/factory/f%201/n/a%2Fb"); + expect(projectPageHref("f1", "api", { section: "overview" })).toBe("/factory/f1/n/api"); expect(projectPageHref("f1", "api", { section: "demos", params: { decision: "D-2" } })).toBe( - "/factory/f1/projects/api?decision=D-2#demos", + "/factory/f1/n/api?decision=D-2", ); expect(projectSection("demos")).toBe("demos"); expect(projectSection("#prs")).toBe("prs"); diff --git a/web/src/components/factory/review/PrPage.test.tsx b/web/src/components/factory/review/PrPage.test.tsx index 46f5218c280..629ee03bd43 100644 --- a/web/src/components/factory/review/PrPage.test.tsx +++ b/web/src/components/factory/review/PrPage.test.tsx @@ -371,7 +371,7 @@ describe("PR page · header", () => { expect(within(drawer).getByTestId("pr-breadcrumb")).toHaveTextContent("api-version"); expect(within(drawer).getByTestId("pr-breadcrumb-project")).toHaveAttribute( "href", - `/factory/${LIVE_RUN_FACTORY_ID}/projects/api-version#prs`, + `/factory/${LIVE_RUN_FACTORY_ID}/n/api-version`, ); expect(within(drawer).getByTestId("pr-gates")).toBeInTheDocument(); expect(within(drawer).getByTestId("pr-loops")).toBeInTheDocument(); diff --git a/web/src/components/factory/room/RoomText.test.tsx b/web/src/components/factory/room/RoomText.test.tsx index 9935176ef76..daa74e1afc3 100644 --- a/web/src/components/factory/room/RoomText.test.tsx +++ b/web/src/components/factory/room/RoomText.test.tsx @@ -50,7 +50,7 @@ describe("RoomText refs", () => { ); expect(screen.getByTestId("room-ref-#api-version")).toHaveAttribute( "href", - expect.stringContaining("/projects/api-version"), + expect.stringContaining("/n/api-version"), ); }); diff --git a/web/src/components/factory/shell/FactoryTopBar.tsx b/web/src/components/factory/shell/FactoryTopBar.tsx index 2812394ad31..f538c42aad1 100644 --- a/web/src/components/factory/shell/FactoryTopBar.tsx +++ b/web/src/components/factory/shell/FactoryTopBar.tsx @@ -225,7 +225,7 @@ export function VerdictPill({ // ── The switcher and you ─────────────────────────────────────── -function FactorySwitcher({ +export function FactorySwitcher({ snapshot, productName, compact, diff --git a/web/src/components/factory/shell/cockpitV2.ts b/web/src/components/factory/shell/cockpitV2.ts index 5f56f3c37fd..7a02f88b572 100644 --- a/web/src/components/factory/shell/cockpitV2.ts +++ b/web/src/components/factory/shell/cockpitV2.ts @@ -1,189 +1,56 @@ -// The new cockpit is dark by default. The server offers it for people to try -// (`OMNIGENT_FACTORY_COCKPIT_V2`, read from preflight), and each person -// switches to it and back. The choice is the person's own preference in the -// factory (`ui.cockpit`, read from the small `notify/me/ui`, so it follows them -// to another browser); this browser remembers it too, for people the server -// cannot store it for (not on the roster, auth off) and while the preference -// loads. A link can carry it (`?cockpit=v2` or `?cockpit=v1`) while the server -// offers the new cockpit. +// Which cockpit a person sees: cockpit v3, for everyone. Earlier builds let +// each person switch (v1 classic, v2, v3 preview) and remembered the choice +// in this browser and on the server; there is no switch any more, and a +// `?cockpit=` link is dropped by the shell. The earlier cockpit's pages stay +// reachable from Debug and render their newest variant. // -// Cockpit v3 (the work levels, `?cockpit=v3`) is a preview behind the same -// offer: the server stores only v1 or v2, so this browser keeps a v3 choice, -// and it wins over the stored one until the person picks v1 or v2 again. +// The storage helpers stay for stories and tests that still write a choice; +// nothing reads it. -import { useCallback, useEffect, useSyncExternalStore } from "react"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { usePreflight } from "@/hooks/useFactory"; -import { - type CockpitPrefsView, - type CockpitChoice as SavedCockpitChoice, - fetchCockpitPrefs, - putCockpitPrefs, -} from "@/lib/factoryApi"; -import { useSearchParams } from "@/lib/routing"; -import { notifyMeKey } from "../notify/notifyQueries"; -import { useOptionalFactoryShell } from "./FactoryShellContext"; - -/** v1 classic, v2 the new cockpit, v3 the work levels (kept in this browser only). */ -export type CockpitChoice = SavedCockpitChoice | "v3"; +/** v1 classic, v2 the new cockpit, v3 the work levels. */ +export type CockpitChoice = "v1" | "v2" | "v3"; +/** @deprecated Nothing reads it; to be removed with the earlier cockpits' pages (next release). */ export const COCKPIT_STORAGE_KEY = "omnigent.factory.cockpit"; export const COCKPIT_PARAM = "cockpit"; -function isChoice(value: unknown): value is CockpitChoice { - return value === "v1" || value === "v2" || value === "v3"; -} - -// Every reader re-renders on a switch, in this tab and in others. -const listeners = new Set<() => void>(); -/** The choice when storage is unavailable (a private window keeps it for this page). */ -let memory: CockpitChoice | null = null; - -/** The choice stored in this browser, if any (storage may be unavailable). */ -export function readCockpitChoice(): CockpitChoice | null { - try { - const value = window.localStorage.getItem(COCKPIT_STORAGE_KEY); - return isChoice(value) ? value : null; - } catch { - return memory; - } -} - +/** @deprecated Kept for stories and tests; nothing reads the choice. */ export function writeCockpitChoice(choice: CockpitChoice): void { - memory = choice; try { window.localStorage.setItem(COCKPIT_STORAGE_KEY, choice); } catch { - // Kept in memory instead. + // Storage may be unavailable; nothing reads it. } - for (const listener of listeners) listener(); } -/** Forget the in-memory copy (tests). */ -export function resetCockpitChoiceCache(): void { - memory = null; -} +/** @deprecated Kept for tests; there is no cache. */ +export function resetCockpitChoiceCache(): void {} -function subscribe(listener: () => void): () => void { - listeners.add(listener); - window.addEventListener("storage", listener); - return () => { - listeners.delete(listener); - window.removeEventListener("storage", listener); - }; -} +export const cockpitPrefsKey = (factoryId: string) => ["factory-cockpit-prefs", factoryId] as const; export interface CockpitV2 { - /** The server lets people switch (preflight `cockpit_v2`). */ available: boolean; - /** The viewer's choice is not known yet: show neither cockpit. */ loading: boolean; - /** The server's answers are in (an address of either cockpit can be followed). */ settled: boolean; - /** This person sees the new cockpit (v2, or v3 built on it). */ on: boolean; - /** This person sees cockpit v3: the node pages, on the new cockpit's shell. */ v3: boolean; - /** Switch to the new cockpit, or back. */ choose: (choice: CockpitChoice) => void; } -export const cockpitPrefsKey = (factoryId: string) => ["factory-cockpit-prefs", factoryId] as const; - -/** - * The person's saved choice: read once per page load (only this person - * changes it, and the switch writes the cache), never again on focus, and a - * refusal (not on the roster) is not asked again. - */ -function useCockpitPrefs(factoryId: string) { - return useQuery({ - queryKey: cockpitPrefsKey(factoryId), - queryFn: ({ signal }) => fetchCockpitPrefs(factoryId, signal), - enabled: Boolean(factoryId), - staleTime: Number.POSITIVE_INFINITY, - refetchOnWindowFocus: false, - retry: false, - retryOnMount: false, - }); -} +const ALWAYS_V3: CockpitV2 = { + available: true, + loading: false, + settled: true, + on: true, + v3: true, + choose: () => undefined, +}; /** - * Whether the viewer sees the new cockpit: the server allows it and they chose - * it. The person's preference in the factory wins over this browser's copy, - * and a switch made here wins over a read still on its way. + * The cockpit the viewer sees: always v3. * - * @param factoryId - The factory whose preference counts (the shell's when omitted). + * @param _factoryId - Unused; kept for callers that name the factory. */ -export function useCockpitV2(factoryId?: string): CockpitV2 { - const shell = useOptionalFactoryShell(); - const fid = factoryId ?? shell?.factoryId ?? ""; - const preflight = usePreflight(); - const available = preflight.data?.cockpit_v2 === true; - const queryClient = useQueryClient(); - const prefs = useCockpitPrefs(available ? fid : ""); - const [params, setParams] = useSearchParams(); - const fromLink = params.get(COCKPIT_PARAM); - const local = useSyncExternalStore(subscribe, readCockpitChoice, () => null); - const saved = prefs.data?.cockpit ?? null; - - const choose = useCallback( - (next: CockpitChoice) => { - writeCockpitChoice(next); - // The server stores v1 or v2 only; v3 stays in this browser. - if (!fid || next === "v3") return; - const key = cockpitPrefsKey(fid); - const keep = () => queryClient.setQueryData(key, { cockpit: next }); - // The click is newer than any read on its way: drop that answer, keep the click. - if (queryClient.isFetching({ queryKey: key })) { - void queryClient.cancelQueries({ queryKey: key }).then(keep); - } else { - keep(); - } - // Sent even before the read answers; someone not on the roster gets a 409 - // and keeps the choice in this browser only. - putCockpitPrefs(fid, { cockpit: next }).then( - (view) => { - queryClient.setQueryData(key, view); - void queryClient.invalidateQueries({ queryKey: notifyMeKey(fid) }); - }, - () => undefined, - ); - }, - [fid, queryClient], - ); - - // A link's choice counts only while the server offers the new cockpit; it is - // kept, then dropped from the address. - const fromLinkChoice = isChoice(fromLink) ? fromLink : null; - const linked = available ? fromLinkChoice : null; - // Waits only for the first answer: a refetch (say after an error) never hides the page again. - const prefsAnswered = !available || !fid || prefs.isFetched; - const settled = !preflight.isLoading && prefsAnswered; - useEffect(() => { - if (!linked || !settled) return; - choose(linked); - setParams( - (prev) => { - const next = new URLSearchParams(prev); - next.delete(COCKPIT_PARAM); - return next; - }, - { replace: true }, - ); - }, [linked, settled, choose, setParams]); - - // A v3 choice lives in this browser only, so it wins over the stored v1 or v2. - const choice = linked ?? (local === "v3" ? local : (saved ?? local)); - const wanted = fromLinkChoice ?? local; - const loading = - (preflight.isLoading && (wanted === "v2" || wanted === "v3")) || - (!prefsAnswered && linked == null && local == null); - return { - available, - loading, - settled, - on: available && (choice === "v2" || choice === "v3"), - v3: available && choice === "v3", - choose, - }; +export function useCockpitV2(_factoryId?: string): CockpitV2 { + return ALWAYS_V3; } diff --git a/web/src/components/factory/shell/shellRoutes.test.ts b/web/src/components/factory/shell/shellRoutes.test.ts index e029e571b42..462ac011bd9 100644 --- a/web/src/components/factory/shell/shellRoutes.test.ts +++ b/web/src/components/factory/shell/shellRoutes.test.ts @@ -104,10 +104,10 @@ describe("legacyFactoryRedirect", () => { it("sends the old drawer's ?project= to the project page, keeping decision and preview", () => { expect(redirect("", "project=api-version")).toEqual({ - to: `/factory/${FID}/projects/api-version`, + to: `/factory/${FID}/n/api-version`, }); expect(redirect("", "project=db-version&decision=D-2")).toEqual({ - to: `/factory/${FID}/projects/db-version?decision=D-2`, + to: `/factory/${FID}/n/db-version?decision=D-2`, }); // A review stacked on the project drawer wins: the PR has its own page. expect(redirect("", "project=api-version&review=9630")).toEqual({ @@ -125,10 +125,10 @@ describe("legacyFactoryRedirect", () => { it("turns a project page's ?tab= into its section anchor", () => { expect(redirect("projects/api-version", "tab=demos")).toEqual({ - to: `/factory/${FID}/projects/api-version#demos`, + to: `/factory/${FID}/n/api-version`, }); expect(redirect("projects/api-version", "tab=overview&decision=D-4")).toEqual({ - to: `/factory/${FID}/projects/api-version?decision=D-4`, + to: `/factory/${FID}/n/api-version?decision=D-4`, }); expect(redirect("projects/api-version", "review=9630")).toEqual({ to: `/factory/${FID}/prs/${PR_9630}`, @@ -217,8 +217,8 @@ describe("cockpitRedirect", () => { expect(land("room/factory", "", "v2", `${BASE}/projects?view=board`)).toBe( `${BASE}/projects?view=board&room=factory`, ); - expect(land("room", "", "v2", `${BASE}/projects/db-version?room=old`)).toBe( - `${BASE}/projects/db-version?room=`, + expect(land("room", "", "v2", `${BASE}/n/db-version?room=old`)).toBe( + `${BASE}/n/db-version?room=`, ); expect(land("room/factory", "", "v2", `${BASE}?view=board`)).toBe( `${BASE}?view=board&room=factory`, @@ -277,10 +277,10 @@ describe("cockpitRedirect", () => { ["steer/versions", "peek=C-14", `${BASE}/improvements/C-14`], ["steer/versions", "peek=F-3", `${BASE}/improvements/F-3`], // The Room drawer over any page opens the classic Room page on that thread. - ["projects/db-version", "room=project%3Adb-version", `${BASE}/room/project%3Adb-version`], - ["", "room=factory", `${BASE}/room/factory`], - ["projects", "room=", `${BASE}/room`], - ["teams", "room=factory&q=x", `${BASE}/room/factory?q=x`], + ["projects/db-version", "room=project%3Adb-version", `${BASE}/chats/project%3Adb-version`], + ["", "room=factory", `${BASE}/chats/factory`], + ["projects", "room=", `${BASE}/chats`], + ["teams", "room=factory&q=x", `${BASE}/chats/factory?q=x`], ["steer/budgets", "", `${BASE}/settings/budgets`], ["steer/alerts", "", `${BASE}/settings/alerts`], ["steer/definition", "", `${BASE}/settings/definition`], diff --git a/web/src/components/factory/team/TeamEditor.test.tsx b/web/src/components/factory/team/TeamEditor.test.tsx index 350e9f40e03..9f3872e3582 100644 --- a/web/src/components/factory/team/TeamEditor.test.tsx +++ b/web/src/components/factory/team/TeamEditor.test.tsx @@ -183,9 +183,7 @@ describe("the team editor", () => { expect(screen.getByTestId("team-no-trial")).toHaveTextContent("No trial for one project."); await waitFor(() => expect(screen.getByTestId("team-apply")).toBeEnabled()); fireEvent.click(screen.getByTestId("team-apply")); - await waitFor(() => - expect(location).toBe(`/factory/${snapshot.factory.id}/projects/${TEAM_SLUG}`), - ); + await waitFor(() => expect(location).toBe(`/factory/${snapshot.factory.id}/n/${TEAM_SLUG}`)); expect(sent(calls, "PUT", "/definition")).toEqual([ { changes: [ @@ -285,9 +283,7 @@ describe("the team editor", () => { renderAt(snapshot, EDITOR); await waitFor(() => expect(screen.getByTestId("team-apply")).toBeEnabled()); fireEvent.click(screen.getByTestId("team-apply")); - await waitFor(() => - expect(location).toBe(`/factory/${snapshot.factory.id}/projects/${TEAM_SLUG}`), - ); + await waitFor(() => expect(location).toBe(`/factory/${snapshot.factory.id}/n/${TEAM_SLUG}`)); expect(readTeamStaging(snapshot.factory.id, TEAM_SLUG)).toEqual({ changes: [], when: "after_current", @@ -355,49 +351,6 @@ describe("the team editor", () => { expect(screen.getByTestId("staged-team-panel")).toHaveTextContent("Staged changes"); }); - it("lands on the project's Team section without the new cockpit", async () => { - window.localStorage.clear(); - const { snapshot } = serve({ cockpitV2: false }); - renderAt(snapshot, EDITOR); - await waitFor(() => - expect(location).toBe(`/factory/${snapshot.factory.id}/projects/${TEAM_SLUG}#team`), - ); - }); -}); - -describe("the project page's team", () => { - it("draws the team as a chart with each member's model, state and spend", async () => { - const { snapshot, calls } = serve(); - renderAt(snapshot, `/projects/${TEAM_SLUG}`); - const section = await screen.findByTestId("team-section"); - await waitFor(() => - expect(within(section).getByTestId("team-summary")).toHaveTextContent( - "6 members · 2 builders at once · changed in definition v12", - ), - ); - // One Team region: the chart is the page's Team section (`#team`). - expect(screen.getAllByRole("region", { name: "Team" })).toHaveLength(1); - expect(section).toHaveAttribute("id", "team"); - // The chart reads the snapshot's sessions, not the fleet. - expect(calls.filter((c) => c.path.endsWith("/agents"))).toEqual([]); - await waitFor(() => - expect(within(section).getByTestId("team-tile-M-6")).toHaveTextContent("Dot"), - ); - const dot = within(section).getByTestId("team-tile-M-6"); - expect(dot).toHaveTextContent("Dot"); - expect(dot).toHaveTextContent("claude · sonnet"); - expect(dot).toHaveTextContent("Stuck"); - expect(within(section).getByTestId("team-group-build")).toHaveTextContent("Ivy"); - fireEvent.click(dot); - await waitFor(() => expect(location).toContain("peek=agent%3Adot")); - const panel = await screen.findByTestId("agent-panel"); - // "Change model…" on an agent opens its project's team editor. - expect(within(panel).getByRole("link", { name: /Change model/ })).toHaveAttribute( - "href", - `/factory/${snapshot.factory.id}${EDITOR}`, - ); - }); - it("links each project team on Teams to its editor", async () => { const { snapshot } = serve(); renderAt(snapshot, "/teams"); @@ -407,48 +360,4 @@ describe("the project page's team", () => { `/factory/${snapshot.factory.id}${EDITOR}`, ); }); - - it("restores an earlier team from History as a staged change in the editor", async () => { - const { snapshot } = serve(); - renderAt(snapshot, `/projects/${TEAM_SLUG}`); - await screen.findByTestId("team-section"); - fireEvent.click(screen.getByRole("button", { name: "History" })); - const v10 = await screen.findByTestId("team-history-v10"); - expect(screen.getByTestId("team-history-v12")).toHaveTextContent("current"); - fireEvent.click(within(v10).getByRole("button", { name: "Restore" })); - await waitFor(() => expect(location).toBe(`/factory/${snapshot.factory.id}${EDITOR}`)); - expect(readTeamStaging(snapshot.factory.id, TEAM_SLUG).changes).toMatchObject([ - { kind: "restore", version: 10 }, - ]); - await screen.findByTestId("team-editor"); - }); - - it("goes to the editor on `e`", async () => { - const { snapshot } = serve(); - renderAt(snapshot, `/projects/${TEAM_SLUG}`); - await screen.findByTestId("team-section"); - fireEvent.keyDown(window, { key: "e" }); - await waitFor(() => expect(location).toBe(`/factory/${snapshot.factory.id}${EDITOR}`)); - }); - - it("names the version in force after an Undo, not the undone one", async () => { - const { snapshot } = serve({ undone: true }); - renderAt(snapshot, `/projects/${TEAM_SLUG}`); - const section = await screen.findByTestId("team-section"); - await waitFor(() => - expect(within(section).getByTestId("team-summary")).toHaveTextContent( - "changed in definition v12", - ), - ); - expect(screen.queryByTestId("team-undo")).toBeNull(); - }); - - it("offers Undo in the header while the last team change is fresh", async () => { - const { snapshot, calls } = serve({ justApplied: true }); - renderAt(snapshot, `/projects/${TEAM_SLUG}`); - const undo = await screen.findByTestId("team-undo"); - expect(undo).toHaveTextContent("Undo v13"); - fireEvent.click(undo); - await waitFor(() => expect(sent(calls, "POST", "/definition/pin")).toEqual([{ version: 12 }])); - }); }); diff --git a/web/src/components/factory/team/teamStaging.ts b/web/src/components/factory/team/teamStaging.ts index 16f0062605b..a1804cded4c 100644 --- a/web/src/components/factory/team/teamStaging.ts +++ b/web/src/components/factory/team/teamStaging.ts @@ -6,7 +6,7 @@ // and "add a reviewer to …", the agent panel's "Change model…"). import { useCallback, useSyncExternalStore } from "react"; -import { projectPageHref } from "@/components/factory/factoryRoutes"; +import { teamEditorPageHref } from "@/components/factory/factoryRoutes"; import { useOptionalFactoryShell } from "@/components/factory/shell/FactoryShellContext"; import { useNavigate } from "@/lib/routing"; import { @@ -128,7 +128,7 @@ export function useTeamStaging( /** `/factory//projects//team`: the team editor. */ export function teamEditorHref(factoryId: string, slug: string): string { - return `${projectPageHref(factoryId, slug)}/team`; + return teamEditorPageHref(factoryId, slug); } /** diff --git a/web/src/components/factory/v3/Breadcrumb.tsx b/web/src/components/factory/v3/Breadcrumb.tsx new file mode 100644 index 00000000000..0b231920b5f --- /dev/null +++ b/web/src/components/factory/v3/Breadcrumb.tsx @@ -0,0 +1,58 @@ +// A node page's plain breadcrumb: Home › Agent assistant › Assistant core › +// this page. Each step is a link (read ahead on hover); the factory tree's +// root reads "The factory". Folded implicit parents are left out. + +import { Link } from "@/lib/routing"; +import { cn } from "@/lib/utils"; +import { factoryPageHref } from "../factoryRoutes"; +import { nodeName } from "./calm/calmModel"; +import { useNodeActions } from "./NodeContext"; +import { NodeLink } from "./nodeParts"; +import { FX_REF, PRODUCT_REF } from "./nodeRoutes"; +import type { NodePage } from "./nodeTypes"; + +const LINK = "min-w-0 truncate hover:text-foreground hover:underline"; + +export function Breadcrumb({ page }: { page: NodePage }) { + const { factoryId } = useNodeActions(); + const crumbs = page.breadcrumb.filter((c) => !c.folded && !c.implicit && c.level !== "need"); + const first = crumbs[0]; + const rest = + first && (first.ref === PRODUCT_REF || first.ref === FX_REF) ? crumbs.slice(1) : crumbs; + const inFx = page.node.ref === FX_REF || first?.ref === FX_REF; + return ( + + ); +} diff --git a/web/src/components/factory/v3/NodePage.test.tsx b/web/src/components/factory/v3/NodePage.test.tsx index 83205d65621..53bd8d6ddd1 100644 --- a/web/src/components/factory/v3/NodePage.test.tsx +++ b/web/src/components/factory/v3/NodePage.test.tsx @@ -180,63 +180,25 @@ describe("cockpit v3 · Your move, Details and answers", () => { }); }); -describe("cockpit v3 · the level strip", () => { - it("counts come from levels_below, “you” counts the asks Your move ranks under each level; empty levels are left out", async () => { - const source = sampleNodeSource(); - const node = source.node; - source.node = async (fid, ref) => { - const page = (await node(fid, ref)) as Page; - return { - ...page, - levels_below: [ - { - level: "project", - name: "Workstreams", - count: 7, - later: 2, - needs: 3, - by_state: { needs: 3, done: 4 }, - }, - { level: "task", name: "Tasks", count: 0, later: 5, needs: 0, by_state: {} }, - { level: "change", name: "Changes", count: 0, later: 0, needs: 0, by_state: {} }, - ], - }; - }; - renderNode("I-1", { source }); - await shown("I-1"); - const strip = screen.getByTestId("v3-level-strip"); - // One ask (C-16) sits under a project of I-1: the strip says 1, as Your move counts it. - expect(screen.getByTestId("v3-level-project")).toHaveTextContent("7+2 later1 you"); - // A level holding only later work says so; it never shows a 0. - expect(screen.getByTestId("v3-level-task")).toHaveTextContent("5 later"); - // A level with nothing in it is left out, even when the data lists it. - expect(within(strip).queryByText("Changes")).toBeNull(); - expect(screen.queryByTestId("v3-level-change")).toBeNull(); - expect(within(strip).getByText("Workstreams")).toBeInTheDocument(); - }); - - it("names the node by its short name in the strip; codes and priorities are Details", async () => { - const view = renderNode("T-16"); +describe("cockpit v3 · the breadcrumb", () => { + it("is a plain breadcrumb from Home down to the parent, never a level strip", async () => { + renderNode("T-16"); await shown("T-16"); - const strip = screen.getByTestId("v3-level-strip"); - expect( - within(strip).getByRole("link", { name: /^Task.*Debit each turn$/ }), - ).toBeInTheDocument(); - expect(within(strip).queryByText(/T-16 ·/)).toBeNull(); - view.unmount(); - renderNode("assistant-core"); - await shown("assistant-core"); - expect(within(screen.getByTestId("v3-level-strip")).queryByText(/P0/)).toBeNull(); + expect(screen.queryByTestId("v3-level-strip")).toBeNull(); + const crumbs = screen.getByTestId("v3-breadcrumb"); + const links = within(crumbs).getAllByRole("link"); + expect(links[0]).toHaveTextContent("Home"); + expect(links[0]).toHaveAttribute("href", "/factory/f-sample"); + // Short names only: no codes, no level names, no need's raw ask. + expect(crumbs).not.toHaveTextContent(/T-16|I-1|Feature|Project/); + expect(links.at(-1)).toHaveTextContent("Assistant core"); }); - it("folds implicit ancestors into their only child, in italics without a state dot", async () => { + it("leaves folded implicit ancestors out", async () => { renderNode("build-tier-drafts-3"); await shown("build-tier-drafts-3"); - const strip = screen.getByTestId("v3-level-strip"); - const folded = within(strip).getAllByTitle(/implicit, folded/); - expect(folded).toHaveLength(2); - // Nothing merged yet: no Results cell (nothing renders empty). - expect(within(strip).queryByTestId("v3-results")).toBeNull(); + const crumbs = screen.getByTestId("v3-breadcrumb"); + expect(within(crumbs).getAllByRole("link")).toHaveLength(1); }); it("an implicit parent's address shows its only child", async () => { @@ -245,16 +207,12 @@ describe("cockpit v3 · the level strip", () => { expect(view.path()).toBe("/factory/f-sample/n/build-tier-drafts-3"); }); - it("on a phone: back to the parent and a row of level cells that scrolls inside itself", async () => { + it("is the same on a phone", async () => { setPhone(true); renderNode("I-1"); await shown("I-1"); - expect(screen.queryByTestId("v3-level-strip")).toBeNull(); - const row = screen.getByTestId("v3-phone-levels"); - expect(within(row).getByRole("link", { name: /‹ Agents that also do/ })).toBeInTheDocument(); - const cells = within(row).getByRole("navigation", { name: "Levels below" }); - expect(cells.className).toContain("overflow-x-auto"); - expect(screen.getByTestId("v3-phone-level-project")).toHaveTextContent("4+41 you"); + expect(screen.queryByTestId("v3-phone-levels")).toBeNull(); + expect(screen.getByTestId("v3-breadcrumb")).toHaveTextContent(/^Home/); }); }); diff --git a/web/src/components/factory/v3/NodePage.tsx b/web/src/components/factory/v3/NodePage.tsx index 9a3a99c0d46..ff9c2016e75 100644 --- a/web/src/components/factory/v3/NodePage.tsx +++ b/web/src/components/factory/v3/NodePage.tsx @@ -1,5 +1,4 @@ -// One node's page in cockpit v3, calm: the level strip (or the phone's level -// row), the short title with Details and the ⋯ verbs, Your move, its latest +// One node's page in cockpit v3, calm: a plain breadcrumb, the short title with Details and the ⋯ verbs, Your move, its latest // demo (proof is never hidden), then its children one line each (or one level // under it, grouped under parents). // Nothing is drawn between nodes. The node's chat is the page's right column. @@ -14,12 +13,11 @@ import { useState, } from "react"; import { Navigate } from "react-router-dom"; -import { useIsMobileViewport } from "@/hooks/useIsMobileViewport"; import { useLocation, useNavigate, useSearchParams } from "@/lib/routing"; import { cn } from "@/lib/utils"; import { NodeChat } from "./chat/NodeChat"; import { ChildrenView } from "./Children"; -import { LevelStrip, PhoneLevelRow } from "./LevelStrip"; +import { Breadcrumb } from "./Breadcrumb"; import { LevelView } from "./LevelView"; import { AboveProvider, @@ -326,7 +324,6 @@ function LoadedNodePage({ // This visit's "since" stays while the page reads again (the mark moved, a verb applied). const page = useVisitSince(read); const prefetch = usePrefetchNode(factoryId); - const phone = useIsMobileViewport(); const navigate = useNavigate(); const body = useRef(null); const [whyOpen, setWhyOpen] = useState(false); @@ -365,22 +362,14 @@ function LoadedNodePage({ [actions, factoryId, prefetch, sheets, verbs.onVerb, openVerb], ); const shown = level ?? page.home ?? (page.children.level || null); - const { view: move, you } = useYourMove(page, shown, factoryId); + const { view: move } = useYourMove(page, shown, factoryId); // Every ask Your move holds: tiles, rows, Details and the chat never repeat it. const above = useMemo(() => movedAsks(move), [move]); return (
    - {phone ? ( -
    - -
    - ) : ( -
    - -
    - )} +
    {notice} diff --git a/web/src/components/factory/v3/calm/YourMove.tsx b/web/src/components/factory/v3/calm/YourMove.tsx index ac0a50ec6dc..285e4eeb8d4 100644 --- a/web/src/components/factory/v3/calm/YourMove.tsx +++ b/web/src/components/factory/v3/calm/YourMove.tsx @@ -219,7 +219,14 @@ function WaveLines({ waves }: { waves?: WaveLine[] }) { ); } -export function YourMove({ view }: { view: MoveView }) { +export function YourMove({ + view, + heading = true, +}: { + view: MoveView; + /** The "Your move" heading (Home names the section itself). */ + heading?: boolean; +}) { const { onSheet } = useNodeActions(); const [more, setMore] = useState(false); if (!view.rows.length) { @@ -250,9 +257,11 @@ export function YourMove({ view }: { view: MoveView }) { const unseen = view.more - (more ? view.rest.length : 0); return (
    -

    - Your move -

    + {heading && ( +

    + Your move +

    + )}
      {rows.map((row, i) => ( diff --git a/web/src/components/factory/v3/calm/calmGate.test.tsx b/web/src/components/factory/v3/calm/calmGate.test.tsx index d1fc8c9cacc..883fe961d77 100644 --- a/web/src/components/factory/v3/calm/calmGate.test.tsx +++ b/web/src/components/factory/v3/calm/calmGate.test.tsx @@ -155,7 +155,7 @@ describe("cockpit v3 · calm gate", () => { vi.stubGlobal( "matchMedia", vi.fn((query: string) => ({ - matches: query.includes("min-width: 1024px"), + matches: query.includes("min-width: 1180px"), media: query, addEventListener: vi.fn(), removeEventListener: vi.fn(), diff --git a/web/src/components/factory/v3/chat/NodeChat.test.tsx b/web/src/components/factory/v3/chat/NodeChat.test.tsx index e00b347289e..51d1d4b85dd 100644 --- a/web/src/components/factory/v3/chat/NodeChat.test.tsx +++ b/web/src/components/factory/v3/chat/NodeChat.test.tsx @@ -26,7 +26,7 @@ function setWindow(kind: "phone" | "desktop") { matches: kind === "phone" ? query.includes("max-width: 767.98px") - : query.includes("min-width: 1024px"), + : query.includes("min-width: 1180px"), media: query, addEventListener: vi.fn(), removeEventListener: vi.fn(), @@ -642,21 +642,21 @@ describe("cockpit v3 · the node chat column", () => { expect(within(why).getByRole("button", { name: "Start it now instead" })).toBeInTheDocument(); }); - it("keeps the column at most 60 % of the window, also after the window narrows", async () => { + it("keeps the column at most 45 % of the window, also after the window narrows", async () => { setWindow("desktop"); resizeTo(1600); - window.localStorage.setItem(CHAT_WIDTH_KEY, "960"); + window.localStorage.setItem(CHAT_WIDTH_KEY, "700"); renderChat("assistant-core", { install }); const column = await screen.findByTestId("v3-chat"); const handle = screen.getByRole("separator", { name: "Resize the chat" }); - expect(column.style.width).toBe("960px"); - resizeTo(1100); - expect(column.style.width).toBe("660px"); - expect(Number(handle.getAttribute("aria-valuenow"))).toBe(660); + expect(column.style.width).toBe("700px"); + resizeTo(1200); + expect(column.style.width).toBe("540px"); + expect(Number(handle.getAttribute("aria-valuenow"))).toBe(540); // A wider window gives the kept width back. resizeTo(1600); - expect(column.style.width).toBe("960px"); - resizeTo(1024); + expect(column.style.width).toBe("700px"); + resizeTo(1280); }); it("keeps the column's width in this browser", async () => { diff --git a/web/src/components/factory/v3/chat/chatLayout.ts b/web/src/components/factory/v3/chat/chatLayout.ts index 81bc8b99627..0d5a4f7b645 100644 --- a/web/src/components/factory/v3/chat/chatLayout.ts +++ b/web/src/components/factory/v3/chat/chatLayout.ts @@ -1,5 +1,5 @@ -// The node chat's place on the page: a column beside the node from 1024 px -// (40 % of the window by default; dragged wider or narrower and kept in this +// The node chat's place on the page: a column beside the node from 1180 px +// (30 % of the window by default; dragged wider or narrower and kept in this // browser), else a composer pinned at the bottom that opens the thread full // screen (`?chat=1`, so Back closes it). The shell reaches the chat through a // slot: `/`, `r` and the top bar's Room button focus it. @@ -7,7 +7,7 @@ import { createContext, useCallback, useContext, useEffect, useState } from "react"; /** Wide enough for the chat beside the page. */ -export const CHAT_BESIDE_QUERY = "(min-width: 1024px)"; +export const CHAT_BESIDE_QUERY = "(min-width: 1180px)"; /** `?chat=1`: the thread is open full screen (narrow windows), or the box takes focus. */ export const CHAT_PARAM = "chat"; @@ -18,9 +18,9 @@ function viewportWidth(): number { return typeof window === "undefined" ? 1440 : window.innerWidth || 1440; } -/** The widest the column gets: 60 % of the window. */ +/** The widest the column gets: 45 % of the window (the page keeps the rest beside the sidebar). */ export function chatMaxPx(viewport: number): number { - return Math.max(CHAT_MIN_PX + 40, Math.round(viewport * 0.6)); + return Math.max(CHAT_MIN_PX + 40, Math.round(viewport * 0.45)); } export function clampChatWidth(px: number, viewport: number): number { @@ -29,9 +29,9 @@ export function clampChatWidth(px: number, viewport: number): number { return Math.round(Math.min(chatMaxPx(viewport), Math.max(CHAT_MIN_PX, px))); } -/** 40 % of the window (the page is at most 1600 px wide). */ +/** 30 % of the window, at most 480 px (the page is at most 1600 px wide). */ export function defaultChatWidth(viewport: number): number { - const px = Math.round(Math.min(viewport, 1600) * 0.4); + const px = Math.min(480, Math.round(Math.min(viewport, 1600) * 0.3)); return Math.min(chatMaxPx(viewport), Math.max(CHAT_MIN_PX, px)); } diff --git a/web/src/components/factory/v3/lenses/Lenses.test.tsx b/web/src/components/factory/v3/lenses/Lenses.test.tsx index 600ac206c67..9be8f925465 100644 --- a/web/src/components/factory/v3/lenses/Lenses.test.tsx +++ b/web/src/components/factory/v3/lenses/Lenses.test.tsx @@ -215,16 +215,6 @@ describe("cockpit v3 · the Lineage lens", () => { "/factory/f-sample/n/assistant-core", ); }); - - it("on a phone the Lineage button opens it as a sheet", async () => { - setPhone(true); - const view = renderNode("T-16"); - await settle(view.container); - fireEvent.click(screen.getByTestId("v3-phone-lineage")); - const sheet = await screen.findByRole("dialog"); - expect(await within(sheet).findByTestId("v3-lineage")).toBeInTheDocument(); - expect(view.search()).toBe("?peek=lineage&at=T-16"); - }); }); describe("cockpit v3 · levels, not spaghetti, in every lens", () => { diff --git a/web/src/components/factory/v3/levelsRule.test.tsx b/web/src/components/factory/v3/levelsRule.test.tsx index a832460a989..4cccd114915 100644 --- a/web/src/components/factory/v3/levelsRule.test.tsx +++ b/web/src/components/factory/v3/levelsRule.test.tsx @@ -171,7 +171,7 @@ describe("cockpit v3 · levels, not spaghetti; nothing empty", () => { vi.stubGlobal( "matchMedia", vi.fn((query: string) => ({ - matches: query.includes("min-width: 1024px"), + matches: query.includes("min-width: 1180px"), media: query, addEventListener: vi.fn(), removeEventListener: vi.fn(), diff --git a/web/src/components/factory/v3/nodeSource.ts b/web/src/components/factory/v3/nodeSource.ts index 7339dd9ff93..452135ba902 100644 --- a/web/src/components/factory/v3/nodeSource.ts +++ b/web/src/components/factory/v3/nodeSource.ts @@ -110,6 +110,8 @@ export interface NodeSource { ref: string, signal?: AbortSignal, cursor?: string, + /** A page's size (the server's default when absent; at most 100). */ + limit?: number, ) => Promise; /** Both trees' levels as the definition names them (`GET …/levels`). */ levels?: (factoryId: string, signal?: AbortSignal) => Promise; @@ -244,8 +246,12 @@ export const liveNodeSource: NodeSource = { ); return view && normalizeMoney(view); }, - demos: async (fid, ref, signal, cursor) => { - const url = `${nodePath(fid, ref)}/demos${query("cursor", cursor)}`; + demos: async (fid, ref, signal, cursor, limit) => { + const params = new URLSearchParams(); + if (cursor) params.set("cursor", cursor); + if (limit) params.set("limit", String(limit)); + const qs = params.toString(); + const url = `${nodePath(fid, ref)}/demos${qs ? `?${qs}` : ""}`; return readOrNull(await authenticatedFetch(url, { signal })); }, levels: async (fid, signal) => { diff --git a/web/src/components/factory/v3/nodeTypes.ts b/web/src/components/factory/v3/nodeTypes.ts index 8a70ca5ab8b..2e55f67c936 100644 --- a/web/src/components/factory/v3/nodeTypes.ts +++ b/web/src/components/factory/v3/nodeTypes.ts @@ -752,6 +752,11 @@ export interface DemoRef { steps?: { passed: number; total: number }; /** What did not pass, in one line (PARTIAL, FAIL). */ partial_note?: string; + /** The feature and project it sits under (`…/demos` only). */ + feature?: { ref: string; title: string }; + project?: { ref: string; title: string }; + /** Its PR merged then (epoch seconds; `…/demos` only). */ + merged_at?: number; } /** `NodePage.proof`: the latest demo (where the level shows one) and the subtree's count. */ diff --git a/web/src/components/factory/v3/nodeWire.server.test.tsx b/web/src/components/factory/v3/nodeWire.server.test.tsx index ecf63a74eda..e865e57f54b 100644 --- a/web/src/components/factory/v3/nodeWire.server.test.tsx +++ b/web/src/components/factory/v3/nodeWire.server.test.tsx @@ -216,13 +216,12 @@ describe("cockpit v3 · the server's node pages", () => { expect(within(openVerbs()).queryByRole("menuitem", { name: /Rename levels/ })).toBeNull(); }); - it("the product page shows the server's strip and results", async () => { + it("the product page shows a plain breadcrumb, no level strip", async () => { serve(); - renderNode("product", { source: liveNodeSource }); - await waitFor(() => expect(screen.getByTestId("v3-results")).toBeInTheDocument()); - expect(screen.getByTestId("v3-results")).toHaveTextContent("7 merged · all in base env"); - const strip = screen.getByTestId("v3-results").parentElement!; - expect(within(strip).queryByText(/^Goals?$/)).toBeNull(); + renderNode("PR-9876", { source: liveNodeSource }); + const crumbs = await screen.findByTestId("v3-breadcrumb"); + expect(crumbs).toHaveTextContent(/^Home/); + expect(screen.queryByTestId("v3-level-strip")).toBeNull(); }); it("Home lists every feature under the need it came from, from the level view", async () => { diff --git a/web/src/components/factory/v3/proof/ProofRow.tsx b/web/src/components/factory/v3/proof/ProofRow.tsx index 838bf7193a2..3e5fbb45d23 100644 --- a/web/src/components/factory/v3/proof/ProofRow.tsx +++ b/web/src/components/factory/v3/proof/ProofRow.tsx @@ -74,11 +74,17 @@ export function ProofThumb({ ); } -/** The recording, playing as soon as it opens (a click asked for it), else its still. */ +/** + * The recording, playing as soon as it opens (a click asked for it), else its + * still. It scrolls into view, clear of a sticky composer or tab bar below. + */ export function ProofPlayer({ demo }: { demo: DemoRef }) { const box = useRef(null); useEffect(() => { - const video = box.current?.querySelector("video"); + const el = box.current; + const reduce = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false; + el?.scrollIntoView?.({ behavior: reduce ? "auto" : "smooth", block: "center" }); + const video = el?.querySelector("video"); try { const started = video?.play?.(); if (started && typeof started.catch === "function") started.catch(() => undefined); @@ -87,7 +93,7 @@ export function ProofPlayer({ demo }: { demo: DemoRef }) { } }, []); return ( -
      +
      ); diff --git a/web/src/components/factory/v3/sheets/V3Sheets.tsx b/web/src/components/factory/v3/sheets/V3Sheets.tsx index bbb7a5df376..0145d16bff2 100644 --- a/web/src/components/factory/v3/sheets/V3Sheets.tsx +++ b/web/src/components/factory/v3/sheets/V3Sheets.tsx @@ -126,7 +126,7 @@ function OutLink({ * rest): short names, what each unblocks, one filled answer on the first row * and outlined ones after. Closed alarms live under Factory › Health. */ -function NeedsList({ +export function NeedsList({ rows, list, tree, @@ -169,7 +169,7 @@ function NeedsList({ } /** A tree's asks in Your move's order: its root's rows, then every other ask it holds. */ -function useSheetRows( +export function useSheetRows( tree: string, list: NeedsYouList | undefined, open: boolean, diff --git a/web/src/lib/factoryApi.ts b/web/src/lib/factoryApi.ts index ad357773957..42bfae95e1c 100644 --- a/web/src/lib/factoryApi.ts +++ b/web/src/lib/factoryApi.ts @@ -1330,6 +1330,19 @@ export interface ThreadView { unread: number; /** The newest unread message that @mentions the reader. */ mention?: ThreadMentionView | null; + /** The reader posted in this thread (the Chats list puts these first). */ + mine?: boolean; + /** The newest thing a person or an agent said here (system lines left out). */ + last?: ThreadLastView | null; +} + +export interface ThreadLastView { + /** "You", an agent's name, a person's id. */ + who: string; + kind: "human" | "agent"; + text: string; + /** Epoch seconds. */ + at: number; } export interface ThreadMentionView { diff --git a/web/src/pages/factory/ChatsPage.tsx b/web/src/pages/factory/ChatsPage.tsx new file mode 100644 index 00000000000..1a0adada2c4 --- /dev/null +++ b/web/src/pages/factory/ChatsPage.tsx @@ -0,0 +1,179 @@ +/** + * `/chats`: every conversation in the factory, the ones you wrote in first, + * then the newest, each with its unread count and the last line said there. + * `/chats/` opens one. + */ + +import { useMemo } from "react"; +import { MessagesSquareIcon } from "lucide-react"; +import { type ChatRow, chatRows, useNodeNames } from "@/components/factory/cockpit/chats"; +import { chatsHref } from "@/components/factory/cockpit/cockpitRoutes"; +import { ChatThreadPage } from "@/components/factory/cockpit/ChatThreadPage"; +import { useFactoryNow } from "@/components/factory/factoryClock"; +import { useRoomThreads } from "@/components/factory/room/roomQueries"; +import { useFactoryShell } from "@/components/factory/shell/FactoryShellContext"; +import { FOCUS_RING } from "@/components/factory/ui/statusTokens"; +import { Link, useParams } from "@/lib/routing"; +import { cn } from "@/lib/utils"; + +function when(at: number, now: Date): string { + if (!at) return ""; + const date = new Date(at * 1000); + const min = Math.round((now.getTime() - date.getTime()) / 60_000); + if (min < 1) return "now"; + if (min < 60) return `${min}m`; + if (min < 24 * 60) return `${Math.round(min / 60)}h`; + return date.toLocaleDateString(undefined, { month: "short", day: "numeric" }); +} + +function Initial({ name }: { name: string }) { + return ( + + {name.slice(0, 1).toUpperCase()} + + ); +} + +function Row({ fid, row, now }: { fid: string; row: ChatRow; now: Date }) { + return ( +
    • + + + + + + {row.name} + + {row.kind} + + + {when(row.at, now)} + + + + + {row.last ? ( + <> + {row.last.who}: + {row.last.text} + + ) : ( + + {row.messages ? "Only updates so far" : "No messages yet"} + {row.with ? ` · ${row.with} answers here` : ""} + + )} + + {row.unread > 0 && ( + + {row.unread} + + )} + + + +
    • + ); +} + +function ChatList() { + const { factoryId: fid, snapshot } = useFactoryShell(); + const threads = useRoomThreads(fid, { nodes: true }); + const names = useNodeNames(fid); + const now = useFactoryNow(); + const rows = useMemo( + () => chatRows(threads.data?.data ?? [], snapshot, names), + [threads.data, snapshot, names], + ); + const talked = rows.filter((r) => r.at > 0); + const quiet = rows.filter((r) => r.at === 0); + return ( +
      +

      Chats

      + {threads.isPending ? ( +
      + {[0, 1, 2, 3].map((i) => ( +
      +
      +
      +
      +
      +
      +
      + ))} +
      + ) : threads.isError ? ( +

      + The chats could not be read.{" "} + +

      + ) : ( + <> + {talked.length > 0 ? ( +
        + {talked.map((r) => ( + + ))} +
      + ) : ( +
      + + No conversations yet. +
      + )} + {quiet.length > 0 && ( +
      + + {quiet.length} quiet chat{quiet.length === 1 ? "" : "s"} (no messages yet) + +
        + {quiet.map((r) => ( + + ))} +
      +
      + )} + + )} +
      + ); +} + +export function ChatsPage() { + const { thread } = useParams<{ thread?: string }>(); + return thread ? : ; +} diff --git a/web/src/pages/factory/Cockpit.test.tsx b/web/src/pages/factory/Cockpit.test.tsx new file mode 100644 index 00000000000..e69503688b4 --- /dev/null +++ b/web/src/pages/factory/Cockpit.test.tsx @@ -0,0 +1,261 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { LIVE_RUN_FACTORY_ID, preflightOk } from "@/components/factory/__fixtures__/liveRun"; +import { round3Tasks } from "@/components/factory/agents/__fixtures__/opsFixtures"; +import { resetFactoryControls } from "@/components/factory/controls/useFactoryControls"; +import { definitionResponse } from "@/components/factory/insights/__fixtures__/learnFixtures"; +import { notifyMe } from "@/components/factory/notify/__fixtures__/notifyFixtures"; +import { round3Snapshot } from "@/components/factory/shell/__fixtures__/shellFixtures"; +import { sampleNodeSource } from "@/components/factory/v3/fixture/sampleNodeSource"; +import { NodeSourceProvider } from "@/components/factory/v3/nodeSource"; +import type { ThreadView } from "@/lib/factoryApi"; +import { FactoryPage } from "./FactoryPage"; +import { type FactoryServer, SNAPSHOT, json, serveFactory } from "./testServer"; + +// The cockpit is one thing for everyone: a sidebar (Home, Demos, Chats, +// Decisions, Search, then the outline of the work) around Home, Demos, +// Chats, Decisions and the node pages. Older addresses land on the place +// that shows the same thing now; there is no cockpit switch. + +const authenticatedFetch = vi.fn(); +vi.mock("@/lib/identity", () => ({ + authenticatedFetch: (...args: unknown[]) => authenticatedFetch(...args), +})); +vi.mock("@/hooks/useSessionUpdatesConnected", () => ({ + useSessionUpdatesConnected: () => true, +})); + +const BASE = `/factory/${LIVE_RUN_FACTORY_ID}`; + +let location = ""; +function LocationProbe() { + const loc = useLocation(); + location = loc.pathname + loc.search; + return null; +} + +function renderAt(path: string) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + + + + + + + } + /> + + + + , + ); +} + +function thread(key: string, fields: Partial = {}): ThreadView { + return { + key, + kind: key.startsWith("project:") ? "project" : "factory", + title: key === "factory" ? "#factory" : `#${key.split(":")[1]}`, + node_ref: key === "factory" ? "product" : key.split(":")[1]!, + project_slug: key.startsWith("project:") ? key.split(":")[1]! : null, + pr_id: null, + pr_number: null, + responder_session_id: null, + responder_title: "Atlas", + last_seq: 1, + last_at: 1_700_000_000, + messages: 3, + unread: 0, + ...fields, + }; +} + +let server: FactoryServer; +function serve(threads: ThreadView[] = []) { + server = serveFactory(authenticatedFetch, { + snapshot: () => json(round3Snapshot()), + overrides: { + "GET /v1/factory/preflight": () => json(preflightOk()), + [`GET ${SNAPSHOT}/notify/me`]: () => json(notifyMe()), + [`GET ${SNAPSHOT}/definition`]: () => json(definitionResponse()), + [`GET ${SNAPSHOT}/tasks`]: () => json({ tasks: round3Tasks(), projects: [] }), + [`GET ${SNAPSHOT}/chat/threads`]: () => json({ data: threads }), + }, + }); + return server; +} + +beforeEach(() => { + authenticatedFetch.mockReset(); + resetFactoryControls(); + vi.stubGlobal( + "matchMedia", + vi.fn((query: string) => ({ + matches: false, + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })), + ); + localStorage.clear(); +}); +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("the cockpit · one sidebar, always in view", () => { + it("lists the four places and Search, then the outline of the work, on Home", async () => { + serve([thread("factory", { unread: 4 })]); + renderAt(""); + const sidebar = await screen.findByTestId("cockpit-sidebar"); + for (const place of ["home", "demos", "chats", "decisions"]) { + expect(within(sidebar).getByTestId(`cockpit-nav-${place}`)).toBeInTheDocument(); + } + expect(within(sidebar).getByTestId("cockpit-nav-home")).toHaveAttribute("aria-current", "page"); + expect(within(sidebar).getByTestId("cockpit-nav-search")).toBeInTheDocument(); + await waitFor(() => + expect(within(sidebar).getByTestId("cockpit-nav-chats-count")).toHaveTextContent("4"), + ); + const outline = within(sidebar).getByTestId("cockpit-outline"); + await waitFor(() => + expect(within(outline).getAllByTestId("cockpit-outline-node").length).toBeGreaterThan(0), + ); + // No cockpit switch, no level strip anywhere. + expect(screen.queryByTestId("cockpit-you-classic")).toBeNull(); + expect(screen.queryByTestId("v3-level-strip")).toBeNull(); + }); + + it("opens a feature in the outline to its projects and marks the page's node", async () => { + serve(); + renderAt("/n/assistant-core"); + const outline = await screen.findByTestId("cockpit-outline"); + const here = await within(outline).findByRole("link", { current: "page" }); + expect(here).toHaveAttribute("data-ref", "assistant-core"); + expect(screen.getByTestId("v3-breadcrumb")).toHaveTextContent(/^Home/); + }); + + it("goes to Search on its button and to Demos from the sidebar", async () => { + serve(); + renderAt(""); + fireEvent.click(await screen.findByTestId("cockpit-nav-demos")); + await waitFor(() => expect(location).toBe(`${BASE}/demos`)); + expect(await screen.findByTestId("cockpit-demos-page")).toBeInTheDocument(); + }); +}); + +describe("the cockpit · Home", () => { + it("shows what needs you, the features one line each, and nothing else by default", async () => { + serve(); + renderAt(""); + const home = await screen.findByTestId("cockpit-home"); + expect(within(home).getByTestId("cockpit-home-needs")).toBeInTheDocument(); + const features = await within(home).findAllByTestId("cockpit-home-feature"); + expect(features.length).toBeGreaterThan(0); + // At most three asks, the rest behind "+n more" (Decisions). + await waitFor(() => + expect(within(home).queryAllByTestId("v3-move-row").length).toBeLessThanOrEqual(3), + ); + }); +}); + +describe("the cockpit · Demos", () => { + it("lists every demo newest first, filters by verdict, and plays one in place", async () => { + serve(); + renderAt("/demos"); + const list = await screen.findByTestId("cockpit-demos"); + const cards = within(list).getAllByTestId("cockpit-demo"); + expect(cards.length).toBeGreaterThan(3); + expect(cards[0]).toHaveAttribute("data-verdict", "PARTIAL"); + fireEvent.click(screen.getByRole("button", { name: "Failed" })); + await waitFor(() => expect(location).toBe(`${BASE}/demos?verdict=FAIL`)); + const failed = within(screen.getByTestId("cockpit-demos")).getAllByTestId("cockpit-demo"); + expect(failed.every((c) => c.getAttribute("data-verdict") === "FAIL")).toBe(true); + fireEvent.click(within(failed[0]!).getByTestId("cockpit-demo-play")); + expect(await within(failed[0]!).findByTestId("v3-proof-player")).toBeInTheDocument(); + }); + + it("opens one playing from the address", async () => { + serve(); + renderAt("/demos?play=demo-9876"); + const card = (await screen.findAllByTestId("cockpit-demo")).find( + (c) => c.getAttribute("data-demo") === "demo-9876", + )!; + expect(within(card).getByTestId("v3-proof-player")).toBeInTheDocument(); + }); +}); + +describe("the cockpit · Chats", () => { + it("lists the chats you wrote in first, then the newest, with unread counts and the last line", async () => { + serve([ + thread("project:api-version", { + last_at: 1_700_000_900, + unread: 2, + last: { who: "Juno", kind: "agent", text: "Merged the schema", at: 1_700_000_900 }, + }), + thread("factory", { + mine: true, + last: { who: "You", kind: "human", text: "Start the dark mode", at: 1_700_000_100 }, + }), + thread("project:db-version", { messages: 0, last_at: null, last_seq: 0 }), + ]); + renderAt("/chats"); + const list = await screen.findByTestId("cockpit-chats"); + const rows = within(list).getAllByTestId("cockpit-chat-row"); + expect(rows.map((r) => r.getAttribute("data-thread"))).toEqual([ + "factory", + "project:api-version", + ]); + expect(rows[0]).toHaveTextContent("Factory chat"); + expect(rows[0]).toHaveTextContent("You: Start the dark mode"); + expect(within(rows[1]!).getByTestId("cockpit-chat-unread")).toHaveTextContent("2"); + // A thread nobody wrote in sits behind "quiet chats". + expect(screen.getByText(/1 quiet chat/)).toBeInTheDocument(); + }); +}); + +describe("the cockpit · older addresses", () => { + it.each([ + ["/projects/db-version", "/n/db-version"], + ["/projects/db-version/history", "/n/db-version"], + ["/projects", ""], + ["/initiatives/I-3", "/n/I-3"], + ["?cockpit=v1", ""], + ["?peek=needs", "/decisions"], + ["/n/I-3?cockpit=v3", "/n/I-3"], + ])("lands %s on %s", async (from, to) => { + serve(); + renderAt(from); + await waitFor(() => expect(location).toBe(`${BASE}${to}`)); + }); + + it("lands a PR page and a ?review= link on the PR's change", async () => { + serve(); + const pr = round3Snapshot().prs[0]!; + renderAt(`/prs/${pr.id}`); + await waitFor(() => expect(location).toBe(`${BASE}/n/PR-${pr.number}`)); + }); + + it("keeps the team editor's address", async () => { + serve(); + renderAt("/projects/db-version/team"); + await screen.findByTestId("factory-shell"); + expect(location).toBe(`${BASE}/projects/db-version/team`); + }); +}); + +describe("the cockpit · counts", () => { + it("never asks the server for a cockpit choice", async () => { + serve(); + renderAt(""); + await screen.findByTestId("cockpit-home"); + expect(server.calls.some((c) => c.path.endsWith("/notify/me/ui"))).toBe(false); + }); +}); diff --git a/web/src/pages/factory/Cockpit.tsx b/web/src/pages/factory/Cockpit.tsx new file mode 100644 index 00000000000..131824aa437 --- /dev/null +++ b/web/src/pages/factory/Cockpit.tsx @@ -0,0 +1,247 @@ +/** + * The cockpit's shell around every factory page: a sidebar that is always in + * view (Home, Demos, Chats, Decisions, Search, then the outline of the work) + * beside the page; on a phone the places move to a bottom tab bar. ⌘K opens + * Search from anywhere; Share and Add project open from the factory menu. + * Sheets (`?peek=`: the base env, Try it, a node's demos, the keys) open by + * the address, so a link opens the exact view. + */ + +import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { CockpitPalette } from "@/components/factory/cmdk/CockpitPalette"; +import { + PLACE_HREF, + useCockpitKeys, + useFactoryPaletteHotkey, +} from "@/components/factory/cmdk/useFactoryPaletteHotkey"; +import { CockpitSidebar, CockpitTabBar } from "@/components/factory/cockpit/CockpitNav"; +import { chatsHref, placeOf } from "@/components/factory/cockpit/cockpitRoutes"; +import { AddProjectDialog } from "@/components/factory/controls/AddProjectDialog"; +import { useFactoryServerNowS } from "@/components/factory/factoryClock"; +import { useFactoryDefinition } from "@/components/factory/insights/learnQueries"; +import { ShareDialog } from "@/components/factory/people/ShareDialog"; +import { unreadTotal } from "@/components/factory/room/roomModel"; +import { useRoomThreads } from "@/components/factory/room/roomQueries"; +import { + FactoryShellContext, + type FactoryShellValue, +} from "@/components/factory/shell/FactoryShellContext"; +import { FactorySwitcher } from "@/components/factory/shell/FactoryTopBar"; +import { factoryLocation } from "@/components/factory/shell/shellRoutes"; +import { PAGE_EXTRA_TOP } from "@/components/factory/shell/scroll"; +import { ViewerBanner } from "@/components/factory/shell/ViewerBanner"; +import { FactoryToastProvider } from "@/components/factory/ui/Toast"; +import { NodeChatSlotContext, createNodeChatSlot } from "@/components/factory/v3/chat/chatLayout"; +import { nodeQueryKeys } from "@/components/factory/v3/nodeQueries"; +import { PRODUCT_REF } from "@/components/factory/v3/nodeRoutes"; +import { useNodeSource } from "@/components/factory/v3/nodeSource"; +import { useNeedsYou } from "@/components/factory/v3/nodeSteer"; +import { V3Sheets } from "@/components/factory/v3/sheets/V3Sheets"; +import { PAGE_PALETTE_ATTRIBUTE } from "@/hooks/useCommandPaletteHotkey"; +import { + mentionNotices, + snapshotNotices, + useFactoryNotifications, +} from "@/hooks/useFactoryNotifications"; +import { useIsMobileViewport } from "@/hooks/useIsMobileViewport"; +import type { FactorySnapshot } from "@/lib/factoryApi"; +import { useNavigate, useSearchParams } from "@/lib/routing"; + +/** The id of the page's main region (the skip link's target). */ +export const COCKPIT_MAIN_ID = "factory-main"; + +/** The sidebar's height once stuck 1rem under the app header, ending 1rem above the bottom. */ +const SIDEBAR_HEIGHT = + "calc(100dvh - var(--omnigent-header-height, 0px) - var(--omnigent-inset-top, 0px) - var(--omnigent-inset-bottom, 0px) - 2rem)"; + +export function Cockpit({ + snapshot, + clockOffsetS, + updatedAt, + rest, + children, +}: { + snapshot: FactorySnapshot; + clockOffsetS: number; + updatedAt: number; + rest: string; + children: ReactNode; +}) { + const fid = snapshot.factory.id; + const nowS = useFactoryServerNowS(clockOffsetS); + const phone = useIsMobileViewport(); + const location = useMemo(() => factoryLocation(rest), [rest]); + const place = placeOf(rest); + const nodeRef = location.page === "node" ? (location.id ?? null) : null; + const [sharing, setSharing] = useState(false); + const [palette, setPalette] = useState(false); + const [adding, setAdding] = useState<{ title: string | null } | null>(null); + const navigate = useNavigate(); + const togglePalette = useCallback(() => setPalette((open) => !open), []); + useFactoryPaletteHotkey(togglePalette); + const threads = useRoomThreads(fid).data?.data; + useFactoryNotifications( + useMemo( + () => [...snapshotNotices(snapshot), ...mentionNotices(fid, snapshot.factory.name, threads)], + [snapshot, fid, threads], + ), + ); + const productName = useFactoryDefinition(fid).data?.effective.product?.name ?? null; + const [chatSlot] = useState(createNodeChatSlot); + const queryClient = useQueryClient(); + const nodeSource = useNodeSource(); + // One count of asks everywhere: the product tree's ranked list (Decisions, Home). + const ranked = useNeedsYou("product", Boolean(nodeSource?.needsYou), fid).data; + const decisions = ranked ? ranked.items.length + ranked.blocks_nothing.length : 0; + const counts = useMemo(() => ({ decisions, unread: unreadTotal(threads) }), [decisions, threads]); + // The asks and the product page move with the factory: read them again when + // the snapshot's open asks change (a push frame re-read it). + const liveMark = useMemo( + () => + snapshot.decisions + .filter((d) => d.status === "open") + .map((d) => d.id) + .sort() + .join(","), + [snapshot.decisions], + ); + const sourceKind = nodeSource?.kind; + const markRead = useRef(liveMark); + useEffect(() => { + if (liveMark === markRead.current || !sourceKind) return; + markRead.current = liveMark; + void queryClient.invalidateQueries({ + queryKey: [...nodeQueryKeys.all(fid), sourceKind, "needs-you"], + }); + void queryClient.invalidateQueries({ + queryKey: nodeQueryKeys.node(fid, sourceKind, PRODUCT_REF), + exact: true, + }); + }, [fid, liveMark, queryClient, sourceKind]); + const [, setParams] = useSearchParams(); + useCockpitKeys({ + // `/`: the chat's box on a node page, else Search. + onPalette: () => { + if (!chatSlot.focus()) setPalette(true); + }, + onHelp: () => + setParams( + (prev) => { + const next = new URLSearchParams(prev); + next.set("peek", "keys"); + return next; + }, + { replace: true }, + ), + onRoom: () => { + if (!chatSlot.focus()) navigate(chatsHref(fid)); + }, + onGo: (go) => navigate(PLACE_HREF[go](fid)), + }); + const shell: FactoryShellValue = useMemo( + () => ({ + factoryId: fid, + snapshot, + clockOffsetS, + nowS, + updatedAt, + location, + wide: false, + cockpit: "v2", + openShare: () => setSharing(true), + openPalette: () => setPalette(true), + openAddProject: () => setAdding({ title: null }), + openAddProjectTitled: (title: string | null) => setAdding({ title }), + }), + [fid, snapshot, clockOffsetS, nowS, updatedAt, location], + ); + const viewer = snapshot.viewer ?? null; + return ( + + { + event.preventDefault(); + document.getElementById(COCKPIT_MAIN_ID)?.focus(); + }} + className="sr-only z-40 rounded-md bg-background px-3 py-1.5 text-sm focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:ring-2 focus:ring-status-blue" + > + Skip to main content + +
      + {phone ? ( +
      + setSharing(true)} + onAddProject={() => setAdding({ title: null })} + /> +
      + ) : ( + setPalette(true)} + onShare={() => setSharing(true)} + onAddProject={() => setAdding({ title: null })} + style={{ top: `calc(1rem - ${PAGE_EXTRA_TOP})`, height: SIDEBAR_HEIGHT }} + /> + )} +
      + {viewer?.role === "viewer" && } + + {children} + setSharing(true)} + onAddProject={() => setAdding({ title: null })} + onRoom={(thread) => navigate(chatsHref(fid, thread))} + nodes + /> + + +
      +
      + {phone && ( + setPalette(true)} /> + )} + + !open && setAdding(null)} + /> +
      + ); +} diff --git a/web/src/pages/factory/CockpitDecisions.tsx b/web/src/pages/factory/CockpitDecisions.tsx new file mode 100644 index 00000000000..286ac9513fa --- /dev/null +++ b/web/src/pages/factory/CockpitDecisions.tsx @@ -0,0 +1,50 @@ +/** + * Decisions (`/decisions`): everything the factory asks this person, ranked + * by what each one unblocks, in the product first and the factory's own + * asks one click away. + */ + +import { useState } from "react"; +import { useFactoryShell } from "@/components/factory/shell/FactoryShellContext"; +import { NodeContextProvider } from "@/components/factory/v3/NodeContext"; +import { useAnswerAsk, useNeedsYou } from "@/components/factory/v3/nodeSteer"; +import { useNodeSource } from "@/components/factory/v3/nodeSource"; +import { NeedsList, useSheetRows } from "@/components/factory/v3/sheets/V3Sheets"; + +export function CockpitDecisions() { + const { factoryId: fid } = useFactoryShell(); + const [tree, setTree] = useState("product"); + const query = useNeedsYou(tree, true, fid); + const source = useNodeSource(); + const onAnswer = useAnswerAsk(null, { sample: source?.kind === "sample", factoryId: fid }); + const list = query.data; + const rows = useSheetRows(tree, list, true, fid); + return ( +
      +
      +

      Decisions

      + + {tree === "factory" ? "How the factory runs" : "What you are building"} · ranked by what + each one unblocks + +
      + {list && rows ? ( + + + + ) : query.isError ? ( +

      + The decisions could not be read.{" "} + +

      + ) : ( +
      + )} +
      + ); +} diff --git a/web/src/pages/factory/CockpitHome.tsx b/web/src/pages/factory/CockpitHome.tsx new file mode 100644 index 00000000000..c04d06bca0b --- /dev/null +++ b/web/src/pages/factory/CockpitHome.tsx @@ -0,0 +1,337 @@ +/** + * Home (`/factory/`): what needs this person (at most three, the rest on + * Decisions), then the features one line each (progress and a ▶ for their + * latest demo), then what shipped lately with its demo. Nothing else by + * default: the rest lives a click away (the outline, a feature's page). + */ + +import { type ReactNode, useMemo, useState } from "react"; +import { CheckIcon, PlayIcon, XIcon } from "lucide-react"; +import { useFactoryDemos } from "@/components/factory/cockpit/demos"; +import { plainPrTitle, useNodeNames } from "@/components/factory/cockpit/chats"; +import { decisionsHref, demosHref } from "@/components/factory/cockpit/cockpitRoutes"; +import { useFactoryNow } from "@/components/factory/factoryClock"; +import { useFactoryDefinition } from "@/components/factory/insights/learnQueries"; +import { useFactoryShell } from "@/components/factory/shell/FactoryShellContext"; +import { FOCUS_RING } from "@/components/factory/ui/statusTokens"; +import { nodeName } from "@/components/factory/v3/calm/calmModel"; +import { YourMove, useYourMove } from "@/components/factory/v3/calm/YourMove"; +import { NodeContextProvider, type NodeActions } from "@/components/factory/v3/NodeContext"; +import { Ring, StateDot, hasRing } from "@/components/factory/v3/nodeParts"; +import { useLevel, useNode, usePrefetchNode } from "@/components/factory/v3/nodeQueries"; +import { PRODUCT_REF, nodeHref } from "@/components/factory/v3/nodeRoutes"; +import { useAnswerAsk } from "@/components/factory/v3/nodeSteer"; +import type { DemoRef, NodePage, NodeSummary } from "@/components/factory/v3/nodeTypes"; +import { ProofPlayer } from "@/components/factory/v3/proof/ProofRow"; +import { DemoStatusTag } from "@/components/factory/board/DemoThumb"; +import type { PrView } from "@/lib/factoryApi"; +import { Link, useNavigate } from "@/lib/routing"; +import { cn } from "@/lib/utils"; + +const SHIPPED_ROWS = 5; + +function Section({ + title, + action, + children, + testId, +}: { + title: string; + action?: ReactNode; + children: ReactNode; + testId: string; +}) { + return ( +
      +
      +

      + {title} +

      + + {action} +
      + {children} +
      + ); +} + +function RowsSkeleton({ rows = 3 }: { rows?: number }) { + return ( +
      + {Array.from({ length: rows }, (_, i) => ( +
      +
      +
      +
      + ))} +
      + ); +} + +/** "▶ 0:42" with the verdict's colour; opens the demo under its row. */ +function PlayChip({ + demo, + open, + onClick, +}: { + demo: Pick; + open: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +function progressWords(node: NodeSummary): string | null { + const p = node.progress; + if (!p || p.what === "not planned") return null; + return `${p.label} ${p.what === "acceptance" ? "checks" : p.what}`; +} + +function FeatureRow({ fid, node, demo }: { fid: string; node: NodeSummary; demo?: DemoRef }) { + const [open, setOpen] = useState(false); + const prefetch = usePrefetchNode(fid); + const needs = node.needs?.open ?? 0; + const progress = progressWords(node); + return ( +
    • +
      + + {hasRing(node.progress) ? ( + + ) : ( + + )} + + prefetch(node.ref)} + onFocus={() => prefetch(node.ref)} + title={node.full_title ?? node.title} + className={cn( + "min-w-0 flex-1 truncate text-[14px] font-medium hover:text-status-blue-fg", + FOCUS_RING, + )} + > + {nodeName(node)} + + {needs > 0 && ( + + {needs} need{needs === 1 ? "s" : ""} you + + )} + {progress && ( + + {progress} + + )} + {node.demo && demo && ( + setOpen((o) => !o)} /> + )} +
      + {open && demo && ( +
      + +
      + )} +
    • + ); +} + +function ShippedRow({ + fid, + pr, + demo, + names, +}: { + fid: string; + pr: PrView; + demo?: DemoRef; + names: ReadonlyMap; +}) { + const [open, setOpen] = useState(false); + const now = useFactoryNow(); + const at = pr.merged?.at; + const when = at ? agoWords(now.getTime() / 1000 - at) : null; + const where = demo?.project?.title ?? (pr.project_slug ? names.get(pr.project_slug) : null); + const title = demo?.title ?? plainPrTitle(pr.title); + return ( +
    • +
      + + + {title} + {where && where.toLowerCase() !== title.toLowerCase() && ( + · {where} + )} + + {when && ( + {when} + )} + {demo && setOpen((o) => !o)} />} +
      + {open && demo && ( +
      + +
      + )} +
    • + ); +} + +function agoWords(seconds: number): string { + const min = Math.max(0, Math.round(seconds / 60)); + if (min < 1) return "just now"; + if (min < 60) return `${min} min ago`; + const h = Math.round(min / 60); + return h < 48 ? `${h} h ago` : `${Math.round(h / 24)} days ago`; +} + +function NeedsYou({ fid, page }: { fid: string; page: NodePage }) { + const navigate = useNavigate(); + const answer = useAnswerAsk(PRODUCT_REF, { factoryId: fid }); + const { view } = useYourMove(page, page.home ?? null, fid); + const actions = useMemo( + () => ({ + factoryId: fid, + onAnswer: answer, + onOpenDecision: () => navigate(decisionsHref(fid)), + onSheet: (sheet) => { + if (sheet === "needs") navigate(decisionsHref(fid)); + }, + }), + [answer, fid, navigate], + ); + return ( + + + + ); +} + +export function CockpitHome() { + const { factoryId: fid, snapshot } = useFactoryShell(); + const product = useNode(PRODUCT_REF, fid); + const features = useLevel(PRODUCT_REF, "feature", fid); + const demos = useFactoryDemos(fid); + const names = useNodeNames(fid); + const productName = useFactoryDefinition(fid).data?.effective.product?.name ?? null; + const byId = useMemo( + () => new Map((demos.data?.items ?? []).map((d) => [d.id, d])), + [demos.data], + ); + const byPr = useMemo(() => { + const out = new Map(); + // Newest first: the first one seen per PR is its latest. + for (const d of demos.data?.items ?? []) { + if (d.pr_number && !out.has(d.pr_number)) out.set(d.pr_number, d); + } + return out; + }, [demos.data]); + const rows = useMemo(() => { + const all = (features.data?.groups ?? []).flatMap((g) => g.items); + return [...all.filter((n) => n.state !== "done"), ...all.filter((n) => n.state === "done")]; + }, [features.data]); + const shipped = useMemo( + () => + snapshot.prs + .filter((p) => p.state === "merged" && p.merged?.at) + .sort((a, b) => (b.merged?.at ?? 0) - (a.merged?.at ?? 0)) + .slice(0, SHIPPED_ROWS), + [snapshot.prs], + ); + const newest = demos.data?.items[0]; + return ( +
      +

      + {productName ?? snapshot.factory.name} +

      +
      + {product.isPending ? ( + + ) : product.data ? ( + + ) : null} +
      +
      + All demos › + + ) + } + > + {features.isPending ? ( + + ) : rows.length ? ( +
        + {rows.map((n) => ( + + ))} +
      + ) : ( +

      + No features yet. Ask for one in Chats, or add a project from the factory menu. +

      + )} +
      + {shipped.length > 0 && ( +
      +
        + {shipped.map((pr) => ( + + ))} +
      +
      + )} +
      + ); +} diff --git a/web/src/pages/factory/CockpitShell.test.tsx b/web/src/pages/factory/CockpitShell.test.tsx deleted file mode 100644 index c80d9b4b1cf..00000000000 --- a/web/src/pages/factory/CockpitShell.test.tsx +++ /dev/null @@ -1,515 +0,0 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; -import axe from "axe-core"; -import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { LIVE_RUN_FACTORY_ID, preflightOk } from "@/components/factory/__fixtures__/liveRun"; -import { resetFactoryControls } from "@/components/factory/controls/useFactoryControls"; -import { definitionResponse } from "@/components/factory/insights/__fixtures__/learnFixtures"; -import { notifyMe } from "@/components/factory/notify/__fixtures__/notifyFixtures"; -import { round3Snapshot } from "@/components/factory/shell/__fixtures__/shellFixtures"; -import { COCKPIT_STORAGE_KEY, resetCockpitChoiceCache } from "@/components/factory/shell/cockpitV2"; -import type { CockpitChoice, FactorySnapshot } from "@/lib/factoryApi"; -import { FactoryPage } from "./FactoryPage"; -import { type FactoryServer, SNAPSHOT, json, serveFactory } from "./testServer"; - -const authenticatedFetch = vi.fn(); -vi.mock("@/lib/identity", () => ({ - authenticatedFetch: (...args: unknown[]) => authenticatedFetch(...args), -})); -vi.mock("@/hooks/useSessionUpdatesConnected", () => ({ - useSessionUpdatesConnected: () => true, -})); - -const FID = LIVE_RUN_FACTORY_ID; -const BASE = `/factory/${FID}`; - -function setWidth({ wide, phone = false }: { wide: boolean; phone?: boolean }) { - vi.stubGlobal( - "matchMedia", - vi.fn((query: string) => ({ - matches: query.includes("min-width: 1280px") - ? wide - : query.includes("max-width: 767.98px") - ? phone - : false, - media: query, - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - })), - ); -} - -let location = ""; -function LocationProbe() { - const loc = useLocation(); - location = loc.pathname + loc.search + loc.hash; - return null; -} - -function renderAt(path: string) { - const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - return render( - - - - - - - - } - /> - - - , - ); -} - -let server: FactoryServer; -let saved: CockpitChoice | null; - -const NOT_ON_ROSTER = { error: { code: "not_on_roster", message: "not on the roster" } }; - -/** The server offers the new cockpit (or not), and the person's saved choice. */ -function serve({ - offered = true, - choice = "v2", - snapshot = round3Snapshot(), - onRoster = true, - summary, - prefs, -}: { - offered?: boolean; - choice?: CockpitChoice | null; - snapshot?: FactorySnapshot; - onRoster?: boolean; - /** Overrides the status-summary read (say, a failure). */ - summary?: () => Response | Promise; - /** Overrides the saved-choice read (say, a slow one). */ - prefs?: () => Response | Promise; -} = {}) { - saved = choice; - server = serveFactory(authenticatedFetch, { - snapshot: () => json(snapshot), - overrides: { - "GET /v1/factory/preflight": () => json({ ...preflightOk(), cockpit_v2: offered }), - [`GET ${SNAPSHOT}/notify/me/ui`]: - prefs ?? (() => (onRoster ? json({ cockpit: saved }) : json(NOT_ON_ROSTER, 409))), - [`PUT ${SNAPSHOT}/notify/me/ui`]: async () => { - if (!onRoster) return json(NOT_ON_ROSTER, 409); - const last = server.calls.at(-1)!.body as { cockpit: CockpitChoice }; - saved = last.cockpit; - return json({ cockpit: saved }); - }, - [`GET ${SNAPSHOT}/notify/me`]: () => json(notifyMe()), - ...(summary ? { [`GET ${SNAPSHOT}/status-summary`]: summary } : {}), - [`GET ${SNAPSHOT}/definition`]: () => { - const body = definitionResponse(); - return json({ - ...body, - effective: { ...body.effective, product: { name: "Sample app (sample)" } }, - }); - }, - }, - }); -} - -function puts(): unknown[] { - return server.calls - .filter((c) => c.method === "PUT" && c.path.endsWith("/notify/me/ui")) - .map((c) => c.body); -} - -/** A response the test answers later. */ -function later(): { answer: (r: Response) => void; respond: () => Promise } { - let answer: (r: Response) => void = () => undefined; - const pending = new Promise((resolve) => { - answer = resolve; - }); - return { answer: (r) => answer(r), respond: () => pending }; -} - -function openMenu(trigger: HTMLElement) { - fireEvent.pointerDown(trigger, { button: 0, ctrlKey: false, pointerType: "mouse" }); -} - -beforeEach(() => { - authenticatedFetch.mockReset(); - resetFactoryControls(); - setWidth({ wide: true }); - localStorage.clear(); - resetCockpitChoiceCache(); -}); -afterEach(() => { - vi.unstubAllGlobals(); -}); - -describe("the new cockpit · the switch", () => { - it("stays classic while the server does not offer it, whatever this browser remembers", async () => { - localStorage.setItem(COCKPIT_STORAGE_KEY, "v2"); - serve({ offered: false }); - renderAt(""); - - expect(await screen.findByTestId("factory-header")).toBeInTheDocument(); - expect(screen.queryByTestId("cockpit-topbar")).toBeNull(); - expect(screen.queryByTestId("factory-try-cockpit")).toBeNull(); - expect(puts()).toEqual([]); - }); - - it("offers Try the new cockpit, and keeps the choice as the person's preference", async () => { - serve({ choice: null }); - renderAt(""); - - fireEvent.click(await screen.findByTestId("factory-try-cockpit")); - - expect(await screen.findByTestId("cockpit-topbar")).toBeInTheDocument(); - expect(screen.queryByTestId("factory-header")).toBeNull(); - await waitFor(() => expect(puts()).toEqual([{ cockpit: "v2" }])); - expect(localStorage.getItem(COCKPIT_STORAGE_KEY)).toBe("v2"); - }); - - it("goes back to the classic cockpit from the avatar menu", async () => { - serve(); - renderAt("/projects"); - - openMenu(await screen.findByTestId("cockpit-you")); - fireEvent.click(await screen.findByTestId("cockpit-you-classic")); - - expect(await screen.findByTestId("factory-header")).toBeInTheDocument(); - await waitFor(() => expect(puts()).toEqual([{ cockpit: "v1" }])); - expect(location).toBe(`${BASE}/projects`); - }); - - it("follows the person's saved choice over this browser's", async () => { - localStorage.setItem(COCKPIT_STORAGE_KEY, "v1"); - serve({ choice: "v2" }); - renderAt(""); - - expect(await screen.findByTestId("cockpit-topbar")).toBeInTheDocument(); - }); - - it("keeps the choice in this browser for someone the server cannot store it for", async () => { - localStorage.setItem(COCKPIT_STORAGE_KEY, "v2"); - serve({ onRoster: false }); - renderAt(""); - - expect(await screen.findByTestId("cockpit-topbar")).toBeInTheDocument(); - expect(puts()).toEqual([]); - }); - - it("keeps a switch made before the saved choice answers, and saves it", async () => { - localStorage.setItem(COCKPIT_STORAGE_KEY, "v1"); - const slow = later(); - serve({ choice: "v1", prefs: slow.respond }); - renderAt("/projects"); - - // The classic page shows from this browser's copy while the read is out. - fireEvent.click(await screen.findByTestId("factory-try-cockpit")); - expect(await screen.findByTestId("cockpit-topbar")).toBeInTheDocument(); - await waitFor(() => expect(puts()).toEqual([{ cockpit: "v2" }])); - // The read that was already out answers with the old choice: the click still wins. - slow.answer(json({ cockpit: "v1" })); - await act(async () => { - await new Promise((resolve) => { - setTimeout(resolve, 50); - }); - }); - expect(screen.getByTestId("cockpit-topbar")).toBeInTheDocument(); - expect(saved).toBe("v2"); - }); - - it("ignores a ?cockpit= link while the server does not offer the new cockpit", async () => { - serve({ offered: false, choice: null }); - renderAt("/projects?cockpit=v2"); - - expect(await screen.findByTestId("factory-header")).toBeInTheDocument(); - expect(localStorage.getItem(COCKPIT_STORAGE_KEY)).toBeNull(); - expect(puts()).toEqual([]); - }); - - it("keeps the classic page up for someone the server cannot store a choice for", async () => { - serve({ choice: null, onRoster: false }); - renderAt("/projects"); - - expect(await screen.findByTestId("factory-try-cockpit")).toBeInTheDocument(); - await act(async () => { - await new Promise((resolve) => { - setTimeout(resolve, 300); - }); - }); - // The refused preference is asked for a few times at most, never in a loop. - const asked = server.calls.filter( - (c) => c.method === "GET" && c.path.endsWith("/notify/me/ui"), - ); - expect(asked.length).toBeLessThanOrEqual(3); - expect(screen.getByTestId("factory-header")).toBeInTheDocument(); - }); - - it("takes a ?cockpit= link, then drops it from the address", async () => { - serve({ choice: null }); - renderAt("/projects?cockpit=v2"); - - expect(await screen.findByTestId("cockpit-topbar")).toBeInTheDocument(); - await waitFor(() => expect(location).toBe(`${BASE}/projects`)); - await waitFor(() => expect(puts()).toEqual([{ cockpit: "v2" }])); - }); -}); - -describe("the new cockpit · the shell", () => { - it("frames the page with one top bar: switcher, verdict, five places, search, Room, you", async () => { - serve(); - renderAt(""); - - const bar = await screen.findByTestId("cockpit-topbar"); - expect(within(bar).getByTestId("cockpit-switcher")).toHaveTextContent("Atlas factory"); - expect(await within(bar).findByTestId("cockpit-switcher-product")).toHaveTextContent( - "building Sample app (sample)", - ); - const tabs = within(bar).getByTestId("cockpit-tabs"); - expect( - within(tabs) - .getAllByRole("link") - .map((a) => a.textContent?.replace(/\d+$/, "")), - ).toEqual(["Home", "Discovery", "Projects", "Teams", "Steer"]); - expect(within(tabs).getByTestId("cockpit-tab-home")).toHaveAttribute("aria-current", "page"); - // Round 3 waits on the creator: Home carries the one amber count. - expect(await within(tabs).findByTestId("cockpit-tab-home-count")).toHaveTextContent(/\d+/); - // Steer counts only changes to how the factory works (C-15), not the plan C-4 in Needs you. - expect(within(tabs).getByTestId("cockpit-tab-steer-count")).toHaveTextContent("1"); - const verdict = await within(bar).findByTestId("cockpit-verdict"); - await waitFor(() => expect(verdict).toHaveAttribute("data-dot", "red")); - // The served summary is the server's builder over the page scenario (fixture-built). - expect(verdict).toHaveTextContent("Running, 1 lead stopped and 1 critical incident"); - expect(within(bar).getByTestId("cockpit-search")).toBeInTheDocument(); - expect(within(bar).getByTestId("cockpit-room")).toBeInTheDocument(); - // No dock, no second row of facts, no classic tabs. - expect(screen.queryByTestId("room-dock-column")).toBeNull(); - expect(screen.queryByTestId("factory-tabs")).toBeNull(); - expect(screen.queryByTestId("factory-live-dot")).toBeNull(); - expect(screen.getByTestId("factory-shell")).toHaveAttribute("data-cockpit", "v2"); - // The switch reads the small preference, never the Slack view (it pages the DM ledger). - const reads = server.calls.filter((c) => c.method === "GET").map((c) => c.path.split("?")[0]); - expect(reads).toContain(`${SNAPSHOT}/notify/me/ui`); - expect(reads).not.toContain(`${SNAPSHOT}/notify/me`); - }); - - it("never says green while the server's verdict is out and the snapshot shows a stopped project", async () => { - const snapshot = round3Snapshot(); - expect(snapshot.projects.some((p) => p.overlay === "failed")).toBe(true); - serve({ - snapshot, - summary: () => json({ error: { code: "internal", message: "boom" } }, 500), - }); - renderAt(""); - - const verdict = await screen.findByTestId("cockpit-verdict"); - await waitFor(() => - expect(server.calls.some((c) => c.path.endsWith("/status-summary"))).toBe(true), - ); - await act(async () => { - await new Promise((resolve) => { - setTimeout(resolve, 50); - }); - }); - expect(verdict).not.toHaveAttribute("data-dot", "green"); - expect(verdict).toHaveAttribute("data-dot", "red"); - expect(verdict).toHaveTextContent(/stopped/); - }); - - it("says the health is unknown, in grey, when the verdict fails for a calm factory", async () => { - const snapshot = round3Snapshot(); - snapshot.projects = snapshot.projects.map((p) => ({ ...p, overlay: null })); - snapshot.members = snapshot.members.map((m) => ({ ...m, health: null, status: "idle" })); - serve({ - snapshot, - summary: () => json({ error: { code: "internal", message: "boom" } }, 500), - }); - renderAt(""); - - const verdict = await screen.findByTestId("cockpit-verdict"); - // Grey "Checking health…" while the read (and its one retry) is out, then unknown. - expect(verdict).toHaveAttribute("data-dot", "grey"); - await waitFor(() => expect(verdict).toHaveTextContent("Health unknown"), { timeout: 4000 }); - expect(verdict).toHaveAttribute("data-dot", "grey"); - }); - - it("opens the Health panel from the verdict, with why and the machine facts", async () => { - serve(); - renderAt(""); - - fireEvent.click(await screen.findByTestId("cockpit-verdict")); - - expect(location).toContain("peek=health"); - const peek = await screen.findByTestId("health-peek"); - expect(within(peek).getByTestId("health-reasons")).toHaveTextContent("cache-warm"); - expect(within(peek).getByTestId("health-facts")).toHaveTextContent("Updates"); - expect(within(peek).queryByTestId("health-fact-config")).toBeNull(); - expect(screen.getByTestId("factory-live-dot")).toBeInTheDocument(); - fireEvent.keyDown(peek, { key: "Escape" }); - await waitFor(() => expect(location).not.toContain("peek=")); - }); - - it("names a deployment config problem among the machine facts", async () => { - const snapshot = round3Snapshot(); - snapshot.health.config = [ - "no always-deny repos configured (OMNIGENT_FACTORY_ALWAYS_DENY_REPOS)", - ]; - serve({ snapshot }); - renderAt(""); - - fireEvent.click(await screen.findByTestId("cockpit-verdict")); - - const peek = await screen.findByTestId("health-peek"); - expect(within(peek).getByTestId("health-fact-config")).toHaveTextContent( - "no always-deny repos configured (OMNIGENT_FACTORY_ALWAYS_DENY_REPOS)", - ); - }); - - it("keeps the Room as a drawer: the button opens it, the address names it, Esc closes it", async () => { - serve(); - renderAt("/projects"); - - fireEvent.click(await screen.findByTestId("cockpit-room")); - - const drawer = await screen.findByTestId("room-drawer"); - expect(location).toBe(`${BASE}/projects?room=`); - expect(drawer).toHaveAttribute("data-thread", "factory"); - fireEvent.keyDown(drawer, { key: "Escape" }); - await waitFor(() => expect(screen.queryByTestId("room-drawer")).toBeNull()); - expect(location).toBe(`${BASE}/projects`); - }); - - it("opens an old Room link as the drawer over Home", async () => { - serve(); - renderAt("/room/factory"); - - const drawer = await screen.findByTestId("room-drawer"); - expect(drawer).toHaveAttribute("data-thread", "factory"); - expect(location).toBe(`${BASE}?room=factory`); - expect(screen.getByTestId("factory-shell")).toHaveAttribute("data-page", "overview"); - }); - - it("reaches Debug from the avatar menu, with today's views one level down", async () => { - serve(); - renderAt(""); - - openMenu(await screen.findByTestId("cockpit-you")); - fireEvent.click(await screen.findByTestId("cockpit-you-debug")); - - const debug = await screen.findByTestId("factory-debug-page"); - expect(location).toBe(`${BASE}/debug/fleet`); - expect(within(debug).getByTestId("debug-tab-fleet")).toHaveAttribute("aria-current", "page"); - fireEvent.click(within(debug).getByTestId("debug-tab-timeline")); - await waitFor(() => expect(location).toBe(`${BASE}/debug/timeline`)); - expect(await screen.findByTestId("factory-debug-page")).toHaveAttribute( - "data-view", - "timeline", - ); - }); - - it.each([ - ["/timeline", `${BASE}/debug/timeline`, "factory-debug-page"], - ["/alerts", `${BASE}/debug/incidents`, "factory-debug-page"], - ["/agents", `${BASE}/teams/agents`, "factory-teams-page"], - ["/settings/budgets", `${BASE}/steer/budgets`, "factory-steer-page"], - ["/improvements?tab=history", `${BASE}/steer/versions?tab=history`, "factory-steer-page"], - ])("sends the classic %s to %s", async (from, to, page) => { - serve(); - renderAt(from); - - await waitFor(() => expect(location).toBe(to)); - expect(await screen.findByTestId(page)).toBeInTheDocument(); - }); - - it("opens Decisions as the Needs you panel over Home", async () => { - serve(); - renderAt("/decisions?decision=D-2"); - - await waitFor(() => expect(location).toBe(`${BASE}?peek=needs&decision=D-2`)); - expect(await screen.findByTestId("peek")).toHaveTextContent("Needs you"); - }); - - it("keeps Settings personal: people, notifications and integrations", async () => { - serve(); - renderAt("/settings/people"); - - const nav = await screen.findByTestId("settings-nav"); - expect( - within(nav) - .getAllByRole("link") - .map((a) => a.textContent), - ).toEqual(["People", "Notifications", "Integrations"]); - }); - - it("says Discovery is off when the factory has not turned it on", async () => { - serve(); - renderAt("/discovery"); - - expect(await screen.findByTestId("discovery-off")).toBeInTheDocument(); - expect(screen.getByTestId("cockpit-tab-discovery")).toHaveAttribute("aria-current", "page"); - }); - - it("shows a viewer the viewer banner", async () => { - serve({ snapshot: round3Snapshot("viewer") }); - renderAt(""); - - expect(await screen.findByTestId("factory-viewer-banner")).toBeInTheDocument(); - }); - - it("has no axe violations in the top bar, the Health panel or the Room drawer", async () => { - serve(); - renderAt("?room=factory"); - await screen.findByTestId("room-drawer"); - const options: axe.RunOptions = { - // jsdom paints nothing (contrast is checked in the browser over Storybook). - rules: { "color-contrast": { enabled: false }, region: { enabled: false } }, - }; - const bar = await axe.run(screen.getByTestId("cockpit-topbar"), options); - expect(bar.violations.map((v) => `${v.id}: ${v.help}`)).toEqual([]); - const drawer = await axe.run(screen.getByTestId("room-drawer"), options); - expect(drawer.violations.map((v) => `${v.id}: ${v.help}`)).toEqual([]); - }); - - it("opens the palette on / and moves between places with g chords, r and ?", async () => { - serve(); - renderAt("/projects"); - await screen.findByTestId("cockpit-topbar"); - - fireEvent.keyDown(window, { key: "/" }); - const palette = await screen.findByTestId("factory-palette"); - expect(palette).toHaveAttribute("data-cockpit", "v2"); - fireEvent.keyDown(palette, { key: "Escape" }); - await waitFor(() => expect(screen.queryByTestId("factory-palette")).toBeNull()); - - fireEvent.keyDown(window, { key: "g" }); - fireEvent.keyDown(window, { key: "t" }); - await waitFor(() => expect(location).toBe(`${BASE}/teams`)); - - fireEvent.keyDown(window, { key: "r" }); - expect(await screen.findByTestId("room-drawer")).toBeInTheDocument(); - expect(location).toBe(`${BASE}/teams?room=`); - fireEvent.keyDown(screen.getByTestId("room-drawer"), { key: "Escape" }); - await waitFor(() => expect(screen.queryByTestId("room-drawer")).toBeNull()); - - fireEvent.keyDown(window, { key: "?", shiftKey: true }); - expect(await screen.findByTestId("cockpit-keys")).toHaveTextContent("g h · g d"); - }); - - it("puts the five places in a bottom bar on a phone", async () => { - setWidth({ wide: false, phone: true }); - serve(); - renderAt("/projects"); - - const bar = await screen.findByTestId("cockpit-phone-tabs"); - expect(within(bar).getAllByRole("link")).toHaveLength(5); - expect(within(bar).getByTestId("cockpit-phone-projects")).toHaveAttribute( - "aria-current", - "page", - ); - expect(screen.queryByTestId("cockpit-tabs")).toBeNull(); - await act(async () => undefined); - }); -}); diff --git a/web/src/pages/factory/CockpitShell.tsx b/web/src/pages/factory/CockpitShell.tsx deleted file mode 100644 index 56980ff9f4e..00000000000 --- a/web/src/pages/factory/CockpitShell.tsx +++ /dev/null @@ -1,423 +0,0 @@ -/** - * The new cockpit's shell around every factory page: one top bar (switcher, - * verdict, five places, search, Room, theme, you), the viewer banner, and - * the page at full width. The Room is a drawer (`?room=`), the - * Health panel and the interim Needs you list are side panels - * (`?peek=health`, `?peek=needs`), so a link opens the exact view. On a - * phone the five places sit in a bottom bar. ⌘K (or `/`), Share and Add - * project open from anywhere; `g` chords jump between the five places, `r` - * opens the Room and `?` lists the keys. - * - * Cockpit v3 has no Room drawer: every node page carries its chat, so `/`, - * `r`, the Room button and a `?room=` link focus (or go to) the chat of the - * node a thread is about. Its sheets open by the address too: Needs you - * (`?peek=needs`, ranked, once the node source serves it), the base env, - * Try it and the keys; ⌘K finds nodes. - */ - -import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useQueryClient } from "@tanstack/react-query"; -import { AddProjectDialog } from "@/components/factory/controls/AddProjectDialog"; -import { CockpitPalette } from "@/components/factory/cmdk/CockpitPalette"; -import { KeyboardHelp } from "@/components/factory/cmdk/KeyboardHelp"; -import { - PLACE_HREF, - useCockpitKeys, - useFactoryPaletteHotkey, -} from "@/components/factory/cmdk/useFactoryPaletteHotkey"; -import { useFactoryServerNowS } from "@/components/factory/factoryClock"; -import { factoryPageHref } from "@/components/factory/factoryRoutes"; -import { shellNeedsCount } from "@/components/factory/home/homeModel"; -import { useCachedFactoryAsks } from "@/components/factory/home/homeQueries"; -import { useFactoryDefinition } from "@/components/factory/insights/learnQueries"; -import { splitNeedsYou } from "@/components/factory/overview/needsYouModel"; -import { useFactoryStatusSummary } from "@/components/factory/overview/statusSummaryQuery"; -import { ShareDialog } from "@/components/factory/people/ShareDialog"; -import { RoomDrawer, mentionCount } from "@/components/factory/room/RoomDock"; -import { useRoomThreads } from "@/components/factory/room/roomQueries"; -import { Peek } from "@/components/factory/ui/Peek"; -import { FactoryToastProvider } from "@/components/factory/ui/Toast"; -import { PAGE_PALETTE_ATTRIBUTE } from "@/hooks/useCommandPaletteHotkey"; -import { - mentionNotices, - snapshotNotices, - useFactoryNotifications, -} from "@/hooks/useFactoryNotifications"; -import { useIsMobileViewport } from "@/hooks/useIsMobileViewport"; -import type { FactorySnapshot } from "@/lib/factoryApi"; -import { useNavigate, useSearchParams } from "@/lib/routing"; -import { - FactoryShellContext, - type FactoryShellValue, -} from "@/components/factory/shell/FactoryShellContext"; -import { FactoryTopBar, fallbackVerdict } from "@/components/factory/shell/FactoryTopBar"; -import { HealthPeek } from "@/components/factory/shell/HealthPeek"; -import { CockpitPhoneTabBar, TreePhoneTabBar } from "@/components/factory/shell/PhoneTabBar"; -import { factoryLocation } from "@/components/factory/shell/shellRoutes"; -import { ViewerBanner } from "@/components/factory/shell/ViewerBanner"; -import { pageThread } from "@/components/factory/room/roomModel"; -import { - CHAT_PARAM, - NodeChatSlotContext, - createNodeChatSlot, -} from "@/components/factory/v3/chat/chatLayout"; -import { DISCOVERY_THREAD, chatNodeRef } from "@/components/factory/v3/chat/nodeThread"; -import { NODE_STALE_MS, nodeQueryKeys, useNode } from "@/components/factory/v3/nodeQueries"; -import { useNeedsYou } from "@/components/factory/v3/nodeSteer"; -import { V3Sheets } from "@/components/factory/v3/sheets/V3Sheets"; -import { FX_REF, PRODUCT_REF, nodeHref } from "@/components/factory/v3/nodeRoutes"; -import { useNodeSource } from "@/components/factory/v3/nodeSource"; -import { DecisionsPage } from "./DecisionsPage"; - -/** The id of the page's main region (the skip link's target). */ -export const COCKPIT_MAIN_ID = "factory-main"; - -/** `?room=` and `?peek=`: the drawer and side panels the address opens. */ -export const ROOM_PARAM = "room"; -export const PEEK_PARAM = "peek"; - -const NONE_HELD: ReadonlySet = new Set(); - -export function CockpitShell({ - snapshot, - clockOffsetS, - updatedAt, - rest, - onClassic, - onV2, - variant = "v2", - children, -}: { - snapshot: FactorySnapshot; - clockOffsetS: number; - updatedAt: number; - rest: string; - /** Switch this person back to the classic cockpit. */ - onClassic: () => void; - /** Cockpit v3: switch back to v2. */ - onV2?: () => void; - /** v3 puts the node pages in this shell (Home is the product's node page). */ - variant?: "v2" | "v3"; - children: ReactNode; -}) { - const fid = snapshot.factory.id; - const [params, setParams] = useSearchParams(); - const nowS = useFactoryServerNowS(clockOffsetS); - const phone = useIsMobileViewport(); - const location = useMemo(() => factoryLocation(rest), [rest]); - const [sharing, setSharing] = useState(false); - const [palette, setPalette] = useState(false); - const [keys, setKeys] = useState(false); - const [adding, setAdding] = useState<{ title: string | null } | null>(null); - const navigate = useNavigate(); - const togglePalette = useCallback(() => setPalette((open) => !open), []); - useFactoryPaletteHotkey(togglePalette); - const threads = useRoomThreads(fid).data?.data; - useFactoryNotifications( - useMemo( - () => [...snapshotNotices(snapshot), ...mentionNotices(fid, snapshot.factory.name, threads)], - [snapshot, fid, threads], - ), - ); - const summaryQuery = useFactoryStatusSummary(fid); - // The query keeps the previous factory's answer while a switched-to one loads. - const summary = summaryQuery.data?.factory_id === fid ? summaryQuery.data : undefined; - const productName = useFactoryDefinition(fid).data?.effective.product?.name ?? null; - const split = useMemo(() => splitNeedsYou(snapshot), [snapshot]); - // Answers Home holds for Undo already left its list; the count leaves them out too. - const [held, setHeld] = useState>(NONE_HELD); - // Home's own read of the list, from the cache only: the shell never fetches it. - const asks = useCachedFactoryAsks(fid); - // One count: the rows of Needs you (older servers: what waits on the viewer). - const shellNeeds = shellNeedsCount( - { - count: - summary?.counts.needs_you ?? - summary?.counts.waiting_for_you ?? - split.mine.length + split.role.length, - at: summary?.counts.needs_you != null ? summary.generated_at : null, - }, - held, - asks?.factory_id === fid ? asks : null, - ); - // Until the server's verdict is in (or when the read fails, or an older - // server has none) the snapshot's facts speak, and they never say green. - const verdict = - summary?.verdict ?? - fallbackVerdict( - snapshot, - summaryQuery.isPending || summaryQuery.isPlaceholderData ? "checking" : "unknown", - ); - const room = params.get(ROOM_PARAM); - const peek = params.get(PEEK_PARAM); - const v3 = variant === "v3"; - const [chatSlot] = useState(createNodeChatSlot); - const queryClient = useQueryClient(); - const nodeSource = useNodeSource(v3); - // v3 with a ranked Needs you: one count of asks everywhere, the sheet's (every - // ask of both trees, also those that block nothing), as Your move ranks them. - const ranked = useNeedsYou("product", v3 && Boolean(nodeSource?.needsYou), fid).data; - const productAsks = ranked ? ranked.items.length + ranked.blocks_nothing.length : null; - const needsYou = ranked ? productAsks! + ranked.other_tree : shellNeeds; - // The ranked list and the base env chip (the product's page) move with the - // factory: read them again whenever the snapshot's open asks change (a push - // frame re-read it) or the summary's poll answers, as the polled count did. - const liveMark = useMemo( - () => - [ - snapshot.decisions - .filter((d) => d.status === "open") - .map((d) => d.id) - .sort() - .join(","), - summary?.counts.needs_you ?? summary?.counts.waiting_for_you ?? "", - summary?.generated_at ?? "", - ].join("|"), - [snapshot.decisions, summary], - ); - const sourceKind = nodeSource?.kind; - const markRead = useRef(liveMark); - useEffect(() => { - if (liveMark === markRead.current) return; - markRead.current = liveMark; - if (!v3 || !sourceKind) return; - void queryClient.invalidateQueries({ - queryKey: [...nodeQueryKeys.all(fid), sourceKind, "needs-you"], - }); - void queryClient.invalidateQueries({ - queryKey: nodeQueryKeys.node(fid, sourceKind, PRODUCT_REF), - exact: true, - }); - }, [fid, liveMark, queryClient, sourceKind, v3]); - /** - * v3: the node page whose chat shows a thread, with its chat open (on - * sample data, the product's: sample nodes have no threads). #discovery - * reads the product's page for a need to open. - */ - const nodeChatHref = useCallback( - async (thread: string): Promise => { - const sample = nodeSource?.kind !== "live"; - const root = - !sample && thread === DISCOVERY_THREAD && nodeSource - ? await queryClient - .fetchQuery({ - queryKey: nodeQueryKeys.node(fid, nodeSource.kind, PRODUCT_REF), - queryFn: ({ signal }) => nodeSource.node(fid, PRODUCT_REF, signal), - staleTime: NODE_STALE_MS, - }) - .catch(() => null) - : null; - const ref = chatNodeRef(thread, { snapshot, sample, root }); - return `${nodeHref(fid, ref)}?${CHAT_PARAM}=1`; - }, - [fid, nodeSource, queryClient, snapshot], - ); - const openThreadChat = useCallback( - (thread: string, replace = false) => { - void nodeChatHref(thread).then((href) => navigate(href, { replace })); - }, - [navigate, nodeChatHref], - ); - /** v3: focus the chat on this page, else open the chat of the page's thread. */ - const openChat = useCallback(() => { - if (chatSlot.focus()) return; - openThreadChat(pageThread(snapshot, location, null)); - }, [chatSlot, location, openThreadChat, snapshot]); - // An old `?room=` link under v3 opens that thread's node with its chat - // (once the shell knows whether the nodes are this factory's or the sample's). - const sourceKnown = nodeSource != null; - useEffect(() => { - if (v3 && room != null && sourceKnown) openThreadChat(room || "factory", true); - }, [openThreadChat, room, sourceKnown, v3]); - - const setParam = useCallback( - (key: string, value: string | null) => - setParams( - (prev) => { - const next = new URLSearchParams(prev); - if (value == null) next.delete(key); - else next.set(key, value); - return next; - }, - { replace: true }, - ), - [setParams], - ); - // v3: the base env the top bar names (the product's node page, read again with the live mark above). - const root = useNode(v3 ? PRODUCT_REF : "", fid).data; - const env = root?.node.env; - // v3: the tree the page is in (its node's root, from the page's own read) and the factory's asks. - const pageRef = v3 && location.page === "node" ? (location.id ?? "") : ""; - const pageNode = useNode(pageRef, fid).data; - const fxRoot = useNode(v3 ? FX_REF : "", fid).data; - const inFactory = (pageNode?.breadcrumb[0]?.ref ?? pageNode?.node.ref) === FX_REF; - useCockpitKeys({ - onPalette: () => { - if (!(v3 && chatSlot.focus())) setPalette(true); - }, - onHelp: () => (v3 ? setParam(PEEK_PARAM, "keys") : setKeys(true)), - onRoom: v3 ? openChat : () => setParam(ROOM_PARAM, room ?? ""), - onGo: (place) => navigate(PLACE_HREF[place](fid)), - }); - const shell: FactoryShellValue = useMemo( - () => ({ - factoryId: fid, - snapshot, - clockOffsetS, - nowS, - updatedAt, - location, - // No dock sits beside the page: pages show their own decisions. - wide: false, - cockpit: "v2", - openShare: () => setSharing(true), - openPalette: () => setPalette(true), - openAddProject: () => setAdding({ title: null }), - openAddProjectTitled: (title: string | null) => setAdding({ title }), - holdAnswers: setHeld, - }), - [fid, snapshot, clockOffsetS, nowS, updatedAt, location], - ); - const viewer = snapshot.viewer ?? null; - return ( - - { - event.preventDefault(); - document.getElementById(COCKPIT_MAIN_ID)?.focus(); - }} - className="sr-only z-40 rounded-md bg-background px-3 py-1.5 text-sm focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:ring-2 focus:ring-status-blue" - > - Skip to main content - -
      - setParam(PEEK_PARAM, "health")} - onPalette={() => setPalette(true)} - onRoom={v3 ? openChat : () => setParam(ROOM_PARAM, room ?? "")} - onShare={() => setSharing(true)} - onAddProject={() => setAdding({ title: null })} - onClassic={onClassic} - onV2={onV2} - {...(v3 - ? { - onNeedsYou: () => setParam(PEEK_PARAM, "needs"), - baseEnv: env - ? { - label: `${env.name} ${env.branch}${env.sha ? ` @${env.sha}` : ""}`, - ok: env.smoke?.ok ?? null, - onOpen: () => setParam(PEEK_PARAM, "env"), - } - : null, - trees: { - current: inFactory ? "factory" : "product", - productHref: factoryPageHref(fid), - factoryHref: nodeHref(fid, FX_REF), - factoryNeeds: ranked?.other_tree ?? fxRoot?.node.needs.open ?? 0, - }, - } - : {})} - /> - {viewer?.role === "viewer" && } -
      - {/* The cockpit toast (Undo after Apply) outlives the page that showed it. */} - - - {children} - - {/* Inside the toast's reach: a verb's Undo shows there. Both are portals. */} - setSharing(true)} - onAddProject={() => setAdding({ title: null })} - onRoom={(thread) => (v3 ? openThreadChat(thread) : setParam(ROOM_PARAM, thread))} - nodes={v3} - /> - {v3 ? ( - - ) : ( - - )} - -
      -
      - {!v3 && ( - !open && setParam(ROOM_PARAM, null)} - onThread={(thread) => setParam(ROOM_PARAM, thread)} - /> - )} - !open && setParam(PEEK_PARAM, null)} - snapshot={snapshot} - nowS={nowS} - verdict={verdict} - spend={summary?.spend ?? null} - /> - !open && setParam(PEEK_PARAM, null)} - title="Needs you" - description="Everything the factory asked a person, answered and expired included." - wide - > - {peek === "needs" && } - - {phone && - (v3 ? ( - - ) : ( - - ))} - - !open && setAdding(null)} - /> -
      - ); -} diff --git a/web/src/pages/factory/CockpitV3.test.tsx b/web/src/pages/factory/CockpitV3.test.tsx deleted file mode 100644 index 209901ddef9..00000000000 --- a/web/src/pages/factory/CockpitV3.test.tsx +++ /dev/null @@ -1,505 +0,0 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; -import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { LIVE_RUN_FACTORY_ID, preflightOk } from "@/components/factory/__fixtures__/liveRun"; -import { round3Tasks } from "@/components/factory/agents/__fixtures__/opsFixtures"; -import { resetFactoryControls } from "@/components/factory/controls/useFactoryControls"; -import { definitionResponse } from "@/components/factory/insights/__fixtures__/learnFixtures"; -import { notifyMe } from "@/components/factory/notify/__fixtures__/notifyFixtures"; -import { round3Snapshot } from "@/components/factory/shell/__fixtures__/shellFixtures"; -import { COCKPIT_STORAGE_KEY, resetCockpitChoiceCache } from "@/components/factory/shell/cockpitV2"; -import serverLevels from "@/components/factory/__fixtures__/nodeLevels.server.json"; -import serverPages from "@/components/factory/__fixtures__/nodePages.server.json"; -import { sampleNodeSource } from "@/components/factory/v3/fixture/sampleNodeSource"; -import { type NodeSource, NodeSourceProvider } from "@/components/factory/v3/nodeSource"; -import type { LevelView, NodePage } from "@/components/factory/v3/nodeTypes"; -import { factoryQueryKey } from "@/hooks/useFactory"; -import type { CockpitChoice, FactorySnapshot } from "@/lib/factoryApi"; -import { FactoryPage } from "./FactoryPage"; -import { type FactoryServer, SNAPSHOT, json, serveFactory } from "./testServer"; - -// Cockpit v3 ships behind the cockpit choice: `?cockpit=v3` (while the server -// offers the new cockpit) turns it on in this browser; v1 and v2 stay as they -// are, and v2 stays what someone who chose the new cockpit sees by default. -// v3 reads the server's node API; the switch tests pass the sample factory -// as their source, the live tests serve the server's own pages. - -const authenticatedFetch = vi.fn(); -vi.mock("@/lib/identity", () => ({ - authenticatedFetch: (...args: unknown[]) => authenticatedFetch(...args), -})); -vi.mock("@/hooks/useSessionUpdatesConnected", () => ({ - useSessionUpdatesConnected: () => true, -})); - -const BASE = `/factory/${LIVE_RUN_FACTORY_ID}`; - -let location = ""; -function LocationProbe() { - const loc = useLocation(); - location = loc.pathname + loc.search; - return null; -} - -/** Render the factory at *path*; v3 reads *source* (the sample), or the server when null. */ -function renderAt(path: string, source: NodeSource | null = sampleNodeSource()) { - const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - const routes = ( - - - - - - - } - /> - - - ); - const view = render( - - {source ? {routes} : routes} - , - ); - return { ...view, client }; -} - -const PAGES = serverPages as unknown as Record; -const LEVELS = serverLevels as unknown as Record; - -/** The server's node API: its pages and level views (written by the server's tests). */ -function nodeRoutes(): Record Response> { - const routes: Record Response> = {}; - for (const [ref, page] of Object.entries(PAGES)) { - routes[`GET ${SNAPSHOT}/n/${encodeURIComponent(ref)}`] = () => json(page); - } - for (const [key, view] of Object.entries(LEVELS)) { - const [ref, level] = key.split("/") as [string, string]; - routes[`GET ${SNAPSHOT}/n/${encodeURIComponent(ref)}/level/${level}`] = () => json(view); - } - return routes; -} - -let server: FactoryServer; -let saved: CockpitChoice | null; - -function serve({ - choice = "v2" as CockpitChoice | null, - offered = true, - extra = {} as Record Response>, - snapshot = round3Snapshot as () => FactorySnapshot, -} = {}) { - saved = choice; - server = serveFactory(authenticatedFetch, { - snapshot: () => json(snapshot()), - overrides: { - ...extra, - "GET /v1/factory/preflight": () => json({ ...preflightOk(), cockpit_v2: offered }), - [`GET ${SNAPSHOT}/notify/me/ui`]: () => json({ cockpit: saved }), - [`PUT ${SNAPSHOT}/notify/me/ui`]: async () => { - saved = (server.calls.at(-1)!.body as { cockpit: CockpitChoice }).cockpit; - return json({ cockpit: saved }); - }, - [`GET ${SNAPSHOT}/notify/me`]: () => json(notifyMe()), - [`GET ${SNAPSHOT}/definition`]: () => json(definitionResponse()), - [`GET ${SNAPSHOT}/tasks`]: () => json({ tasks: round3Tasks(), projects: [] }), - }, - }); -} - -function puts(): unknown[] { - return server.calls - .filter((c) => c.method === "PUT" && c.path.endsWith("/notify/me/ui")) - .map((c) => c.body); -} - -beforeEach(() => { - authenticatedFetch.mockReset(); - resetFactoryControls(); - vi.stubGlobal( - "matchMedia", - vi.fn((query: string) => ({ - matches: false, - media: query, - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - })), - ); - localStorage.clear(); - resetCockpitChoiceCache(); -}); -afterEach(() => { - vi.unstubAllGlobals(); -}); - -describe("cockpit v3 · the switch and routes", () => { - it("takes a ?cockpit=v3 link: Home is the product's node page in the new cockpit's shell", async () => { - serve(); - renderAt("?cockpit=v3"); - - const page = await screen.findByTestId("v3-node-page"); - expect(page).toHaveAttribute("data-ref", "product"); - expect(screen.getByTestId("factory-shell")).toHaveAttribute("data-cockpit", "v3"); - expect(screen.getByTestId("cockpit-topbar")).toBeInTheDocument(); - // The sample source is labelled as sample. - expect(screen.getByTestId("v3-sample-notice")).toHaveTextContent("Sample data"); - await waitFor(() => expect(location).toBe(BASE)); - expect(localStorage.getItem(COCKPIT_STORAGE_KEY)).toBe("v3"); - // The server stores v1 or v2 only: v3 stays in this browser. - expect(puts()).toEqual([]); - }); - - it("keeps v3 after a reload, over the person's stored v2", async () => { - serve({ choice: "v2" }); - const first = renderAt("?cockpit=v3"); - await screen.findByTestId("v3-node-page"); - first.unmount(); - resetCockpitChoiceCache(); - - renderAt(""); - expect(await screen.findByTestId("v3-node-page")).toHaveAttribute("data-ref", "product"); - }); - - it("a v3 link to a node or a level survives a reload", async () => { - localStorage.setItem(COCKPIT_STORAGE_KEY, "v3"); - serve(); - const first = renderAt("/n/I-1"); - expect(await screen.findByTestId("v3-node-page")).toHaveAttribute("data-ref", "I-1"); - first.unmount(); - - renderAt("/n/I-1"); - expect(await screen.findByTestId("v3-node-page")).toHaveAttribute("data-ref", "I-1"); - expect(location).toBe(`${BASE}/n/I-1`); - expect(screen.getByTestId("v3-level-strip")).toBeInTheDocument(); - }); - - it("opens one level under a node from its address", async () => { - localStorage.setItem(COCKPIT_STORAGE_KEY, "v3"); - serve(); - renderAt("/n/product/project"); - - expect(await screen.findByTestId("v3-level-view")).toBeInTheDocument(); - expect(screen.getAllByTestId("v3-group").length).toBeGreaterThan(0); - // Grouped under the features' names; no meta text says so. - expect(screen.getByTestId("v3-below")).not.toHaveTextContent("grouped under"); - }); - - it("goes back to v2 from the page, and saves v2 as before", async () => { - serve({ choice: "v1" }); - renderAt("?cockpit=v3"); - fireEvent.click(await screen.findByRole("button", { name: "Back to cockpit v2" })); - - await waitFor(() => expect(screen.queryByTestId("v3-node-page")).toBeNull()); - expect(screen.getByTestId("factory-shell")).toHaveAttribute("data-cockpit", "v2"); - await waitFor(() => expect(puts()).toEqual([{ cockpit: "v2" }])); - expect(localStorage.getItem(COCKPIT_STORAGE_KEY)).toBe("v2"); - }); - - it("leaves v2 the default for someone who chose the new cockpit", async () => { - serve({ choice: "v2" }); - renderAt(""); - - expect(await screen.findByTestId("cockpit-topbar")).toBeInTheDocument(); - expect(screen.getByTestId("factory-shell")).toHaveAttribute("data-cockpit", "v2"); - expect(screen.queryByTestId("v3-node-page")).toBeNull(); - }); - - it("sends a v3 link opened on v2 to the closest page there (a project's page)", async () => { - serve({ choice: "v2" }); - renderAt("/n/db-version"); - - await waitFor(() => expect(location).toBe(`${BASE}/projects/db-version`)); - }); - - it("sends a task's v3 link opened on v2 to its project's page", async () => { - serve({ choice: "v2" }); - renderAt("/n/T-3"); - - await waitFor(() => expect(location).toBe(`${BASE}/projects/api-version`)); - }); - - it("sends a feature's v3 link opened on v2 to its initiative's page", async () => { - serve({ choice: "v2" }); - renderAt("/n/I-1"); - - await waitFor(() => expect(location).toBe(`${BASE}/initiatives/I-1`)); - }); - - it("offers the way back to v2 in the person's menu while v3 is on", async () => { - serve({ choice: "v2" }); - renderAt("?cockpit=v3"); - await screen.findByTestId("v3-node-page"); - - fireEvent.pointerDown(screen.getByTestId("cockpit-you"), { - button: 0, - ctrlKey: false, - pointerType: "mouse", - }); - fireEvent.click(await screen.findByTestId("cockpit-you-v2")); - - await waitFor(() => expect(screen.queryByTestId("v3-node-page")).toBeNull()); - expect(screen.getByTestId("factory-shell")).toHaveAttribute("data-cockpit", "v2"); - expect(localStorage.getItem(COCKPIT_STORAGE_KEY)).toBe("v2"); - }); - - it("says an answer on sample data answered nothing", async () => { - serve(); - renderAt("?cockpit=v3"); - const move = await screen.findByTestId("v3-your-move"); - - fireEvent.click(within(move).getAllByRole("button", { name: "Apply" })[0]!); - - expect(await screen.findByText(/Sample data: nothing was answered/)).toBeInTheDocument(); - expect(server.calls.filter((c) => c.path.includes("/decisions/"))).toEqual([]); - }); - - it("opens v3's sheets from the shell: Needs you from an ask's peek, the base env, the keys", async () => { - serve(); - renderAt("?cockpit=v3"); - const move = await screen.findByTestId("v3-your-move"); - const row = within(move).getAllByTestId("v3-move-row")[0]!; - fireEvent.click(within(row).getByRole("button", { name: /Apply C-16/ })); - fireEvent.click(within(row).getByRole("button", { name: "All asks" })); - // The sheet lists the asks as Your move does (short words, never the raw title). - const needs = await screen.findByTestId("v3-needs-sheet", {}, { timeout: 4000 }); - await waitFor(() => expect(needs).toHaveTextContent("Apply C-16")); - expect(needs).not.toHaveTextContent("proposes C-16"); - await waitFor(() => expect(location).toBe(`${BASE}?peek=needs`)); - fireEvent.keyDown(document.activeElement ?? document.body, { key: "Escape" }); - await waitFor(() => expect(screen.queryByTestId("v3-needs-sheet")).toBeNull()); - // One count: every ask of both trees (Product's 5 and Factory's 3), also those that block nothing. - expect(await screen.findByTestId("cockpit-needs-you")).toHaveTextContent("8 need you"); - const env = await screen.findByTestId("cockpit-base-env"); - expect(env).toHaveTextContent("Base env sample-main @817480d"); - fireEvent.click(env); - expect(await screen.findByTestId("v3-env-sheet")).toHaveTextContent("smoke OK"); - fireEvent.keyDown(document.activeElement ?? document.body, { key: "Escape" }); - await waitFor(() => expect(screen.queryByTestId("v3-env-sheet")).toBeNull()); - fireEvent.keyDown(window, { key: "?" }); - expect(await screen.findByTestId("cockpit-keys")).toHaveTextContent("a · s · t"); - expect(location).toBe(`${BASE}?peek=keys`); - }); - - it("raises the “need you” pill when a new ask opens, without any v3 write", async () => { - let snapshot = round3Snapshot(); - serve({ snapshot: () => snapshot }); - const sample = sampleNodeSource(); - let asked = false; - const source: NodeSource = { - ...sample, - // The ranked list gains the new ask once it is open. - needsYou: async (fid, tree, signal) => { - const list = await sample.needsYou!(fid, tree, signal); - const first = list.items[0]!; - return asked - ? { - ...list, - items: [...list.items, { ...first, decision: { ...first.decision, id: "D-99" } }], - } - : list; - }, - }; - const { client } = renderAt("?cockpit=v3", source); - // The ranked count (every ask, also those that block nothing) once the list is read. - await waitFor(() => - expect(screen.getByTestId("cockpit-needs-you")).toHaveTextContent("8 need you"), - ); - // An agent asks: the next snapshot (a push frame re-reads it) holds one more open decision. - const open = snapshot.decisions.find((d) => d.status === "open")!; - asked = true; - snapshot = { ...snapshot, decisions: [...snapshot.decisions, { ...open, id: "D-99" }] }; - await client.invalidateQueries({ queryKey: factoryQueryKey(LIVE_RUN_FACTORY_ID) }); - await waitFor(() => - expect(screen.getByTestId("cockpit-needs-you")).toHaveTextContent("9 need you"), - ); - }); - - it("ignores ?cockpit=v3 while the server does not offer the new cockpit", async () => { - serve({ offered: false, choice: null }); - renderAt("?cockpit=v3"); - - expect(await screen.findByTestId("factory-header")).toBeInTheDocument(); - expect(screen.queryByTestId("v3-node-page")).toBeNull(); - expect(localStorage.getItem(COCKPIT_STORAGE_KEY)).toBeNull(); - }); -}); - -describe("cockpit v3 · the server's node API", () => { - it("reads the server's pages by default: Home lists its features under their needs", async () => { - serve({ extra: nodeRoutes() }); - renderAt("?cockpit=v3", null); - - const page = await screen.findByTestId("v3-node-page"); - expect(page).toHaveAttribute("data-ref", "product"); - const view = await screen.findByTestId("v3-level-view"); - expect(within(view).getAllByTestId("v3-group")).toHaveLength( - LEVELS["product/feature"]!.groups.length, - ); - expect(screen.queryByTestId("v3-sample-notice")).toBeNull(); - const reads = server.calls.map((c) => c.path); - expect(reads).toContain(`${SNAPSHOT}/n/product`); - expect(reads).toContain(`${SNAPSHOT}/n/product/level/feature`); - }); - - it("Apply in Your move answers through the answer route and reads the page again", async () => { - const decision = PAGES["assistant-core"]!.status.decision!; - const answer = `/v1/factory/decisions/${decision.row_id}/answer`; - serve({ - extra: { ...nodeRoutes(), [`POST ${answer}`]: () => json({ decision: { id: "x" } }) }, - }); - localStorage.setItem(COCKPIT_STORAGE_KEY, "v3"); - renderAt("/n/assistant-core", null); - const move = await screen.findByTestId("v3-your-move"); - const reads = () => server.calls.filter((c) => c.path === `${SNAPSHOT}/n/assistant-core`); - const before = reads().length; - - fireEvent.click(within(move).getByRole("button", { name: "Apply" })); - - await waitFor(() => - expect(server.calls.find((c) => c.path === answer)?.body).toEqual({ option_id: "apply" }), - ); - await waitFor(() => expect(reads().length).toBeGreaterThan(before)); - expect(await screen.findByText("D-45: Apply")).toBeInTheDocument(); - }); -}); - -describe("cockpit v3 · the factory tree in the shell", () => { - it("Product | Factory in the top bar: one tree at a time, the factory's asks counted", async () => { - localStorage.setItem(COCKPIT_STORAGE_KEY, "v3"); - serve(); - renderAt("/n/fx"); - - expect(await screen.findByTestId("v3-node-page")).toHaveAttribute("data-ref", "fx"); - const trees = screen.getByTestId("cockpit-trees"); - await waitFor(() => - expect(within(trees).getByTestId("cockpit-tree-factory")).toHaveAttribute( - "aria-current", - "page", - ), - ); - // C-17 and C-18 wait on a person, and an older ask on Sentinel. - expect(within(trees).getByTestId("cockpit-tree-factory-count")).toHaveTextContent("3"); - // v3's two trees take the place of v2's places. - expect(screen.queryByTestId("cockpit-tabs")).toBeNull(); - - fireEvent.click(within(trees).getByTestId("cockpit-tree-product")); - expect(await screen.findByTestId("v3-node-page")).toHaveAttribute("data-ref", "product"); - await waitFor(() => - expect( - within(screen.getByTestId("cockpit-trees")).getByTestId("cockpit-tree-product"), - ).toHaveAttribute("aria-current", "page"), - ); - }); - - it("on a phone: one ⋯ menu in the top bar, and the bottom bar is Product · Factory · Chat", async () => { - vi.stubGlobal( - "matchMedia", - vi.fn((query: string) => ({ - matches: query.includes("max-width: 767.98px"), - media: query, - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - })), - ); - localStorage.setItem(COCKPIT_STORAGE_KEY, "v3"); - serve(); - renderAt(""); - expect(await screen.findByTestId("v3-node-page")).toHaveAttribute("data-ref", "product"); - const bar = screen.getByTestId("cockpit-topbar"); - // Search, the chat, the theme and you fold into one menu: the bar fits 390 px. - for (const gone of ["cockpit-search", "cockpit-room", "cockpit-theme", "cockpit-you"]) { - expect(within(bar).queryByTestId(gone)).toBeNull(); - } - fireEvent.pointerDown(within(bar).getByTestId("cockpit-more"), { - button: 0, - ctrlKey: false, - pointerType: "mouse", - }); - expect(await screen.findByTestId("cockpit-more-search")).toBeInTheDocument(); - expect(screen.getByTestId("cockpit-you-settings")).toBeInTheDocument(); - fireEvent.keyDown(document.activeElement ?? document.body, { key: "Escape" }); - // No tab leaves v3: its two trees (each with its asks) and the chat. - const tabs = screen.getByTestId("cockpit-phone-tabs"); - expect(tabs).toHaveAttribute("data-cockpit", "v3"); - expect(tabs).toHaveTextContent(/^\d*Product\d*FactoryChat$/); - await waitFor(() => - expect(within(tabs).getByTestId("cockpit-phone-product-badge")).toHaveTextContent("5"), - ); - expect(within(tabs).getByTestId("cockpit-phone-factory-badge")).toHaveTextContent("3"); - expect(screen.queryByTestId("cockpit-phone-discovery")).toBeNull(); - }); - - it("an area deep in the factory tree is the Factory's too", async () => { - localStorage.setItem(COCKPIT_STORAGE_KEY, "v3"); - serve(); - renderAt("/n/fx:health"); - - expect(await screen.findByTestId("v3-closed-alarms")).toBeInTheDocument(); - await waitFor(() => - expect(screen.getByTestId("cockpit-tree-factory")).toHaveAttribute("aria-current", "page"), - ); - }); - - it("r on Factory › Definition renames the levels; elsewhere r still talks in the chat", async () => { - localStorage.setItem(COCKPIT_STORAGE_KEY, "v3"); - serve(); - const first = renderAt("/n/fx:definition"); - expect(await screen.findByTestId("v3-levels-block")).toBeInTheDocument(); - - fireEvent.keyDown(document.body, { key: "r" }); - - expect(await screen.findByTestId("v3-preview")).toHaveAttribute("data-label", "Rename levels"); - expect(screen.queryByTestId("v3-chat-full")).toBeNull(); - first.unmount(); - - renderAt("/n/I-1"); - expect(await screen.findByTestId("v3-node-page")).toHaveAttribute("data-ref", "I-1"); - fireEvent.keyDown(document.body, { key: "r" }); - expect(await screen.findByTestId("v3-chat-full")).toBeInTheDocument(); - expect(screen.queryByTestId("v3-preview")).toBeNull(); - }); -}); - -describe("cockpit v3 · the node chat in the shell", () => { - it("replaces the Room drawer: the Room button opens the node's chat", async () => { - serve(); - renderAt("?cockpit=v3"); - expect(await screen.findByTestId("v3-chat-pinned")).toBeInTheDocument(); - - fireEvent.click(screen.getByTestId("cockpit-room")); - - const full = await screen.findByTestId("v3-chat-full"); - expect(screen.queryByTestId("room-drawer")).toBeNull(); - // Sample nodes have no threads: the chat is this factory's #factory, and says so. - expect(within(full).getByTestId("room-thread")).toHaveAttribute("data-thread", "factory"); - expect(within(full).getByTestId("v3-chat-title")).toHaveAttribute( - "title", - expect.stringContaining("this factory’s #factory: the sample’s nodes have no threads"), - ); - await waitFor(() => expect(location).toBe(`${BASE}?chat=1`)); - }); - - it("sends an old ?room= link to the node the thread is about, with its chat open", async () => { - serve({ choice: null }); - localStorage.setItem(COCKPIT_STORAGE_KEY, "v3"); - renderAt("?room=factory"); - - expect(await screen.findByTestId("v3-chat-full")).toBeInTheDocument(); - await waitFor(() => expect(location).toBe(`${BASE}/n/product?chat=1`)); - expect(screen.queryByTestId("room-drawer")).toBeNull(); - }); - - it("sends a project's ?room= link on sample data to the product's chat, never a missing page", async () => { - serve({ choice: null }); - localStorage.setItem(COCKPIT_STORAGE_KEY, "v3"); - renderAt("?room=project:db-version"); - - expect(await screen.findByTestId("v3-chat-full")).toBeInTheDocument(); - await waitFor(() => expect(location).toBe(`${BASE}/n/product?chat=1`)); - expect(screen.queryByTestId("v3-node-missing")).toBeNull(); - }); -}); diff --git a/web/src/pages/factory/DemosPage.tsx b/web/src/pages/factory/DemosPage.tsx new file mode 100644 index 00000000000..073c5b913b2 --- /dev/null +++ b/web/src/pages/factory/DemosPage.tsx @@ -0,0 +1,174 @@ +/** + * `/demos`: every demo the factory recorded, newest first, as cards that play + * in place. Filters by feature and by verdict sit in the address + * (`?feature=I-1&verdict=PARTIAL`, `&play=` opens one playing), so a link + * opens the same list. + */ + +import { useMemo } from "react"; +import { ClapperboardIcon } from "lucide-react"; +import { DemoCard, DemoListSkeleton, useFactoryDemos } from "@/components/factory/cockpit/demos"; +import { useFactoryShell } from "@/components/factory/shell/FactoryShellContext"; +import { FOCUS_RING } from "@/components/factory/ui/statusTokens"; +import type { DemoRef, DemoVerdict } from "@/components/factory/v3/nodeTypes"; +import { useSearchParams } from "@/lib/routing"; +import { cn } from "@/lib/utils"; + +const VERDICTS: { key: DemoVerdict | null; label: string }[] = [ + { key: null, label: "All" }, + { key: "PASS", label: "Passed" }, + { key: "PARTIAL", label: "Partial" }, + { key: "FAIL", label: "Failed" }, +]; + +function matches(demo: DemoRef, feature: string | null, verdict: string | null): boolean { + if (feature && demo.feature?.ref !== feature) return false; + if (verdict === "FAIL") return demo.verdict === "FAIL" || demo.verdict === "ERROR"; + if (verdict && demo.verdict !== verdict) return false; + return true; +} + +function Chip({ + on, + onClick, + children, + testId, +}: { + on: boolean; + onClick: () => void; + children: React.ReactNode; + testId?: string; +}) { + return ( + + ); +} + +export function DemosPage() { + const { factoryId } = useFactoryShell(); + const query = useFactoryDemos(factoryId); + const [params, setParams] = useSearchParams(); + const feature = params.get("feature"); + const verdict = params.get("verdict"); + const play = params.get("play"); + const items = useMemo(() => query.data?.items ?? [], [query.data]); + const features = useMemo(() => { + const seen = new Map(); + for (const d of items) if (d.feature) seen.set(d.feature.ref, d.feature.title); + return [...seen.entries()]; + }, [items]); + const shown = items.filter((d) => matches(d, feature, verdict)); + const set = (key: string, value: string | null) => + setParams( + (prev) => { + const next = new URLSearchParams(prev); + if (value == null) next.delete(key); + else next.set(key, value); + next.delete("play"); + return next; + }, + { replace: true }, + ); + return ( +
      +
      +

      Demos

      + {query.data && ( + + {query.data.total} recorded · newest first + + )} +
      + {items.length > 0 && ( +
      + {features.length > 1 && ( +
      + set("feature", null)} testId="demos-feature-all"> + All features + + {features.map(([ref, title]) => ( + set("feature", feature === ref ? null : ref)} + testId="demos-feature" + > + {title} + + ))} +
      + )} +
      + {VERDICTS.map((v) => ( + set("verdict", v.key)} + testId="demos-verdict" + > + {v.label} + + ))} +
      +
      + )} + {query.isPending ? ( + + ) : query.isError ? ( +

      + The demos could not be read:{" "} + {query.error instanceof Error ? query.error.message : "error"}.{" "} + +

      + ) : !items.length ? ( +
      + + No demos yet. The factory records one for each change it builds. +
      + ) : !shown.length ? ( +

      + No demos match.{" "} + +

      + ) : ( +
        + {shown.map((d) => ( + + ))} +
      + )} +
      + ); +} diff --git a/web/src/pages/factory/DiscoveryPage.test.tsx b/web/src/pages/factory/DiscoveryPage.test.tsx index 64020473fac..2be6db7dc4d 100644 --- a/web/src/pages/factory/DiscoveryPage.test.tsx +++ b/web/src/pages/factory/DiscoveryPage.test.tsx @@ -286,7 +286,7 @@ describe("Discovery on", () => { expect(screen.getByTestId("opportunity-row-O-3")).toHaveAttribute("data-selected", "true"); fireEvent.keyDown(document.body, { key: "g" }); fireEvent.keyDown(document.body, { key: "p" }); - await waitFor(() => expect(location).toBe(`${BASE}/projects`)); + await waitFor(() => expect(location).toBe(BASE)); expect(sent("PATCH", "/opportunities/O-3")).toEqual([]); }); diff --git a/web/src/pages/factory/FactoryLayout.tsx b/web/src/pages/factory/FactoryLayout.tsx index de54b77325c..edc722dd947 100644 --- a/web/src/pages/factory/FactoryLayout.tsx +++ b/web/src/pages/factory/FactoryLayout.tsx @@ -1,330 +1,93 @@ /** * The shell around every factory page (`/factory/:factoryId/*`): it reads the - * live snapshot once, sends old cockpit links to their pages, beats presence, - * and frames the page with the header, the page tabs, the viewer banner, the - * Room dock (from 1280 px) and, on a phone, the bottom tabs. ⌘K opens the - * factory's palette; Share and Add project open from anywhere in it. - * - * Someone who switched to the new cockpit (the server offers it with - * `OMNIGENT_FACTORY_COCKPIT_V2`; each person chooses) gets `CockpitShell` - * instead, and each cockpit sends the other's addresses to its own pages. + * live snapshot once, sends older addresses to the place that shows them now + * (`cockpitTarget`, then the board's old parameters), beats presence, and + * frames the page with the cockpit (`Cockpit`: the sidebar, or a phone's tab + * bar). */ -import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { type ReactNode, useEffect } from "react"; import { ArrowLeftIcon, LockIcon } from "lucide-react"; import { PageScroll } from "@/components/PageScroll"; -import { WIDE_BOARD_QUERY, useMediaQuery } from "@/components/factory/board/boardHooks"; -import { FactoryHeader } from "@/components/factory/board/FactoryHeader"; -import { FactoryCommandPalette } from "@/components/factory/cmdk/FactoryCommandPalette"; -import { useFactoryPaletteHotkey } from "@/components/factory/cmdk/useFactoryPaletteHotkey"; -import { AddProjectDialog } from "@/components/factory/controls/AddProjectDialog"; +import { type CockpitTarget, cockpitTarget } from "@/components/factory/cockpit/cockpitRoutes"; import { FactoryControlsProvider } from "@/components/factory/controls/FactoryControlsContext"; -import { useFactoryServerNowS } from "@/components/factory/factoryClock"; import { FactoryLoadError } from "@/components/factory/FactoryLoadError"; -import { myDecisionCount, splitNeedsYou } from "@/components/factory/overview/needsYouModel"; -import { ShareDialog } from "@/components/factory/people/ShareDialog"; -import { - FactoryShellContext, - type FactoryShellValue, -} from "@/components/factory/shell/FactoryShellContext"; -import { FactoryTabs } from "@/components/factory/shell/FactoryTabs"; -import { - DockResizeHandle, - RoomDock, - RoomSheetButton, - useDockWidth, -} from "@/components/factory/room/RoomDock"; -import { unreadTotal } from "@/components/factory/room/roomModel"; -import { useRoomThreads } from "@/components/factory/room/roomQueries"; -import { useStickyTop } from "@/components/factory/room/useViewportFill"; -import { PhoneTabBar } from "@/components/factory/shell/PhoneTabBar"; +import { EmptyState } from "@/components/factory/project/projectParts"; import { PAGE_EXTRA_TOP, pageExtraBottom } from "@/components/factory/shell/scroll"; -import { useCockpitV2 } from "@/components/factory/shell/cockpitV2"; import { cockpitRedirect, factoryLocation, legacyFactoryRedirect, presenceLocation, } from "@/components/factory/shell/shellRoutes"; -import { ViewerBanner } from "@/components/factory/shell/ViewerBanner"; -import { PAGE_PALETTE_ATTRIBUTE } from "@/hooks/useCommandPaletteHotkey"; +import { Button } from "@/components/ui/button"; import { COCKPIT_MIN_REFRESH_MS, useFactory } from "@/hooks/useFactory"; -import { - mentionNotices, - snapshotNotices, - useFactoryNotifications, -} from "@/hooks/useFactoryNotifications"; import { useFactoryPresence } from "@/hooks/useFactoryPresence"; -import { EmptyState } from "@/components/factory/project/projectParts"; -import { Button } from "@/components/ui/button"; import { useIsMobileViewport } from "@/hooks/useIsMobileViewport"; import type { FactorySnapshot } from "@/lib/factoryApi"; -import { Link, useLocation, useNavigate, useParams, useSearchParams } from "@/lib/routing"; +import { Link, useNavigate, useParams, useSearchParams } from "@/lib/routing"; import { ApiError } from "@/lib/sessionsApi"; -import { CockpitShell } from "./CockpitShell"; - -/** The dock's height once stuck 1rem under the header, ending 1rem above the bottom. */ -const DOCK_STUCK_HEIGHT = - "calc(100dvh - var(--omnigent-header-height) - var(--omnigent-inset-top) - var(--omnigent-inset-bottom) - 2rem)"; +import { Cockpit } from "./Cockpit"; +/** The cockpit's outline while the snapshot loads: the sidebar and a page. */ function ShellSkeleton() { return (
      -
      -
      -
      -
      -
      -
      - {[0, 1, 2, 3].map((i) => ( -
      +
      +
      + {[0, 1, 2, 3, 4].map((i) => ( +
      ))}
      -
      -
      -
      -
      -
      -
      +
      +
      +
      +
      ); } -function Shell({ - snapshot, - clockOffsetS, - updatedAt, - rest, - onTryCockpit, - children, -}: { - snapshot: FactorySnapshot; - clockOffsetS: number; - updatedAt: number; - rest: string; - /** Offers "Try the new cockpit" (the server lets people switch). */ - onTryCockpit?: () => void; - children: ReactNode; -}) { - const [params] = useSearchParams(); - const nowS = useFactoryServerNowS(clockOffsetS); - const wide = useMediaQuery(WIDE_BOARD_QUERY); - const phone = useIsMobileViewport(); - const location = useMemo(() => factoryLocation(rest), [rest]); - const [sharing, setSharing] = useState(false); - const [palette, setPalette] = useState(false); - const [adding, setAdding] = useState<{ title: string | null } | null>(null); - const [dockWidth, setDockWidth] = useDockWidth(); - const dockRef = useRef(null); - const threads = useRoomThreads(snapshot.factory.id).data?.data; - const roomUnread = unreadTotal(threads); - const togglePalette = useCallback(() => setPalette((open) => !open), []); - useFactoryPaletteHotkey(togglePalette); - useFactoryNotifications( - useMemo( - () => [ - ...snapshotNotices(snapshot), - ...mentionNotices(snapshot.factory.id, snapshot.factory.name, threads), - ], - [snapshot, threads], - ), - ); - const split = useMemo(() => splitNeedsYou(snapshot), [snapshot]); - const viewer = snapshot.viewer ?? null; - const shell: FactoryShellValue = useMemo( - () => ({ - factoryId: snapshot.factory.id, - snapshot, - clockOffsetS, - nowS, - updatedAt, - location, - wide, - cockpit: "classic", - openShare: () => setSharing(true), - openPalette: () => setPalette(true), - openAddProject: () => setAdding({ title: null }), - openAddProjectTitled: (title: string | null) => setAdding({ title }), - }), - [snapshot, clockOffsetS, nowS, updatedAt, location, wide], - ); - const dock = wide && location.page !== "room" && location.page !== "settings"; - const dockTop = useStickyTop(dockRef, dock); - const sheet = !wide && !phone && location.page !== "room"; - return ( - -
      - setSharing(true)} - onAddProject={() => setAdding({ title: null })} - /> - {!phone && ( - setPalette(true)} - onTryCockpit={onTryCockpit} - /> - )} - {viewer?.role === "viewer" && } -
      -
      - {children} -
      - {dock && ( - // The page scroller's padding already clears the header, so the dock - // sticks 1rem under it and ends 1rem above the bottom. - - )} -
      -
      - {sheet && ( - - )} - {phone && ( - setSharing(true)} - onPalette={() => setPalette(true)} - onTryCockpit={onTryCockpit} - /> - )} - - setSharing(true)} - onAddProject={() => setAdding({ title: null })} - /> - ` prefills it. - key={adding ? `add:${adding.title ?? ""}` : "add"} - factoryId={snapshot.factory.id} - open={adding != null} - initialTitle={adding?.title ?? null} - onOpenChange={(open) => !open && setAdding(null)} - /> -
      - ); -} - /** * The factory shell; `children` is the routed page. It renders nothing of the - * page until the snapshot is in, and only a redirect for an old link. + * page until the snapshot is in, and only a redirect for an older address. */ export function FactoryLayout({ children }: { children: ReactNode }) { const { factoryId = "", "*": rest = "" } = useParams<{ factoryId: string; "*": string }>(); const [params] = useSearchParams(); const navigate = useNavigate(); const phone = useIsMobileViewport(); - const wide = useMediaQuery(WIDE_BOARD_QUERY); const { data, error, isLoading, updatedAt, clockOffsetS, refetch } = useFactory(factoryId, { minRefreshMs: COCKPIT_MIN_REFRESH_MS, }); - const legacy = legacyFactoryRedirect(factoryId, rest, params, data?.prs); - const cockpit = useCockpitV2(factoryId); - const here = useLocation(); - // The factory page the viewer was on before a Room link (the drawer opens over it). - const from = useRef(null); - const mode = cockpit.on ? "v2" : "classic"; - const other = legacy ? null : cockpitRedirect(factoryId, rest, params, mode, from.current); - // Follow another cockpit's address only once it is known which cockpit this person sees. - const switched = legacy ? null : cockpit.loading || (other && !cockpit.settled) ? "wait" : other; - const target = legacy && legacy !== "wait" ? legacy.to : switched !== "wait" ? switched : null; + const target = factoryTarget(factoryId, rest, params, data?.prs); + const to = target && target !== "wait" ? target.to : null; useEffect(() => { - if (target) navigate(target, { replace: true }); - }, [navigate, target]); - useEffect(() => { - if (factoryLocation(rest).page !== "room") from.current = `${here.pathname}${here.search}`; - }, [rest, here.pathname, here.search]); - const redirecting = Boolean(legacy) || switched != null; + if (to) navigate(to, { replace: true }); + }, [navigate, to]); + const redirecting = target != null; // A 404 means no factory or no access; one after data (a removed person's // refetch) drops the stale page too. const missing = error instanceof ApiError && error.status === 404; useFactoryPresence(factoryId, presenceLocation(factoryLocation(rest)), { enabled: !redirecting && Boolean(data) && !missing, }); - const v2 = cockpit.on; return ( - {/* The new cockpit's switcher lists every factory instead. */} - {!v2 && ( - - - Factory - - )} {error && !missing && ( - {v2 ? ( - cockpit.choose("v1")} - onV2={cockpit.v3 ? () => cockpit.choose("v2") : undefined} - variant={cockpit.v3 ? "v3" : "v2"} - > - {children} - - ) : ( - cockpit.choose("v2") : undefined} - > - {children} - - )} + + {children} + )} ); } + +/** + * Where an address goes before its page renders: an earlier cockpit's page + * to the place that shows it now, an old link's parameters to their page. + */ +export function factoryTarget( + factoryId: string, + rest: string, + params: URLSearchParams, + prs: FactorySnapshot["prs"] | undefined, +): CockpitTarget { + const cockpit = cockpitTarget(factoryId, rest, params, prs); + if (cockpit) return cockpit; + const legacy = legacyFactoryRedirect(factoryId, rest, params, prs); + if (legacy) return legacy; + const page = factoryLocation(rest).page; + if (page === "decisions" || page === "room") return null; + const moved = cockpitRedirect(factoryId, rest, params, "v2", null); + return moved ? { to: moved } : null; +} diff --git a/web/src/pages/factory/FactoryPage.test.tsx b/web/src/pages/factory/FactoryPage.test.tsx deleted file mode 100644 index a4bdb28a27d..00000000000 --- a/web/src/pages/factory/FactoryPage.test.tsx +++ /dev/null @@ -1,472 +0,0 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; -import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - LIVE_RUN_FACTORY_ID, - liveRunSnapshot, - pendingForever, -} from "@/components/factory/__fixtures__/liveRun"; -import { resetFactoryControls } from "@/components/factory/controls/useFactoryControls"; -import { round3Snapshot } from "@/components/factory/shell/__fixtures__/shellFixtures"; -import { factoryQueryKey } from "@/hooks/useFactory"; -import type { FactorySnapshot } from "@/lib/factoryApi"; -import { isMacPlatform } from "@/lib/hotkeys"; -import { FactoryPage } from "./FactoryPage"; -import { type FactoryServer, SNAPSHOT, json, serveFactory } from "./testServer"; - -const authenticatedFetch = vi.fn(); -vi.mock("@/lib/identity", () => ({ - authenticatedFetch: (...args: unknown[]) => authenticatedFetch(...args), -})); -const connected = vi.hoisted(() => ({ value: true })); -vi.mock("@/hooks/useSessionUpdatesConnected", () => ({ - useSessionUpdatesConnected: () => connected.value, -})); - -const FID = LIVE_RUN_FACTORY_ID; -const PR_9630 = "f74d5967c53455139e85ba1b402a5bd4"; - -function setWidth({ wide, phone = false }: { wide: boolean; phone?: boolean }) { - vi.stubGlobal( - "matchMedia", - vi.fn((query: string) => ({ - matches: query.includes("min-width: 1280px") - ? wide - : query.includes("max-width: 767.98px") - ? phone - : false, - media: query, - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - })), - ); -} - -let location = ""; -function LocationProbe() { - const loc = useLocation(); - location = loc.pathname + loc.search + loc.hash; - return null; -} - -function renderAt(path: string, seed?: (client: QueryClient) => void) { - const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - seed?.(client); - const view = render( - - - - - - - - } - /> - - - , - ); - return { client, ...view }; -} - -let server: FactoryServer; -function serve(snapshot: () => FactorySnapshot | Response | Promise = liveRunSnapshot) { - server = serveFactory(authenticatedFetch, { - snapshot: async () => { - const body = await snapshot(); - return body instanceof Response ? body : json(body); - }, - }); -} - -beforeEach(() => { - authenticatedFetch.mockReset(); - connected.value = true; - resetFactoryControls(); - setWidth({ wide: true }); - localStorage.clear(); -}); -afterEach(() => { - vi.useRealTimers(); - vi.unstubAllGlobals(); -}); - -describe("FactoryPage · shell", () => { - it("shows the shell skeleton, never an empty page, while the first read is pending", () => { - serve(() => pendingForever()); - renderAt(""); - expect(screen.getByTestId("factory-loading")).toBeInTheDocument(); - expect(screen.queryByTestId("factory-needs-you")).toBeNull(); - }); - - it("frames the Overview with the header, tabs and the Room dock", async () => { - serve(); - renderAt(""); - const header = await screen.findByTestId("factory-header"); - expect(header).toHaveTextContent("Atlas factory"); - expect(screen.getByTestId("factory-live-dot")).toHaveAttribute("data-state", "live"); - expect(screen.getByTestId("factory-tab-overview")).toHaveAttribute("aria-current", "page"); - expect(screen.getByTestId("factory-needs-you")).toBeInTheDocument(); - expect(screen.getByTestId("projects-table")).toBeInTheDocument(); - expect(screen.getByTestId("overview-heatmap")).toBeInTheDocument(); - expect(screen.getByTestId("overview-live-feed")).toBeInTheDocument(); - expect(screen.getByTestId("factory-health-strip")).toBeInTheDocument(); - const dock = screen.getByTestId("room-dock"); - expect(dock).toHaveAttribute("data-thread", "factory"); - expect(await within(dock).findByTestId("room-message-1")).toHaveTextContent( - "Show the deployed versions in Settings", - ); - fireEvent.mouseDown(within(dock).getByTestId("room-dock-tab-decisions")); - expect(await within(dock).findByTestId("decision-card-D-2")).toBeInTheDocument(); - }); - - it("shows the base, engine mode and definition version only when the server sends them", async () => { - serve(() => round3Snapshot()); - renderAt(""); - await screen.findByTestId("factory-header"); - expect(screen.getByTestId("factory-engine-mode")).toHaveTextContent("assist"); - expect(screen.getByTestId("factory-def-version")).toHaveTextContent("def v7"); - expect(screen.getByTestId("factory-base-line")).toHaveTextContent("app#develop@57ab671"); - expect(screen.getByTestId("factory-base-line")).toHaveAttribute("data-env", "ready"); - expect(screen.getByTestId("factory-presence")).toHaveAttribute("data-count", "2"); - }); - - it("leaves the chips out on an older server", async () => { - serve(); - renderAt(""); - await screen.findByTestId("factory-header"); - expect(screen.queryByTestId("factory-engine-mode")).toBeNull(); - expect(screen.queryByTestId("factory-def-version")).toBeNull(); - expect(screen.queryByTestId("factory-base-line")).toBeNull(); - }); - - it("drops the dock below 1280 px", async () => { - setWidth({ wide: false }); - serve(); - renderAt(""); - await screen.findByTestId("factory-needs-you"); - expect(screen.getByTestId("factory-layout")).toHaveAttribute("data-wide", "false"); - expect(screen.queryByTestId("room-dock")).toBeNull(); - }); - - it("swaps the top tabs for the bottom tabs on a phone", async () => { - setWidth({ wide: false, phone: true }); - serve(); - renderAt(""); - await screen.findByTestId("factory-needs-you"); - expect(screen.queryByTestId("factory-tabs")).toBeNull(); - const bar = screen.getByTestId("factory-phone-tabs"); - expect(within(bar).getByTestId("factory-phone-room")).toHaveTextContent("Room"); - expect(within(bar).getByTestId("factory-phone-needs-you")).toHaveTextContent("Needs you"); - expect(within(bar).getByTestId("factory-phone-projects")).toHaveTextContent("Projects"); - expect(within(bar).getByTestId("factory-phone-more")).toHaveTextContent("More"); - }); - - it("keeps stale data under an error alert when a refresh fails, and retries", async () => { - let fail = true; - serve(() => (fail ? json({ error: { message: "down" } }, 503) : liveRunSnapshot())); - const { client } = renderAt("", (c) => - c.setQueryData(factoryQueryKey(FID), liveRunSnapshot(), { - updatedAt: new Date(2026, 9, 26, 14, 2).getTime(), - }), - ); - await act(async () => { - await client.invalidateQueries({ queryKey: factoryQueryKey(FID) }); - }); - const alert = await screen.findByTestId("factory-load-error"); - expect(alert).toHaveTextContent("Couldn’t refresh this factory"); - expect(screen.getByTestId("factory-needs-you")).toBeInTheDocument(); - fail = false; - fireEvent.click(screen.getByTestId("factory-load-error-retry")); - await waitFor(() => expect(screen.queryByTestId("factory-load-error")).toBeNull()); - }); - - it("says a removed person no longer has access", async () => { - serve(() => json({ error: { message: "not found" } }, 404)); - renderAt(""); - expect(await screen.findByTestId("factory-missing")).toHaveTextContent( - "you no longer have access", - ); - }); - - it("drops the page when a refetch says access was removed", async () => { - let removed = false; - serve(() => (removed ? json({ error: { message: "not found" } }, 404) : liveRunSnapshot())); - const { client } = renderAt(""); - expect(await screen.findByTestId("factory-needs-you")).toBeInTheDocument(); - removed = true; - await act(async () => { - await client.invalidateQueries({ queryKey: factoryQueryKey(FID) }); - }); - expect(await screen.findByTestId("factory-missing")).toHaveTextContent( - "you no longer have access", - ); - expect(screen.queryByTestId("factory-needs-you")).toBeNull(); - expect(screen.queryByTestId("factory-load-error")).toBeNull(); - }); -}); - -describe("FactoryPage · routes and old links", () => { - it.each([ - ["?project=api-version", `/factory/${FID}/projects/api-version`], - ["?project=db-version&decision=D-2", `/factory/${FID}/projects/db-version?decision=D-2`], - ["?review=9630", `/factory/${FID}/prs/${PR_9630}`], - [ - `?review=9630&pr=${PR_9630}&preview=unavailable`, - `/factory/${FID}/prs/${PR_9630}?preview=unavailable`, - ], - [`?pr=${PR_9630}&preview=unavailable`, `/factory/${FID}/prs/${PR_9630}?preview=unavailable`], - ["?project=api-version&review=9630", `/factory/${FID}/prs/${PR_9630}`], - ["?tab=activity", `/factory/${FID}/activity`], - ["?tab=board", `/factory/${FID}`], - ["?tab=board&decision=D-2", `/factory/${FID}?decision=D-2`], - ["/projects/api-version?tab=prs", `/factory/${FID}/projects/api-version#prs`], - ["/projects/api-version?review=9630", `/factory/${FID}/prs/${PR_9630}`], - ])("sends %s to %s", async (from, to) => { - serve(); - renderAt(from); - await waitFor(() => expect(location).toBe(to)); - expect(await screen.findByTestId("factory-shell")).toBeInTheDocument(); - }); - - it("lands an old ?review= link from a PR body on the PR page", async () => { - serve(); - renderAt("?review=9630"); - const page = await screen.findByTestId("pr-page"); - expect(page).toHaveAttribute("data-pr", "9630"); - expect(page).toHaveTextContent("Ready for human merge"); - expect(screen.queryByRole("dialog")).toBeNull(); - }); - - it("explains a stable preview link that landed on the PR page", async () => { - serve(() => { - const snapshot = liveRunSnapshot(); - const pr = snapshot.prs.find((p) => p.number === 9630)!; - pr.preview = { ...pr.preview!, state: "parked" }; - return snapshot; - }); - renderAt(`?review=9630&pr=${PR_9630}&preview=unavailable`); - expect(await screen.findByTestId("review-preview-unavailable")).toHaveTextContent( - "this PR’s factory preview isn’t running (parked)", - ); - }); - - it("names a PR number the factory doesn't track", async () => { - serve(); - renderAt("?review=9999"); - await waitFor(() => expect(location).toBe(`/factory/${FID}/prs/9999`)); - expect(await screen.findByTestId("pr-missing")).toHaveTextContent("9999"); - }); - - it("moves between pages from the tabs", async () => { - serve(); - renderAt(""); - fireEvent.click(await screen.findByTestId("factory-tab-projects")); - expect(await screen.findByTestId("factory-projects-page")).toBeInTheDocument(); - expect(location).toBe(`/factory/${FID}/projects`); - fireEvent.click(screen.getByTestId("factory-tab-activity")); - expect(await screen.findByTestId("activity-list")).toBeInTheDocument(); - fireEvent.click(screen.getByTestId("factory-tab-settings")); - expect(await screen.findByTestId("people-settings")).toBeInTheDocument(); - expect(location).toBe(`/factory/${FID}/settings/people`); - fireEvent.click(screen.getByTestId("factory-tab-room")); - expect(await screen.findByTestId("factory-room-page")).toHaveAttribute( - "data-thread", - "factory", - ); - // The Room page holds the thread itself: no dock beside it. - expect(screen.queryByTestId("room-dock")).toBeNull(); - fireEvent.click(screen.getByTestId("factory-tab-graph")); - expect(await screen.findByTestId("factory-graph-page")).toHaveAttribute("data-kind", "work"); - fireEvent.click(screen.getByTestId("factory-tab-decisions")); - expect(await screen.findByTestId("factory-decisions-page")).toBeInTheDocument(); - expect(location).toBe(`/factory/${FID}/decisions`); - }); - - it("opens a project's page from its table row, never a drawer", async () => { - serve(); - renderAt(""); - fireEvent.click(await screen.findByTestId("projects-row-link-api-version")); - expect(await screen.findByTestId("project-page-api-version")).toBeInTheDocument(); - expect(location).toBe(`/factory/${FID}/projects/api-version`); - expect(screen.queryByRole("dialog")).toBeNull(); - }); - - it("shows the round-2 cards on Projects for a few projects, the table past four", async () => { - serve(); - renderAt("/projects"); - expect(await screen.findByTestId("factory-projects-page")).toHaveAttribute( - "data-view", - "cards", - ); - fireEvent.click(await screen.findByTestId("project-open-api-version")); - expect(await screen.findByTestId("project-page-api-version")).toBeInTheDocument(); - }); - - it("defaults Projects to the table for the round-3 factory", async () => { - serve(() => round3Snapshot()); - renderAt("/projects"); - expect(await screen.findByTestId("factory-projects-page")).toHaveAttribute( - "data-view", - "table", - ); - expect(screen.getByTestId("projects-envs")).toHaveTextContent("base-env"); - expect(screen.getByTestId("projects-closed")).toHaveTextContent("Closed (1)"); - }); - - it("says when a factory path has no page", async () => { - serve(); - renderAt("/nowhere"); - expect(await screen.findByTestId("factory-unknown-page")).toBeInTheDocument(); - }); -}); - -describe("FactoryPage · presence, ⌘K and sharing", () => { - it("beats presence with the page the viewer is on, and moves with them", async () => { - serve(); - const { unmount } = renderAt(""); - await screen.findByTestId("factory-needs-you"); - await waitFor(() => expect(server.beats).toContainEqual({ location: "overview", idle: false })); - fireEvent.click(screen.getByTestId("projects-row-link-db-version")); - await waitFor(() => - expect(server.beats).toContainEqual({ location: "project:db-version", idle: false }), - ); - unmount(); - await waitFor(() => - expect(server.beats).toContainEqual({ location: null, idle: false, leave: true }), - ); - }); - - it("opens the palette on ⌘K, jumps to a project and runs a slash command", async () => { - serve(); - renderAt(""); - await screen.findByTestId("factory-needs-you"); - const mac = isMacPlatform(); - fireEvent.keyDown(window, { key: "k", metaKey: mac, ctrlKey: !mac }); - const input = await screen.findByTestId("factory-palette-input"); - fireEvent.change(input, { target: { value: "db-version" } }); - fireEvent.click(await screen.findByTestId("factory-palette-item-project:db-version")); - expect(await screen.findByTestId("project-page-db-version")).toBeInTheDocument(); - fireEvent.click(screen.getByTestId("factory-palette-open")); - fireEvent.change(await screen.findByTestId("factory-palette-input"), { - target: { value: "/pause #db-version" }, - }); - fireEvent.click(await screen.findByTestId("factory-palette-run")); - await waitFor(() => - expect(server.controls).toEqual([{ action: "pause", target: "project:db-version" }]), - ); - }); - - it("opens Share from the header and invites through the people API", async () => { - serve(() => round3Snapshot()); - renderAt(""); - fireEvent.click(await screen.findByTestId("factory-share")); - const dialog = await screen.findByTestId("share-dialog"); - expect(await within(dialog).findByTestId("people-row-mia@example.com")).toBeInTheDocument(); - expect(within(dialog).getByTestId("share-credential-line")).toHaveTextContent( - "Agents act as lena@example.com’s GitHub & Databricks", - ); - }); - - it("disables every header lever for a viewer, with Request access", async () => { - serve(() => round3Snapshot("viewer")); - renderAt(""); - expect(await screen.findByTestId("factory-viewer-banner")).toBeInTheDocument(); - expect(screen.getByTestId("factory-add-project")).toBeDisabled(); - expect(screen.getByTestId("factory-pause-all")).toBeDisabled(); - expect(screen.getByTestId("factory-request-access")).toBeEnabled(); - }); - - it("highlights ?decision in the dock", async () => { - serve(); - renderAt("?decision=D-2"); - const dock = await screen.findByTestId("room-dock"); - expect(within(dock).getByTestId("decision-card-D-2")).toHaveAttribute( - "data-highlighted", - "true", - ); - }); - - it("reads the snapshot once for the whole shell", async () => { - serve(); - renderAt("/projects/api-version"); - await screen.findByTestId("project-page-api-version"); - expect(server.calls.filter((c) => c.method === "GET" && c.path === SNAPSHOT)).toHaveLength(1); - }); -}); - -describe("FactoryPage · the Room dock", () => { - it("follows the page: #slug on a project page, !n on a PR page, the last thread elsewhere", async () => { - serve(); - renderAt("/projects/db-version"); - const dock = await screen.findByTestId("room-dock"); - expect(dock).toHaveAttribute("data-thread", "project:db-version"); - expect(within(dock).getByTestId("room-thread-title")).toHaveTextContent("#db-version"); - fireEvent.click(await screen.findByTestId("factory-tab-projects")); - await waitFor(() => - expect(screen.getByTestId("room-dock")).toHaveAttribute("data-thread", "project:db-version"), - ); - fireEvent.click(screen.getByTestId("factory-tab-overview")); - await screen.findByTestId("factory-needs-you"); - // Off project and PR pages the dock keeps the last thread it showed. - expect(screen.getByTestId("room-dock")).toHaveAttribute("data-thread", "project:db-version"); - }); - - it("binds a PR page to its !n thread", async () => { - serve(); - renderAt(`/prs/${PR_9630}`); - const dock = await screen.findByTestId("room-dock"); - expect(dock).toHaveAttribute("data-thread", `pr:${PR_9630}`); - expect(within(dock).getByTestId("room-thread-title")).toHaveTextContent("!9630"); - }); - - it("keeps its width from the last drag", async () => { - localStorage.setItem("omnigent.factory.roomDock.width", "420"); - serve(); - renderAt(""); - const layout = await screen.findByTestId("factory-layout"); - expect(layout.style.gridTemplateColumns).toBe("minmax(0,1fr) 420px"); - fireEvent.keyDown(screen.getByTestId("room-dock-resize"), { key: "ArrowLeft" }); - expect(layout.style.gridTemplateColumns).toBe("minmax(0,1fr) 436px"); - expect(localStorage.getItem("omnigent.factory.roomDock.width")).toBe("436"); - }); - - it("is a bottom sheet with the unread count below 1280 px", async () => { - setWidth({ wide: false }); - serve(() => round3Snapshot()); - renderAt(""); - const button = await screen.findByTestId("room-sheet-open"); - expect(await screen.findByTestId("room-sheet-unread")).toHaveTextContent("6"); - expect(screen.queryByTestId("room-dock")).toBeNull(); - fireEvent.click(button); - expect(await screen.findByTestId("room-sheet")).toBeInTheDocument(); - expect(screen.getByTestId("room-dock")).toHaveAttribute("data-thread", "factory"); - }); - - it("posts from the dock as the viewer", async () => { - serve(() => round3Snapshot()); - renderAt(""); - const dock = await screen.findByTestId("room-dock"); - const input = await within(dock).findByTestId("room-composer-input"); - expect(within(dock).getByTestId("room-composer-identity")).toHaveTextContent( - "Posting as Lena · owner", - ); - fireEvent.change(input, { - target: { value: "@atlas add a project for a /health badge on settings" }, - }); - fireEvent.click(within(dock).getByTestId("room-composer-send")); - await waitFor(() => - expect(server.posts).toEqual([ - { - thread: "factory", - body: "@atlas add a project for a /health badge on settings", - mentions: ["@atlas"], - }, - ]), - ); - }); -}); diff --git a/web/src/pages/factory/FactoryPage.tsx b/web/src/pages/factory/FactoryPage.tsx index cdecf395ff8..0f9ca7c1e04 100644 --- a/web/src/pages/factory/FactoryPage.tsx +++ b/web/src/pages/factory/FactoryPage.tsx @@ -1,15 +1,10 @@ /** - * Everything under `/factory/:factoryId/*`: the factory shell (header, tabs, - * dock, presence) around one page. Overview at the root, then Projects, a - * project, a PR, Agents and an agent, the Timeline, Alerts and an incident, - * Activity, the Room, Decisions, the Graph, Insights, Improvements and a - * change, and Settings. Old cockpit links - * (`?project=`, `?review=`, `?pr=…&preview=unavailable`, `?tab=activity`) - * are sent to their pages by the shell. - * - * The new cockpit's places (Discovery, Teams, Steer) and Debug live here too; - * the shell sends each cockpit's addresses to the other's pages. Cockpit v3's - * node pages are `n/` and `n//`. + * Everything under `/factory/:factoryId/*`: the cockpit (sidebar, presence) + * around one page. Its places are Home at the root, Demos, Chats + * (`chats/` one conversation), Decisions and the node pages + * (`n/`, `n//`). Older addresses (a project or PR page, an + * initiative, the Room, `?review=`) are sent to the place that shows them now + * by the shell; the earlier cockpits' other pages stay reachable from Debug. */ import { CompassIcon } from "lucide-react"; @@ -22,23 +17,21 @@ import { Link } from "@/lib/routing"; import { AgentsPage } from "./AgentsPage"; import { AlertsPage } from "./AlertsPage"; import { DebugPage } from "./DebugPage"; -import { DecisionsPage } from "./DecisionsPage"; +import { CockpitDecisions } from "./CockpitDecisions"; import { DiscoveryPage } from "./DiscoveryPage"; import { FactoryLayout } from "./FactoryLayout"; import { GraphPage } from "./GraphPage"; import { ImprovementsPage } from "./ImprovementsPage"; import { IncidentPage } from "./IncidentPage"; import { InsightsPage } from "./InsightsPage"; -import { PrPage } from "./PrPage"; import { ProposalPage } from "./ProposalPage"; -import { ProjectPage } from "./ProjectPage"; -import { ProjectsPage } from "./ProjectsPage"; -import { RoomPage } from "./RoomPage"; import { SettingsPage } from "./SettingsPage"; import { SteerPage } from "./SteerPage"; import { TimelinePage } from "./TimelinePage"; -import { AgentRoute, HomeRoute, ProjectTeamRoute, TeamsRoute } from "./cockpitV2Routes"; -import { InitiativePage } from "./InitiativePage"; +import { AgentRoute, ProjectTeamRoute, TeamsRoute } from "./cockpitV2Routes"; +import { ChatsPage } from "./ChatsPage"; +import { CockpitHome } from "./CockpitHome"; +import { DemosPage } from "./DemosPage"; import { NodeRoute } from "./NodeRoute"; /** `/activity`: every factory event, filterable by project, member and kind. */ @@ -81,13 +74,11 @@ export function FactoryPage() { return ( - } /> - } /> - } /> + } /> + } /> + } /> + } /> } /> - } /> - } /> - } /> } /> } /> } /> @@ -96,9 +87,7 @@ export function FactoryPage() { } /> } /> } /> - } /> - } /> - } /> + } /> } /> } /> } /> diff --git a/web/src/pages/factory/HomePage.test.tsx b/web/src/pages/factory/HomePage.test.tsx deleted file mode 100644 index c4304d6a195..00000000000 --- a/web/src/pages/factory/HomePage.test.tsx +++ /dev/null @@ -1,524 +0,0 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; -import axe from "axe-core"; -import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { LIVE_RUN_FACTORY_ID, preflightOk } from "@/components/factory/__fixtures__/liveRun"; -import { round3IssueList } from "@/components/factory/agents/__fixtures__/opsFixtures"; -import { resetFactoryControls } from "@/components/factory/controls/useFactoryControls"; -import { asksOf } from "@/components/factory/home/__fixtures__/asksFixtures"; -import { - SAMPLE_DISCOVERY, - SAMPLE_OPPORTUNITIES, - discoveryDefinition, -} from "@/components/factory/home/__fixtures__/homeFixtures"; -import { ANSWER_UNDO_MS } from "@/components/factory/home/useDeferredAnswers"; -import { definitionResponse } from "@/components/factory/insights/__fixtures__/learnFixtures"; -import { notifyMe } from "@/components/factory/notify/__fixtures__/notifyFixtures"; -import { statusSummaryOf } from "@/components/factory/overview/__fixtures__/nowFixtures"; -import { roomProposals } from "@/components/factory/room/__fixtures__/roomFixtures"; -import { round3Snapshot } from "@/components/factory/shell/__fixtures__/shellFixtures"; -import { resetCockpitChoiceCache } from "@/components/factory/shell/cockpitV2"; -import type { AsksResponse } from "@/lib/factoryAsksApi"; -import type { DefinitionResponse, FactorySnapshot, PrView } from "@/lib/factoryApi"; -import { FactoryPage } from "./FactoryPage"; -import { type FactoryServer, SNAPSHOT, json, serveFactory } from "./testServer"; - -const authenticatedFetch = vi.fn(); -vi.mock("@/lib/identity", () => ({ - authenticatedFetch: (...args: unknown[]) => authenticatedFetch(...args), -})); -vi.mock("@/hooks/useSessionUpdatesConnected", () => ({ - useSessionUpdatesConnected: () => true, -})); - -const FID = LIVE_RUN_FACTORY_ID; -/** Round 3's D-2, which waits on the operator (mia). */ -const D2 = "a9d44647df3050199c1577ba21f16b67"; -/** Round 3's D-31, which waits on the operators. */ -const D31 = "ae1fb5c75b755a4dbc645077510d63ef"; -const BASE = `/factory/${FID}`; - -let location = ""; -function LocationProbe() { - const loc = useLocation(); - location = loc.pathname + loc.search; - return null; -} - -function renderAt(path: string) { - const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - return render( - - - - - - - - } - /> - - - , - ); -} - -let server: FactoryServer; - -/** (sample) Ten more ready PRs, each on its own project, so the merge row caps its list. */ -function withReadyPrs(snapshot: FactorySnapshot, n: number): FactorySnapshot { - const template = snapshot.prs.find((p) => p.ready)!; - const prs: PrView[] = Array.from({ length: n }, (_, i) => ({ - ...template, - id: `${(i + 1).toString(16).padStart(32, "0")}`, - number: 9700 + i, - title: `Change ${i} (sample)`, - project_slug: `sample-${i}`, - ready_since: snapshot.server_time - (n - i) * 600, - })); - return { ...snapshot, prs: [...snapshot.prs, ...prs] }; -} - -/** The server offers the new cockpit, this person chose it, and Home's reads. */ -function serve({ - snapshot = round3Snapshot(), - asks, - answer, - definition = { ...definitionResponse(), discovery_server: true }, - extra = {}, -}: { - snapshot?: FactorySnapshot; - /** The asks read; the fixture builder over `snapshot` by default. */ - asks?: () => Response; - answer?: () => Response; - /** The definition read: discovery off in the factory, on this server, by default. */ - definition?: DefinitionResponse; - extra?: Record Response | Promise>; -} = {}) { - const built: AsksResponse = asksOf(snapshot, { - incidents: round3IssueList(), - proposals: roomProposals(), - }); - server = serveFactory(authenticatedFetch, { - snapshot: () => json(snapshot), - overrides: { - "GET /v1/factory/preflight": () => json({ ...preflightOk(), cockpit_v2: true }), - [`GET ${SNAPSHOT}/notify/me/ui`]: () => json({ cockpit: "v2" }), - [`GET ${SNAPSHOT}/notify/me`]: () => json(notifyMe()), - [`GET ${SNAPSHOT}/definition`]: () => json(definition), - [`GET ${SNAPSHOT}/asks`]: asks ?? (() => json(built)), - [`POST /v1/factory/decisions/${D2}/answer`]: answer ?? (() => json({ decision: {} })), - [`POST /v1/factory/decisions/${D31}/answer`]: () => json({ decision: {} }), - ...extra, - }, - }); - return built; -} - -function answers(): unknown[] { - return server.calls.filter((c) => c.method === "POST" && c.path.endsWith("/answer")); -} - -function reads(suffix: string): unknown[] { - return server.calls.filter((c) => c.method === "GET" && c.path.split("?")[0]!.endsWith(suffix)); -} - -beforeEach(() => { - authenticatedFetch.mockReset(); - resetFactoryControls(); - localStorage.clear(); - resetCockpitChoiceCache(); - vi.stubGlobal( - "matchMedia", - vi.fn((query: string) => ({ - matches: query.includes("min-width: 1280px"), - media: query, - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - })), - ); -}); -afterEach(() => { - vi.useRealTimers(); - vi.unstubAllGlobals(); -}); - -describe("Home", () => { - it("is the new cockpit's front page: a headline from facts, the loop, projects and Needs you", async () => { - const built = serve(); - renderAt(""); - - expect(await screen.findByTestId("factory-home")).toBeInTheDocument(); - const needs = await screen.findByTestId("home-needs"); - expect(within(needs).getByTestId("home-needs-count")).toHaveTextContent( - String(built.counts.total), - ); - expect(screen.getByTestId("home-headline")).toHaveTextContent( - `${built.counts.total} things need you.`, - ); - // Discovery is off here: the loop shows Build and Measure only, with a way to turn it on. - const loop = screen.getByLabelText("How the factory works right now"); - expect(loop).toHaveAttribute("data-discovery", "off"); - expect(within(loop).getByText("Turn on discovery")).toBeInTheDocument(); - expect(screen.getByTestId("home-projects")).toBeInTheDocument(); - expect(screen.queryByTestId("factory-overview")).toBeNull(); - }); - - it("answers a question in place with Undo, and sends it only once Undo has passed", async () => { - serve({ snapshot: round3Snapshot("operator") }); - renderAt(""); - const row = await screen.findByTestId("need-D-2"); - vi.useFakeTimers({ shouldAdvanceTime: true }); - - fireEvent.click(within(row).getByTestId("need-D-2-option-a")); - - // The row leaves at once; the answer waits for Undo. - await waitFor(() => expect(screen.queryByTestId("need-D-2")).toBeNull()); - const toast = await screen.findByTestId("factory-toast"); - expect(toast).toHaveTextContent("goes out in a few seconds"); - fireEvent.click(within(toast).getByRole("button", { name: "Undo" })); - expect(await screen.findByTestId("need-D-2")).toBeInTheDocument(); - await act(async () => { - vi.advanceTimersByTime(ANSWER_UNDO_MS + 100); - }); - expect(answers()).toEqual([]); - - // Answered again, and left alone: it goes out after the Undo window. - fireEvent.click(within(screen.getByTestId("need-D-2")).getByTestId("need-D-2-option-a")); - await act(async () => { - vi.advanceTimersByTime(ANSWER_UNDO_MS + 100); - }); - await waitFor(() => - expect(answers()).toEqual([ - { - method: "POST", - path: `/v1/factory/decisions/${D2}/answer`, - body: { option_id: "a" }, - }, - ]), - ); - }); - - it("leaves a held answer out of the top bar's count too, until Undo brings it back", async () => { - const snapshot = round3Snapshot("operator"); - const built = asksOf(snapshot, { incidents: round3IssueList(), proposals: roomProposals() }); - const summary = statusSummaryOf(snapshot, { incidents: round3IssueList() }); - const total = built.counts.total; - serve({ - snapshot, - extra: { - // The server counts the rows of the same list Home reads. - [`GET ${SNAPSHOT}/status-summary`]: () => - json({ ...summary, counts: { ...summary.counts, needs_you: total } }), - }, - }); - renderAt(""); - const badge = () => screen.getByTestId("cockpit-tab-home-count"); - const count = () => within(screen.getByTestId("home-needs")).getByTestId("home-needs-count"); - await screen.findByTestId("need-D-2"); - await waitFor(() => expect(badge()).toHaveTextContent(String(total))); - expect(count()).toHaveTextContent(String(total)); - vi.useFakeTimers({ shouldAdvanceTime: true }); - - fireEvent.click(screen.getByTestId("need-D-2-option-a")); - - // Held for Undo: the row left Home's list, and the top bar counts as Home does. - await waitFor(() => expect(screen.queryByTestId("need-D-2")).toBeNull()); - expect(count()).toHaveTextContent(String(total - 1)); - expect(badge()).toHaveTextContent(String(total - 1)); - expect(badge()).toHaveAccessibleName(`${total - 1} need${total - 1 === 1 ? "s" : ""} you`); - fireEvent.click( - within(await screen.findByTestId("factory-toast")).getByRole("button", { name: "Undo" }), - ); - - expect(await screen.findByTestId("need-D-2")).toBeInTheDocument(); - expect(count()).toHaveTextContent(String(total)); - expect(badge()).toHaveTextContent(String(total)); - expect(answers()).toEqual([]); - }); - - it("keeps the top bar and Home agreed once the answer is sent and leaves the list", async () => { - const snapshot = round3Snapshot("operator"); - const built = asksOf(snapshot, { incidents: round3IssueList(), proposals: roomProposals() }); - const summary = statusSummaryOf(snapshot, { incidents: round3IssueList() }); - const total = built.counts.total; - let sent = false; - const left = () => ({ - ...built, - asks: built.asks.filter((a) => a.id !== "D-2"), - counts: { ...built.counts, total: total - 1 }, - }); - serve({ - snapshot, - asks: () => json(sent ? left() : built), - answer: () => { - sent = true; - return json({ decision: {} }); - }, - extra: { - [`GET ${SNAPSHOT}/status-summary`]: () => - json({ ...summary, counts: { ...summary.counts, needs_you: sent ? total - 1 : total } }), - }, - }); - renderAt(""); - const badge = () => screen.getByTestId("cockpit-tab-home-count"); - const count = () => within(screen.getByTestId("home-needs")).getByTestId("home-needs-count"); - await screen.findByTestId("need-D-2"); - await waitFor(() => expect(badge()).toHaveTextContent(String(total))); - vi.useFakeTimers({ shouldAdvanceTime: true }); - - fireEvent.click(screen.getByTestId("need-D-2-option-a")); - await act(async () => { - vi.advanceTimersByTime(ANSWER_UNDO_MS + 100); - }); - - // Sent; the server's next reads no longer list it: still one count, never one off. - await waitFor(() => expect(answers()).toHaveLength(1)); - await waitFor(() => expect(reads("/asks").length).toBeGreaterThan(1)); - await waitFor(() => expect(badge()).toHaveTextContent(String(total - 1))); - expect(count()).toHaveTextContent(String(total - 1)); - expect(screen.queryByTestId("need-D-2")).toBeNull(); - }); - - it("counts from the list when its read after a send lands before the summary's", async () => { - const snapshot = round3Snapshot("operator"); - const built = asksOf(snapshot, { incidents: round3IssueList(), proposals: roomProposals() }); - const summary = statusSummaryOf(snapshot, { incidents: round3IssueList() }); - const total = built.counts.total; - let sent = false; - const gate: { release: (() => void) | null } = { release: null }; - const later = built.generated_at + 5; - serve({ - snapshot, - asks: () => - json( - sent - ? { ...built, generated_at: later, asks: built.asks.filter((a) => a.id !== "D-2") } - : built, - ), - answer: () => { - sent = true; - return json({ decision: {} }); - }, - extra: { - // After the send the summary's read is held back: the list's read lands first. - [`GET ${SNAPSHOT}/status-summary`]: () => { - const counted = (needs: number, at: number) => - json({ ...summary, generated_at: at, counts: { ...summary.counts, needs_you: needs } }); - if (!sent) return counted(total, built.generated_at); - return new Promise((resolve) => { - gate.release = () => resolve(counted(total - 1, later)); - }); - }, - }, - }); - renderAt(""); - const badge = () => screen.getByTestId("cockpit-tab-home-count"); - const count = () => within(screen.getByTestId("home-needs")).getByTestId("home-needs-count"); - await screen.findByTestId("need-D-2"); - await waitFor(() => expect(badge()).toHaveTextContent(String(total))); - vi.useFakeTimers({ shouldAdvanceTime: true }); - - fireEvent.click(screen.getByTestId("need-D-2-option-a")); - await act(async () => { - vi.advanceTimersByTime(ANSWER_UNDO_MS + 100); - }); - await waitFor(() => expect(answers()).toHaveLength(1)); - await waitFor(() => expect(gate.release).not.toBeNull()); - await waitFor(() => expect(reads("/asks").length).toBeGreaterThan(1)); - - // The summary's read is still out, and nothing is held any more: the newer list counts. - await waitFor(() => expect(count()).toHaveTextContent(String(total - 1))); - expect(screen.queryByTestId("need-D-2")).toBeNull(); - expect({ badge: badge().textContent, home: count().textContent }).toEqual({ - badge: String(total - 1), - home: String(total - 1), - }); - await act(async () => { - gate.release?.(); - }); - await waitFor(() => expect(badge()).toHaveTextContent(String(total - 1))); - }); - - it("keeps Undo for a second answer while the first one goes out", async () => { - serve({ snapshot: round3Snapshot("operator") }); - renderAt(""); - await screen.findByTestId("need-D-31"); - vi.useFakeTimers({ shouldAdvanceTime: true }); - - fireEvent.click(screen.getByTestId("need-D-2-option-a")); - await act(async () => { - vi.advanceTimersByTime(3_000); - }); - fireEvent.click(screen.getByTestId("need-D-31-option-raise")); - expect(await screen.findByTestId("factory-toast")).toHaveTextContent( - "2 answers go out in a few seconds. Undo takes back D-31.", - ); - - // D-2's time is up: it goes out, and the toast still offers Undo for D-31. - await act(async () => { - vi.advanceTimersByTime(ANSWER_UNDO_MS - 3_000 + 100); - }); - await waitFor(() => expect(answers()).toHaveLength(1)); - const toast = screen.getByTestId("factory-toast"); - expect(toast).toHaveTextContent("your answer to D-31 goes out in a few seconds."); - fireEvent.click(within(toast).getByRole("button", { name: "Undo" })); - - expect(await screen.findByTestId("need-D-31")).toBeInTheDocument(); - await act(async () => { - vi.advanceTimersByTime(ANSWER_UNDO_MS * 2); - }); - expect(answers()).toEqual([ - { method: "POST", path: `/v1/factory/decisions/${D2}/answer`, body: { option_id: "a" } }, - ]); - }); - - it("holds an answer while the pointer is on its toast, and sends it once the tab is hidden", async () => { - serve({ snapshot: round3Snapshot("operator") }); - renderAt(""); - await screen.findByTestId("need-D-2"); - vi.useFakeTimers({ shouldAdvanceTime: true }); - - fireEvent.click(screen.getByTestId("need-D-2-option-a")); - const toast = await screen.findByTestId("factory-toast"); - fireEvent.pointerEnter(toast); - await act(async () => { - vi.advanceTimersByTime(ANSWER_UNDO_MS * 3); - }); - // Held open: nothing went out, and Undo still works later. - expect(answers()).toEqual([]); - fireEvent.pointerLeave(toast); - - // A phone switching apps hides the tab: what is held goes out at once. - const visibility = vi.spyOn(document, "visibilityState", "get").mockReturnValue("hidden"); - act(() => { - document.dispatchEvent(new Event("visibilitychange")); - }); - visibility.mockRestore(); - await waitFor(() => expect(answers()).toHaveLength(1)); - }); - - it("stays dark while this server has discovery off, whatever the definition says", async () => { - serve({ definition: discoveryDefinition({ goal: true, server: false }) }); - renderAt(""); - - await screen.findByTestId("home-needs-rows"); - const loop = screen.getByLabelText("How the factory works right now"); - expect(loop).toHaveAttribute("data-discovery", "off"); - expect(within(loop).queryByText("Turn on discovery")).toBeNull(); - expect(screen.queryByTestId("home-checklist")).toBeNull(); - expect(screen.queryByTestId("home-users")).toBeNull(); - expect(reads("/discovery")).toEqual([]); - expect(reads("/opportunities")).toEqual([]); - }); - - it("offers the hunch box to people who may write, once the server answers discovery", async () => { - const extra = { - [`GET ${SNAPSHOT}/discovery`]: () => json(SAMPLE_DISCOVERY), - [`GET ${SNAPSHOT}/opportunities`]: () => json(SAMPLE_OPPORTUNITIES), - }; - serve({ definition: discoveryDefinition({ goal: true }), extra }); - const { unmount } = renderAt(""); - - expect(await screen.findByTestId("home-hunch")).toBeInTheDocument(); - unmount(); - - serve({ snapshot: round3Snapshot("viewer"), definition: discoveryDefinition(), extra }); - renderAt(""); - expect(await screen.findByTestId("home-users")).toBeInTheDocument(); - expect(screen.queryByTestId("home-hunch")).toBeNull(); - }); - - it("folds every ready PR into one merge row, lists five, and opens the merge panel", async () => { - serve({ snapshot: withReadyPrs(round3Snapshot(), 10) }); - renderAt(""); - - const row = await screen.findByTestId("need-merge"); - expect(row).toHaveTextContent("Merge 11 ready pull requests"); - expect(within(row).getAllByRole("listitem")).toHaveLength(6); // 5 PRs and "and 6 more" - expect(row).toHaveTextContent("and 6 more"); - fireEvent.click(within(row).getByTestId("need-merge-primary")); - - await waitFor(() => expect(location).toBe(`${BASE}?peek=merge`)); - const peek = await screen.findByTestId("merge-peek"); - expect(within(peek).getAllByRole("listitem")).toHaveLength(11); - expect(peek).toHaveTextContent("Agents never merge."); - }); - - it("shows five rows, then and N more", async () => { - const snapshot = round3Snapshot(); - const decisions = Array.from({ length: 8 }, (_, i) => ({ - ...snapshot.decisions[0]!, - id: `dx${i}`, - ref: `D-${90 + i}`, - number: 90 + i, - assignee: "operators", - claimed_by: null, - })); - serve({ snapshot: { ...snapshot, decisions } }); - renderAt(""); - - const rows = () => screen.getByTestId("home-needs-rows").querySelectorAll(":scope > li"); - await screen.findByTestId("home-needs-rows"); - expect(rows()).toHaveLength(5); - const more = screen.getByTestId("home-needs-more"); - // 8 questions, the merge row and the stopped lead's incident (its decision is gone). - expect(more).toHaveTextContent("and 5 more"); - fireEvent.click(more); - expect(more).toHaveAttribute("aria-expanded", "true"); - expect(rows()).toHaveLength(10); - }); - - it("says calmly that nothing needs you, and what the factory handled itself", async () => { - const empty: AsksResponse = { - factory_id: FID, - generated_at: 0, - asks: [], - handled: ["Dot is stuck (sample); it clears once the agent moves again."], - counts: { total: 0, mine: 0, others: 0, in_steer: 0, folded: 0 }, - }; - serve({ asks: () => json(empty) }); - renderAt(""); - - expect(await screen.findByTestId("home-needs-empty")).toHaveTextContent("Nothing needs you."); - expect(screen.getByTestId("home-handled")).toHaveTextContent( - "Handled by the factory. Dot is stuck (sample)", - ); - expect(screen.getByTestId("home-headline")).toHaveTextContent(/^Nothing needs you\./); - }); - - it("keeps today's Now card while the server has no asks route", async () => { - serve({ asks: () => json({ error: { message: "not found" } }, 404) }); - renderAt(""); - - expect(await screen.findByTestId("factory-now")).toBeInTheDocument(); - expect(screen.queryByTestId("home-needs")).toBeNull(); - }); - - it("opens an agent's panel from a ring on a project row", async () => { - serve(); - renderAt(""); - const projects = await screen.findByTestId("home-projects"); - const ring = within(projects).getAllByRole("button")[0]!; - - fireEvent.click(ring); - - await waitFor(() => expect(location).toMatch(/\?peek=agent%3A|\?peek=agent:/)); - expect(await screen.findByTestId("peek")).toBeInTheDocument(); - }); - - it("has no axe violations", async () => { - serve(); - renderAt(""); - const home = await screen.findByTestId("factory-home"); - await screen.findByTestId("home-needs-rows"); - const results = await axe.run(home, { - rules: { "color-contrast": { enabled: false }, region: { enabled: false } }, - }); - expect(results.violations.map((v) => `${v.id}: ${v.help}`)).toEqual([]); - }); -}); diff --git a/web/src/pages/factory/InitiativePage.test.tsx b/web/src/pages/factory/InitiativePage.test.tsx deleted file mode 100644 index ab50f7e6ca0..00000000000 --- a/web/src/pages/factory/InitiativePage.test.tsx +++ /dev/null @@ -1,186 +0,0 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; -import { MemoryRouter, Route, Routes } from "react-router-dom"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { LIVE_RUN_FACTORY_ID, preflightOk } from "@/components/factory/__fixtures__/liveRun"; -import { round3IssueList } from "@/components/factory/agents/__fixtures__/opsFixtures"; -import { resetFactoryControls } from "@/components/factory/controls/useFactoryControls"; -import { asksOf } from "@/components/factory/home/__fixtures__/asksFixtures"; -import { - sampleInitiativeLine, - sampleInitiativeView, -} from "@/components/factory/initiatives/__fixtures__/initiativeFixtures"; -import { definitionResponse } from "@/components/factory/insights/__fixtures__/learnFixtures"; -import { notifyMe } from "@/components/factory/notify/__fixtures__/notifyFixtures"; -import { roomProposals } from "@/components/factory/room/__fixtures__/roomFixtures"; -import { round3Snapshot } from "@/components/factory/shell/__fixtures__/shellFixtures"; -import { resetCockpitChoiceCache } from "@/components/factory/shell/cockpitV2"; -import type { InitiativeView } from "@/lib/factoryInitiativesApi"; -import { FactoryPage } from "./FactoryPage"; -import { initiativeRef } from "./InitiativePage"; -import { type FactoryServer, SNAPSHOT, json, serveFactory } from "./testServer"; - -const authenticatedFetch = vi.fn(); -vi.mock("@/lib/identity", () => ({ - authenticatedFetch: (...args: unknown[]) => authenticatedFetch(...args), -})); -vi.mock("@/hooks/useSessionUpdatesConnected", () => ({ - useSessionUpdatesConnected: () => true, -})); - -const BASE = `/factory/${LIVE_RUN_FACTORY_ID}`; -const PAGE = `${SNAPSHOT}/initiatives/I-3`; - -function renderAt(path: string) { - const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - return render( - - - - } /> - - - , - ); -} - -let server: FactoryServer; - -function serve(view: InitiativeView, ran?: InitiativeView) { - const snapshot = round3Snapshot(); - const asks = asksOf(snapshot, { incidents: round3IssueList(), proposals: roomProposals() }); - server = serveFactory(authenticatedFetch, { - snapshot: () => json(snapshot), - overrides: { - "GET /v1/factory/preflight": () => json({ ...preflightOk(), cockpit_v2: true }), - [`GET ${SNAPSHOT}/notify/me/ui`]: () => json({ cockpit: "v2" }), - [`GET ${SNAPSHOT}/notify/me`]: () => json(notifyMe()), - [`GET ${SNAPSHOT}/definition`]: () => - json({ ...definitionResponse(), discovery_server: true }), - [`GET ${SNAPSHOT}/asks`]: () => json({ ...asks, initiatives: [sampleInitiativeLine(view)] }), - [`GET ${PAGE}`]: () => json(view), - [`POST ${PAGE}/acceptance`]: () => json(ran ?? view), - [`GET ${SNAPSHOT}/initiatives/I-9`]: () => - json({ error: { code: "unknown_initiative", message: "no initiative" } }, 404), - }, - }); -} - -beforeEach(() => { - authenticatedFetch.mockReset(); - resetFactoryControls(); - resetCockpitChoiceCache(); -}); - -afterEach(() => { - vi.useRealTimers(); -}); - -describe("initiatives", () => { - it("reads 3 and i-3 as I-3", () => { - expect(initiativeRef("3")).toBe("I-3"); - expect(initiativeRef("i-3")).toBe("I-3"); - expect(initiativeRef("I-3")).toBe("I-3"); - expect(initiativeRef("x")).toBe("x"); - }); - - it("puts a card per initiative on Home, above the projects", async () => { - serve(sampleInitiativeView("failing")); - renderAt(""); - - const card = await screen.findByTestId("initiative-card-I-3", {}, { timeout: 15_000 }); - expect(within(card).getByRole("link", { name: "Versions in Settings" })).toHaveAttribute( - "href", - `/factory/${LIVE_RUN_FACTORY_ID}/initiatives/I-3`, - ); - expect(within(card).getByTestId("initiative-state")).toHaveTextContent("Acceptance failing"); - expect(within(card).getByRole("img", { name: "1/3 merged" })).toBeInTheDocument(); - expect(within(card).getByTestId("initiative-acceptance")).toHaveTextContent( - "Acceptance failing · 1 of 4 steps", - ); - expect(within(card).getByTestId("initiative-try")).toHaveAttribute( - "href", - "https://app-1a2b.envs.sandbox.example.com", - ); - const section = screen.getByTestId("home-initiatives"); - const projects = screen.getByTestId("home-projects"); - expect( - section.compareDocumentPosition(projects) & Node.DOCUMENT_POSITION_FOLLOWING, - ).toBeTruthy(); - }, 30_000); - - it("shows the ask, the acceptance with its evidence and the projects by wave", async () => { - serve(sampleInitiativeView("failing")); - renderAt("/initiatives/3"); - - const page = await screen.findByTestId("initiative-page", {}, { timeout: 15_000 }); - expect(within(page).getByRole("heading", { level: 1 })).toHaveTextContent( - "Versions in SettingsI-3Acceptance failing", - ); - expect( - within(screen.getByTestId("initiative-ask")).getByText(/Can we show which/), - ).toBeTruthy(); - expect(screen.getByTestId("initiative-ask")).toHaveTextContent( - "Asked by Lena (sample) · 5h ago", - ); - expect(within(page).getByTestId("initiative-try")).toHaveAttribute( - "href", - "https://app-1a2b.envs.sandbox.example.com", - ); - expect(page).toHaveTextContent("Turn on settings.versions there"); - expect(screen.getByTestId("initiative-step-3")).toHaveAttribute("data-mark", "fail"); - expect(screen.getByTestId("initiative-step-3")).toHaveTextContent( - "the row says “unknown” instead of a migration id", - ); - expect(screen.getByTestId("initiative-step-4")).toHaveTextContent( - "Cannot run in envs: synthetic login unavailable on the base env", - ); - expect(screen.getByTestId("initiative-last-run")).toHaveTextContent( - "Run 2 · failed · 4c1d9e0 · tasks T-14", - ); - expect(within(screen.getByTestId("initiative-step-1")).getByText("Screenshot")).toHaveAttribute( - "href", - expect.stringMatching(/^\/v1\/factory\/demos\//), - ); - expect(screen.getByTestId("initiative-project-settings-row")).toHaveAttribute( - "data-role", - "skeleton", - ); - expect(screen.getByTestId("initiative-wave-2")).toHaveTextContent( - "Uses api-version: serves its version through the same route", - ); - expect(screen.getByTestId("initiative-next-wave")).toHaveTextContent( - "Next: wave 3 · Copy the versions from the CLI too", - ); - }, 30_000); - - it("runs the acceptance now", async () => { - const view = sampleInitiativeView("in_progress"); - const ran: InitiativeView = { - ...view, - runs: [...view.runs, { n: 2, status: "requested", sha: view.base?.env_sha, tasks: [] }], - }; - serve(view, ran); - renderAt("/initiatives/I-3"); - - fireEvent.click(await screen.findByTestId("initiative-run-now", {}, { timeout: 15_000 })); - - await waitFor(() => - expect(server.calls.some((c) => c.method === "POST" && c.path === `${PAGE}/acceptance`)).toBe( - true, - ), - ); - await waitFor(() => - expect(screen.getByTestId("initiative-run-now")).toHaveTextContent("Run on its way"), - ); - }, 30_000); - - it("says when there is no such initiative", async () => { - serve(sampleInitiativeView()); - renderAt("/initiatives/9"); - - expect( - await screen.findByTestId("initiative-missing", {}, { timeout: 15_000 }), - ).toHaveTextContent("This factory has no initiative I-9."); - }, 30_000); -}); diff --git a/web/src/pages/factory/LearnPages.test.tsx b/web/src/pages/factory/LearnPages.test.tsx index 9cb387221e1..2cfbea48fbf 100644 --- a/web/src/pages/factory/LearnPages.test.tsx +++ b/web/src/pages/factory/LearnPages.test.tsx @@ -14,7 +14,7 @@ import { round3Events, round3Snapshot, } from "@/components/factory/shell/__fixtures__/shellFixtures"; -import type { ProposalView, ViewerRole } from "@/lib/factoryApi"; +import type { ViewerRole } from "@/lib/factoryApi"; import { FactoryPage } from "./FactoryPage"; import { type FactoryServer, SNAPSHOT, json, serveFactory } from "./testServer"; @@ -28,8 +28,6 @@ configure({ asyncUtilTimeout: 5000 }); const FID = round3Snapshot().factory.id; const BASE = `/factory/${FID}`; -const PR_9630 = "f74d5967c53455139e85ba1b402a5bd4"; -const IC = "9c5729417ac856a58105fd08ab3637f7"; let location = ""; function LocationProbe() { @@ -97,11 +95,6 @@ function sent(method: string, path: string): unknown[] { .map((c) => c.body); } -function openSelect(trigger: HTMLElement) { - fireEvent.pointerDown(trigger, new MouseEvent("pointerdown", { bubbles: true, button: 0 })); - fireEvent.click(trigger); -} - beforeEach(() => { authenticatedFetch.mockReset(); resetFactoryControls(); @@ -191,162 +184,9 @@ describe("Insights", () => { // ── Settings → History ───────────────────────────────────────── -describe("Settings", () => { - it("saves a budget as an applied human change that History lists", async () => { - serve("creator", { - [`PUT ${SNAPSHOT}/definition`]: () => { - learn = { afterSave: true }; - return json({ proposal: "C-17", proposal_id: "c17", status: "applied", def_version: 8 }); - }, - }); - renderAt("/settings/budgets"); - - const input = await screen.findByTestId("setting-input-loops.implement.attempts"); - expect(input).toHaveValue("3"); - fireEvent.change(input, { target: { value: "4" } }); - expect(screen.getByTestId("settings-pending")).toHaveTextContent( - "loops.implement.attempts: 3 → 4", - ); - fireEvent.change(screen.getByTestId("settings-note"), { - target: { value: "Billing tasks need a fourth try." }, - }); - fireEvent.click(screen.getByTestId("settings-save")); - - await waitFor(() => - expect(sent("PUT", `${SNAPSHOT}/definition`)).toEqual([ - { - changes: [{ op: "replace", path: "loops.implement.attempts", value: 4 }], - note: "Billing tasks need a fourth try.", - base_version: 7, - }, - ]), - ); - fireEvent.click(screen.getByText("History of changes →")); - const row = await screen.findByTestId("history-C-17"); - expect(row).toHaveTextContent("loops.implement.attempts: 3 → 4"); - expect(row).toHaveTextContent("Applied"); - expect(row).toHaveTextContent("v7→v8"); - }); - - it("refuses an out-of-bounds value before it is sent", async () => { - serve(); - renderAt("/settings/budgets"); - - fireEvent.change(await screen.findByTestId("setting-input-loops.implement.attempts"), { - target: { value: "9" }, - }); - - expect(screen.getByTestId("setting-error-loops.implement.attempts")).toHaveTextContent("1–6"); - expect(screen.getByTestId("settings-save")).toBeDisabled(); - }); - - it.each(["viewer", "operator"] as const)("shows a %s the values read-only", async (role) => { - serve(role); - renderAt("/settings/budgets"); - - expect(await screen.findByTestId("settings-read-only")).toHaveTextContent( - "Owners can change settings", - ); - expect(screen.queryByTestId("setting-input-loops.implement.attempts")).toBeNull(); - expect(screen.getByTestId("setting-value-loops.implement.attempts")).toHaveTextContent( - "3 tries", - ); - // No disabled form to scroll past: the values, with one line saying who can change them. - expect(screen.queryByTestId("settings-save")).toBeNull(); - expect(screen.queryByTestId("settings-save-bar")).toBeNull(); - }); - - it("sets the Slack webhook without ever showing it", async () => { - serve("creator", { [`PUT ${SNAPSHOT}/alerts/slack`]: () => json({ slack: true }) }); - renderAt("/settings/alerts"); - - const url = "https://hooks.slack.com/services/T000/B000/XXXX"; - fireEvent.change(await screen.findByTestId("settings-slack-url"), { target: { value: url } }); - fireEvent.click(screen.getByTestId("settings-slack-save")); - - await waitFor(() => expect(sent("PUT", `${SNAPSHOT}/alerts/slack`)).toEqual([{ url }])); - await waitFor(() => expect(screen.getByTestId("settings-slack-url")).toHaveValue("")); - }); - - it("adds a rule as a definition change", async () => { - serve("creator", { - [`PUT ${SNAPSHOT}/definition`]: () => - json({ proposal: "C-17", proposal_id: "c17", status: "applied", def_version: 8 }), - }); - renderAt("/settings/rules"); - - expect(await screen.findByTestId("rule-R-3")).toHaveTextContent("Run the package"); - fireEvent.click(screen.getByTestId("rules-add")); - fireEvent.change(screen.getByTestId("rules-form-text"), { - target: { value: "Decide reversible naming choices yourself." }, - }); - fireEvent.click(screen.getByTestId("rules-form-save")); - - await waitFor(() => - expect(sent("PUT", `${SNAPSHOT}/definition`)).toEqual([ - { - changes: [ - { - op: "add", - path: "rules", - value: { - role: "ic", - project: "*", - text: "Decide reversible naming choices yourself.", - }, - }, - ], - note: "Added a rule for IC", - base_version: 7, - }, - ]), - ); - }); -}); - // ── Improvements ─────────────────────────────────────────────── describe("Improvements", () => { - it("scores the trial against its baseline and keeps it on an owner's click", async () => { - serve("creator", { - [`POST ${SNAPSHOT}/proposals/C-14/keep`]: () => json({ proposal: { ref: "C-14" } }), - }); - renderAt("/improvements"); - - const card = await screen.findByTestId("proposal-C-14"); - expect(within(card).getByTestId("proposal-C-14-trial")).toHaveTextContent("31h/48h"); - const line = await within(card).findByTestId("proposal-C-14-scorecard"); - expect( - within(line).getByTestId("proposal-C-14-scorecard-guard-merge_acceptance"), - ).toHaveAttribute("data-status", "ok"); - expect(line).toHaveTextContent("target heartbeat share 31% → 19%"); - fireEvent.click(within(card).getByTestId("proposal-C-14-keep")); - - await waitFor(() => expect(sent("POST", `${SNAPSHOT}/proposals/C-14/keep`)).toHaveLength(1)); - }); - - it("marks trials the idle factory extended and says when one stays applied", async () => { - serve("creator", {}, { idle: true }); - renderAt("/improvements"); - - const c13 = await screen.findByTestId("proposal-C-13"); - expect(within(c13).getByTestId("proposal-C-13-extended")).toHaveTextContent( - "extended ×2 (idle)", - ); - expect(within(c13).getByTestId("proposal-C-13-keeps")).toHaveTextContent( - "If nobody answers in 22h, it stays applied because nothing got worse.", - ); - expect(await within(c13).findByTestId("proposal-C-13-scorecard")).toBeInTheDocument(); - const c10 = screen.getByTestId("proposal-C-10"); - expect(within(c10).getByTestId("proposal-C-10-extended")).toHaveTextContent( - "extended ×1 (idle)", - ); - expect(within(c10).queryByTestId("proposal-C-10-keeps")).toBeNull(); - expect( - within(screen.getByTestId("proposal-C-14")).queryByTestId("proposal-C-14-extended"), - ).toBeNull(); - }); - it("records an idle trial's extensions on its page", async () => { serve("creator", {}, { idle: true }); renderAt("/improvements/C-13"); @@ -377,98 +217,6 @@ describe("Improvements", () => { ); }); - it("approves a pending rules change as a trial", async () => { - serve("creator", { - [`POST ${SNAPSHOT}/proposals/C-15/apply`]: () => json({ proposal: {}, decision: {} }), - }); - renderAt("/improvements?tab=proposed"); - - fireEvent.click(await screen.findByTestId("proposal-C-15-approve")); - - await waitFor(() => expect(sent("POST", `${SNAPSHOT}/proposals/C-15/apply`)).toEqual([{}])); - }); - - it("records a code change while Tier 2 is off, with Copy as issue", async () => { - serve(); - renderAt("/improvements?tab=proposed"); - - expect(await screen.findByTestId("proposal-C-16-tier2")).toHaveTextContent( - "Smith lane off (not switched on for this server)", - ); - expect(screen.getByTestId("proposal-C-16-copy-issue")).toBeEnabled(); - expect(screen.queryByTestId("proposal-C-16-approve")).toBeNull(); - expect(screen.getByTestId("improvements-tier2")).toHaveAttribute("data-enabled", "false"); - }); - - it("lets an owner dismiss a code change recorded while Tier 2 is off", async () => { - serve("creator", { - [`POST ${SNAPSHOT}/proposals/C-16/reject`]: () => json({ proposal: {}, decision: null }), - }); - renderAt("/improvements?tab=proposed"); - - const dismiss = await screen.findByTestId("proposal-C-16-reject"); - expect(dismiss).toHaveTextContent("Dismiss"); - fireEvent.click(dismiss); - expect(sent("POST", `${SNAPSHOT}/proposals/C-16/reject`)).toEqual([]); - fireEvent.click(screen.getByTestId("proposal-reject-confirm-C-16")); - - await waitFor(() => expect(sent("POST", `${SNAPSHOT}/proposals/C-16/reject`)).toEqual([{}])); - }); - - it("asks why before rejecting a pending change, as the Decisions card does", async () => { - serve("creator", { - [`POST ${SNAPSHOT}/proposals/C-15/reject`]: () => json({ proposal: {}, decision: {} }), - }); - renderAt("/improvements?tab=proposed"); - - fireEvent.click(await screen.findByTestId("proposal-C-15-reject")); - const form = screen.getByTestId("proposal-reject-form-C-15"); - expect(sent("POST", `${SNAPSHOT}/proposals/C-15/reject`)).toEqual([]); - // The levers make way for the form; Approve can't be hit by mistake. - expect(screen.queryByTestId("proposal-C-15-approve")).toBeNull(); - const note = within(form).getByTestId("proposal-reject-note-C-15"); - expect(note).toHaveAttribute("placeholder", "Why not? The proposer reads this (optional)"); - fireEvent.change(note, { target: { value: "Plan human-only steps in the charter instead" } }); - fireEvent.click(within(form).getByTestId("proposal-reject-confirm-C-15")); - - await waitFor(() => - expect(sent("POST", `${SNAPSHOT}/proposals/C-15/reject`)).toEqual([ - { note: "Plan human-only steps in the charter instead" }, - ]), - ); - await waitFor(() => expect(screen.queryByTestId("proposal-reject-form-C-15")).toBeNull()); - }); - - it("closes the reject form on Cancel without sending", async () => { - serve(); - renderAt("/improvements?tab=proposed"); - - fireEvent.click(await screen.findByTestId("proposal-C-15-reject")); - fireEvent.click(screen.getByTestId("proposal-reject-cancel-C-15")); - - expect(screen.queryByTestId("proposal-reject-form-C-15")).toBeNull(); - expect(screen.getByTestId("proposal-C-15-approve")).toBeEnabled(); - expect(sent("POST", `${SNAPSHOT}/proposals/C-15/reject`)).toEqual([]); - }); - - it("keeps the form and its note when the reject fails", async () => { - serve("creator", { - [`POST ${SNAPSHOT}/proposals/C-15/reject`]: () => - json({ error: { code: "unavailable", message: "try again" } }, 503), - }); - renderAt("/improvements?tab=proposed"); - - fireEvent.click(await screen.findByTestId("proposal-C-15-reject")); - fireEvent.change(screen.getByTestId("proposal-reject-note-C-15"), { - target: { value: "too vague" }, - }); - fireEvent.click(screen.getByTestId("proposal-reject-confirm-C-15")); - - expect(await screen.findByTestId("proposal-C-15-error")).toBeInTheDocument(); - expect(screen.getByTestId("proposal-reject-note-C-15")).toHaveValue("too vague"); - expect(screen.getByTestId("proposal-reject-confirm-C-15")).toBeEnabled(); - }); - it("shows the reject note on a rejected change", async () => { const rejected = { ...learnProposals().find((p) => p.ref === "C-15")!, @@ -487,25 +235,6 @@ describe("Improvements", () => { expect(screen.queryByTestId("proposal-C-15-reject")).toBeNull(); }); - it.each(["viewer", "operator"] as const)("disables every Tier-1 lever for a %s", async (role) => { - serve(role); - renderAt("/improvements?tab=proposed"); - - const levers = await Promise.all( - ["approve", "edit", "reject"].map((lever) => screen.findByTestId(`proposal-C-15-${lever}`)), - ); - for (const lever of levers) expect(lever).toBeDisabled(); - }); - - it("shows no counts until the proposals have loaded", async () => { - serve("creator", {}, { proposals: "pending" }); - renderAt("/improvements"); - - expect(await screen.findByTestId("improvements-loading")).toBeInTheDocument(); - expect(screen.queryByTestId("improvements-counts")).toBeNull(); - expect(screen.getByTestId("improvements-tab-trial")).toHaveTextContent(/^On trial$/); - }); - it("names the member who decided, not their session id", async () => { serve(); renderAt("/improvements/C-14"); @@ -514,247 +243,11 @@ describe("Improvements", () => { await waitFor(() => expect(record).toHaveTextContent(/Decided\s*Atlas/)); expect(record).not.toHaveTextContent(/[0-9a-f]{32}/); }); - - it("lists the definition's versions with a diff and bundle drift", async () => { - serve(); - renderAt("/improvements?tab=definition"); - - expect(await screen.findByTestId("definition-v7")).toHaveAttribute("data-pinned", "true"); - expect(await screen.findByTestId("definition-diff")).toHaveTextContent( - "cadence.project_lead: 7200 → 10800", - ); - expect(await screen.findByTestId("definition-bundles-drift")).toHaveTextContent( - "1 member runs an older bundle", - ); - }); - - it("files an operator's change for the owners from a finding", async () => { - serve("operator", { - [`POST ${SNAPSHOT}/definition/proposals`]: () => - json({ proposal: { ref: "C-19" }, decision: "D-40" }, 201), - }); - renderAt("/improvements?tab=findings"); - - fireEvent.click(await screen.findByTestId("finding-propose-F-7")); - const dialog = await screen.findByTestId("propose-change-dialog"); - await waitFor(() => expect(within(dialog).getByTestId("propose-value")).toHaveValue("3")); - fireEvent.change(within(dialog).getByTestId("propose-value"), { target: { value: "4" } }); - fireEvent.click(within(dialog).getByTestId("propose-submit")); - - await waitFor(() => - expect(sent("POST", `${SNAPSHOT}/definition/proposals`)).toEqual([ - { - kind: "tuning", - changes: [{ op: "replace", path: "loops.implement.attempts", value: 4 }], - rationale: expect.stringContaining("CI failed then passed"), - evidence: ["F-7"], - }, - ]), - ); - await waitFor(() => expect(location).toContain("/improvements/C-19")); - }); -}); - -// ── Edit on a pending proposal ───────────────────────────────── - -/** The fixture's proposals with `pending` in place of the pending ones. */ -function pendingOnly(pending: ProposalView[]): LearnRouteOptions { - const all = [...learnProposals().filter((p) => p.status !== "pending"), ...pending]; - return { - proposals: (path: string) => { - const kind = new URL(path, "http://x").searchParams.get("kind"); - return { body: { data: all.filter((p) => !kind || p.kind === kind) } }; - }, - }; -} - -function c15With(fields: Partial): ProposalView { - return { ...learnProposals().find((p) => p.ref === "C-15")!, ...fields }; -} - -const MULTI = (): ProposalView => - c15With({ - id: "4040404040404040404040404040404a", - ref: "C-40", - number: 40, - kind: "tuning", - title: "Slower heartbeats for leads", - ops: [ - { op: "replace", path: "cadence.lead", value: 7200 }, - { op: "replace", path: "cadence.project_lead", value: 14400 }, - ], - before: null, - after: null, - }); - -const APPLIED = { proposal: "C-30", proposal_id: "c30", status: "trial", def_version: 9 }; - -describe("Edit on a pending proposal", { timeout: 20_000 }, () => { - it("keeps a project-scoped rule on its project", async () => { - const scoped = c15With({ - ops: [ - { - op: "add", - path: "rules", - value: { role: "ic", project: "api-version", text: "Run unit tests first." }, - }, - ], - }); - serve( - "creator", - { - [`PUT ${SNAPSHOT}/definition`]: () => json(APPLIED), - [`POST ${SNAPSHOT}/proposals/C-15/reject`]: () => json({ proposal: {}, decision: {} }), - }, - pendingOnly([scoped]), - ); - renderAt("/improvements?tab=proposed"); - - fireEvent.click(await screen.findByTestId("proposal-C-15-edit")); - const dialog = await screen.findByTestId("propose-change-dialog"); - expect(within(dialog).getByTestId("propose-rule-project")).toHaveTextContent("#api-version"); - fireEvent.change(within(dialog).getByTestId("propose-rule-text"), { - target: { value: "Run the unit tests first." }, - }); - const submit = within(dialog).getByTestId("propose-submit"); - await waitFor(() => expect(submit).toBeEnabled()); - fireEvent.click(submit); - - await waitFor(() => expect(sent("PUT", `${SNAPSHOT}/definition`)).toHaveLength(1)); - const body = sent("PUT", `${SNAPSHOT}/definition`)[0] as { changes: unknown[]; note: string }; - expect(body.changes).toEqual([ - { - op: "add", - path: "rules", - value: { role: "ic", project: "api-version", text: "Run the unit tests first." }, - }, - ]); - expect(body.note).toMatch(/^Edited from C-15: .*\(evidence: D-1, D-2/); - await waitFor(() => - expect(sent("POST", `${SNAPSHOT}/proposals/C-15/reject`)).toEqual([ - { note: "Edited and applied as C-30" }, - ]), - ); - await waitFor(() => expect(location).toContain("/improvements/C-30")); - }); - - it("applies every change of a multi-change proposal, then rejects the original", async () => { - serve( - "creator", - { - [`PUT ${SNAPSHOT}/definition`]: () => json(APPLIED), - [`POST ${SNAPSHOT}/proposals/C-40/reject`]: () => json({ proposal: {}, decision: {} }), - }, - pendingOnly([MULTI()]), - ); - renderAt("/improvements?tab=proposed"); - - fireEvent.click(await screen.findByTestId("proposal-C-40-edit")); - const dialog = await screen.findByTestId("propose-change-dialog"); - const second = within(dialog).getByTestId("propose-change-1"); - expect(within(dialog).getByTestId("propose-change-0")).toHaveTextContent("Change 1 of 2"); - await waitFor(() => expect(within(second).getByTestId("propose-value")).toHaveValue("14400")); - fireEvent.change(within(second).getByTestId("propose-value"), { target: { value: "21600" } }); - fireEvent.click(within(dialog).getByTestId("propose-submit")); - - await waitFor(() => expect(sent("PUT", `${SNAPSHOT}/definition`)).toHaveLength(1)); - expect((sent("PUT", `${SNAPSHOT}/definition`)[0] as { changes: unknown[] }).changes).toEqual([ - { op: "replace", path: "cadence.lead", value: 7200 }, - { op: "replace", path: "cadence.project_lead", value: 21600 }, - ]); - await waitFor(() => expect(sent("POST", `${SNAPSHOT}/proposals/C-40/reject`)).toHaveLength(1)); - const order = server.calls.map((c) => `${c.method} ${c.path.split("?")[0]}`); - expect(order.indexOf(`POST ${SNAPSHOT}/proposals/C-40/reject`)).toBeGreaterThan( - order.indexOf(`PUT ${SNAPSHOT}/definition`), - ); - }); - - it("says the original is still pending when its reject fails, and retries only the reject", async () => { - let rejectOk = false; - serve( - "creator", - { - [`PUT ${SNAPSHOT}/definition`]: () => json(APPLIED), - [`POST ${SNAPSHOT}/proposals/C-40/reject`]: () => - rejectOk - ? json({ proposal: {}, decision: {} }) - : json({ error: { code: "conflict", message: "decision busy" } }, 409), - }, - pendingOnly([MULTI()]), - ); - renderAt("/improvements?tab=proposed"); - - fireEvent.click(await screen.findByTestId("proposal-C-40-edit")); - const dialog = await screen.findByTestId("propose-change-dialog"); - const submit = within(dialog).getByTestId("propose-submit"); - await waitFor(() => expect(submit).toBeEnabled()); - fireEvent.click(submit); - - expect(await within(dialog).findByTestId("propose-error")).toHaveTextContent( - "Applied as C-30, but C-40 is still pending", - ); - expect(submit).toHaveTextContent("Reject C-40"); - expect(location).not.toContain("/improvements/C-30"); - rejectOk = true; - fireEvent.click(submit); - - await waitFor(() => expect(sent("POST", `${SNAPSHOT}/proposals/C-40/reject`)).toHaveLength(2)); - expect(sent("PUT", `${SNAPSHOT}/definition`)).toHaveLength(1); - await waitFor(() => expect(location).toContain("/improvements/C-30")); - }); }); // ── Feedback round trips ─────────────────────────────────────── describe("feedback", () => { - it("rates a PR with its review", async () => { - serve("creator", { - [`POST /v1/factory/prs/${PR_9630}/feedback`]: () => - json({ pr_id: PR_9630, human_review: null, mail_id: "m1" }), - }); - renderAt(`/prs/${PR_9630}`); - - fireEvent.click(await screen.findByTestId("pr-rating-stars-2")); - fireEvent.click(screen.getByTestId("pr-rating-tags-missing_tests")); - fireEvent.click(screen.getByTestId("review-request-changes")); - fireEvent.change(screen.getByTestId("review-feedback-text"), { - target: { value: "Add a test for the 404." }, - }); - fireEvent.click(screen.getByTestId("review-feedback-send")); - - await waitFor(() => - expect(sent("POST", `/v1/factory/prs/${PR_9630}/feedback`)).toEqual([ - { - verdict: "changes_requested", - text: "Add a test for the 404.", - rating: 2, - tags: ["missing_tests"], - }, - ]), - ); - }); - - it("says whether a demo was useful", async () => { - serve(); - renderAt(`/prs/${PR_9630}`); - - fireEvent.click(await screen.findByTestId("demo-thumbs-up")); - - await waitFor(() => - expect(sent("POST", "/v1/factory/feedback")).toEqual([ - expect.objectContaining({ - factory_id: FID, - target_kind: "demo", - target_id: "df2503ab72c051c6bdfa1e15f1570e31", - kind: "useful", - rating: 1, - project: "api-version", - }), - ]), - ); - expect(await screen.findByTestId("demo-thumbs-sent")).toHaveTextContent("Useful"); - }); - it("flags a question as unnecessary", async () => { serve(); renderAt("/decisions"); @@ -773,71 +266,4 @@ describe("feedback", () => { ]), ); }); - - it("rates a finished project", async () => { - serve(); - renderAt("/projects/docs-factory-envs"); - - fireEvent.click(await screen.findByTestId("wrap-up-stars-5")); - fireEvent.click(screen.getByTestId("wrap-up-tags-good_demos")); - fireEvent.click(screen.getByTestId("wrap-up-send")); - - await waitFor(() => - expect(sent("POST", "/v1/factory/feedback")).toEqual([ - expect.objectContaining({ - target_kind: "project", - target_id: "docs-factory-envs", - kind: "rating", - rating: 5, - tags: ["good_demos"], - }), - ]), - ); - expect(await screen.findByTestId("wrap-up-sent")).toHaveTextContent("Rated 5★ · good demos"); - }); - - it("confirms or corrects a finding's label", async () => { - serve(); - renderAt("/improvements?tab=findings"); - - fireEvent.click(await screen.findByTestId("calibrate-F-7-no")); - openSelect(screen.getByTestId("calibrate-F-7-cause")); - fireEvent.click(await screen.findByRole("option", { name: "env flaky" })); - - await waitFor(() => - expect(sent("POST", "/v1/factory/feedback")).toEqual([ - expect.objectContaining({ - target_kind: "issue", - kind: "calibration", - rating: -1, - tags: ["env_flaky"], - }), - ]), - ); - }); - - it("reports a problem on an agent", async () => { - serve("viewer", { - [`POST ${SNAPSHOT}/issues`]: () => json({ issue: { id: "i1", ref: "INC-20" } }, 201), - }); - renderAt(`/agents/${IC}`); - - fireEvent.click(await screen.findByTestId("agent-report-problem")); - fireEvent.change(screen.getByTestId("report-problem-summary"), { - target: { value: "Keeps rerunning the same failing test" }, - }); - fireEvent.click(screen.getByTestId("report-problem-submit")); - - await waitFor(() => - expect(sent("POST", `${SNAPSHOT}/issues`)).toEqual([ - expect.objectContaining({ - summary: "Keeps rerunning the same failing test", - target: `member:${IC}`, - class: "incident", - severity: "warning", - project: "api-version", - }), - ]), - ); - }); }); diff --git a/web/src/pages/factory/NewFactoryPage.test.tsx b/web/src/pages/factory/NewFactoryPage.test.tsx index a0f6aff6766..3290c67c49f 100644 --- a/web/src/pages/factory/NewFactoryPage.test.tsx +++ b/web/src/pages/factory/NewFactoryPage.test.tsx @@ -98,7 +98,7 @@ function renderPage(info: ServerInfo | "loading" = "loading", path = "/factory/n } /> } /> - } /> + } /> diff --git a/web/src/pages/factory/OpsPages.test.tsx b/web/src/pages/factory/OpsPages.test.tsx index 3bd9a8a8300..22feeb0bb94 100644 --- a/web/src/pages/factory/OpsPages.test.tsx +++ b/web/src/pages/factory/OpsPages.test.tsx @@ -4,10 +4,7 @@ import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { IC_ROUTE_R2, - PL_CACHE, - PL_CACHE_DEAD_REASON, PR_9631, - agentDetail, opsRoutes, round3IssueList, spansResponse, @@ -81,132 +78,7 @@ beforeEach(() => { location = ""; }); -describe("two clicks from a symptom to the lever", () => { - it("goes from the Overview's repeating-IC chip to the Agent page and interrupts it", async () => { - serve(); - renderAt(""); - let clicks = 0; - const chip = await screen.findByTestId("needs-issue-agent-INC-13"); - expect(chip).toHaveTextContent("ic:route-r2"); - fireEvent.click(chip); - clicks += 1; - expect(location).toBe(`${BASE}/agents/${IC_ROUTE_R2}`); - const interrupt = await screen.findByTestId("agent-interrupt"); - expect(screen.getByTestId("agent-header")).toHaveTextContent("same failing command 7×"); - fireEvent.click(interrupt); - clicks += 1; - await waitFor(() => expect(server.controls).toHaveLength(1)); - expect(server.controls[0]).toEqual({ action: "interrupt", target: `member:${IC_ROUTE_R2}` }); - expect(clicks).toBe(2); - }); -}); - -describe("AgentsPage", () => { - it("lists the fleet grouped by project and narrows it by the query", async () => { - serve(); - renderAt("/agents"); - expect(await screen.findByTestId("fleet-table")).toBeInTheDocument(); - expect(screen.getByTestId("fleet-counts")).toHaveTextContent("1 dead"); - expect(screen.getByTestId("fleet-group-project:db-version")).toBeInTheDocument(); - fireEvent.change(screen.getByTestId("fleet-query"), { - target: { value: "state:running tool:Bash cost>2 project:db-*" }, - }); - await waitFor(() => expect(screen.getAllByTestId(/^fleet-row-/)).toHaveLength(1)); - expect(screen.getByTestId(`fleet-row-${IC_ROUTE_R2}`)).toBeInTheDocument(); - expect(location).toContain("q=state%3Arunning"); - }); - - it("toggles a saved view into the query", async () => { - serve(); - renderAt("/agents"); - fireEvent.click(await screen.findByTestId("fleet-view-looping")); - await waitFor(() => expect(screen.getByTestId("fleet-query")).toHaveValue("is:looping")); - expect(screen.getByTestId(`fleet-row-${IC_ROUTE_R2}`)).toBeInTheDocument(); - }); - - it("bulk-interrupts the selected agents behind a confirmation", async () => { - serve(); - renderAt("/agents?q=task:T-4"); - await screen.findByTestId(`fleet-row-${IC_ROUTE_R2}`); - for (const row of screen.getAllByTestId(/^fleet-row-/)) { - fireEvent.click(within(row).getByRole("checkbox")); - } - expect(screen.getByTestId("fleet-bulk-bar")).toHaveTextContent("3 agents selected"); - fireEvent.click(screen.getByTestId("fleet-bulk-redispatch")); - const dialog = await screen.findByTestId("intervention-dialog"); - expect(within(dialog).getByTestId("intervention-targets")).toHaveTextContent("T-4"); - fireEvent.click(within(dialog).getByTestId("intervention-confirm")); - await waitFor(() => expect(server.controls).toHaveLength(1)); - expect(server.controls[0]).toEqual({ action: "redispatch", target: "task:T-4" }); - }); - - it("leaves selected agents the query hides out of the bulk levers", async () => { - serve(); - renderAt("/agents?q=task:T-4"); - await screen.findByTestId(`fleet-row-${IC_ROUTE_R2}`); - for (const row of screen.getAllByTestId(/^fleet-row-/)) { - fireEvent.click(within(row).getByRole("checkbox")); - } - fireEvent.change(screen.getByTestId("fleet-query"), { target: { value: "flag:repeat" } }); - await waitFor(() => expect(screen.getAllByTestId(/^fleet-row-/)).toHaveLength(1)); - expect(screen.getByTestId("fleet-bulk-count")).toHaveTextContent( - "1 agent selected (2 hidden by the filter)", - ); - fireEvent.click(screen.getByTestId("fleet-bulk-interrupt")); - const dialog = await screen.findByTestId("intervention-dialog"); - fireEvent.click(within(dialog).getByTestId("intervention-confirm")); - await waitFor(() => expect(server.controls).toHaveLength(1)); - expect(server.controls[0]).toEqual({ action: "interrupt", target: `member:${IC_ROUTE_R2}` }); - }); - - it("shows Show on Timeline for a selection", async () => { - serve(); - renderAt("/agents?q=flag:repeat"); - const row = await screen.findByTestId(`fleet-row-${IC_ROUTE_R2}`); - fireEvent.click(within(row).getByRole("checkbox")); - fireEvent.click(screen.getByTestId("fleet-bulk-timeline")); - expect(location).toBe(`${BASE}/timeline?agents=${IC_ROUTE_R2}`); - }); - - it("disables every lever for a viewer", async () => { - serve("viewer"); - renderAt("/agents?q=flag:repeat"); - const row = await screen.findByTestId(`fleet-row-${IC_ROUTE_R2}`); - fireEvent.click(within(row).getByRole("checkbox")); - for (const kind of ["message", "interrupt", "redispatch", "reassign"]) { - expect(screen.getByTestId(`fleet-bulk-${kind}`)).toBeDisabled(); - } - }); -}); - describe("AgentPage", () => { - it("shows the now line, the issue's runbook, tabs and a composer that messages as the viewer", async () => { - serve(); - renderAt(`/agents/${IC_ROUTE_R2}`); - const now = await screen.findByTestId("agent-now"); - expect(now).toHaveTextContent("Bash pnpm -F sample-api test"); - expect(screen.getByTestId("agent-meta")).toHaveTextContent("attempt 3/3 ⛔"); - expect(screen.getByTestId("agent-issue-INC-13")).toBeInTheDocument(); - expect(screen.getByTestId("agent-panel-live")).toHaveTextContent( - 'relation "knex_migrations" does not exist', - ); - fireEvent.click(screen.getByTestId("agent-tab-mail")); - expect(await screen.findByTestId("agent-panel-mail")).toHaveTextContent( - "re-run with knex seed", - ); - expect(location).toContain("tab=mail"); - fireEvent.change(screen.getByTestId("agent-composer-input"), { - target: { value: "seed it in setup" }, - }); - fireEvent.click(screen.getByTestId("agent-composer-send")); - await waitFor(() => expect(server.controls).toHaveLength(1)); - expect(server.controls[0]).toEqual({ - action: "message", - target: `member:${IC_ROUTE_R2}`, - note: "seed it in setup", - }); - }); - it("marks the moment the Timeline opened it at", async () => { serve(); renderAt(`/agents/${IC_ROUTE_R2}?tab=tools&at=${(1793017800 - 7 * 60) * 1000}`); @@ -216,34 +88,6 @@ describe("AgentPage", () => { .filter((li) => li.className.includes("ring-1")); expect(marked).toHaveLength(1); }); - - it("shows why a dead agent failed: the runner's error beside its incident", async () => { - serve(); - renderAt(`/agents/${PL_CACHE}`); - expect(await screen.findByTestId("agent-last-error")).toHaveTextContent(PL_CACHE_DEAD_REASON); - expect(screen.getByTestId("agent-header")).toHaveAttribute("data-state", "dead"); - expect(screen.getByTestId("agent-issue-INC-12")).toBeInTheDocument(); - }); - - it("leaves the error to the incident banner when its summary already quotes it", async () => { - const detail = agentDetail(PL_CACHE)!; - detail.issues = detail.issues.map((i) => - i.ref === "INC-12" ? { ...i, summary: `PL · cache-warm dead · ${PL_CACHE_DEAD_REASON}` } : i, - ); - serve("creator", { [`GET ${SNAPSHOT}/agents/${PL_CACHE}`]: () => json(detail) }); - renderAt(`/agents/${PL_CACHE}`); - expect(await screen.findByTestId("agent-issue-INC-12")).toHaveTextContent(PL_CACHE_DEAD_REASON); - expect(screen.queryByTestId("agent-last-error")).not.toBeInTheDocument(); - }); - - it("says when the agent isn't in this factory", async () => { - serve("creator", { - [`GET ${SNAPSHOT}/agents/00000000000000000000000000000000`]: () => - json({ error: { code: "not_found", message: "agent not found" } }, 404), - }); - renderAt("/agents/00000000000000000000000000000000"); - expect(await screen.findByTestId("agent-missing")).toBeInTheDocument(); - }); }); describe("Alerts and the incident page", () => { @@ -355,39 +199,3 @@ describe("TimelinePage", () => { await waitFor(() => expect(location).not.toContain("at=")); }); }); - -describe("Loop UI", () => { - it("draws the PR's loop strip with attempts, budget and remedies", async () => { - serve(); - renderAt(`/prs/${PR_9631}`); - const strip = await screen.findByTestId("pr-loop-strip"); - const impl = within(strip).getByTestId("loop-line-implement"); - expect(impl).toHaveAttribute("data-state", "exhausted"); - expect(within(impl).getByTestId("loop-state-implement")).toHaveTextContent("⛔ exhausted 3/3"); - expect(within(strip).getByTestId("loop-line-review")).toHaveAttribute( - "data-state", - "converged", - ); - expect(within(strip).getByTestId("loop-flake")).toHaveTextContent("F-7"); - }); - - it("shows the project's loops on its Loops tab", async () => { - serve(); - renderAt("/projects/db-version#loops"); - const loops = await screen.findByTestId("project-loops"); - expect(within(loops).getByTestId("project-loops-pr-9631")).toBeInTheDocument(); - expect(within(loops).getByTestId("project-loops-rest")).toHaveTextContent("plan"); - }); - - it("falls back to the snapshot's loop counts on a server without the loops route", async () => { - serve("creator", { - [`GET ${SNAPSHOT}/loops`]: () => - json({ error: { code: "not_found", message: "no route" } }, 404), - }); - renderAt("/projects/db-version#loops"); - const summary = await screen.findByTestId("project-loops-summary"); - expect(summary).toHaveTextContent("3 running"); - expect(summary).toHaveTextContent("1 exhausted"); - expect(screen.queryByTestId("project-loops-empty")).not.toBeInTheDocument(); - }); -}); diff --git a/web/src/pages/factory/ProjectPage.test.tsx b/web/src/pages/factory/ProjectPage.test.tsx deleted file mode 100644 index 111aa7371c6..00000000000 --- a/web/src/pages/factory/ProjectPage.test.tsx +++ /dev/null @@ -1,266 +0,0 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; -import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { LIVE_RUN_FACTORY_ID, pendingForever } from "@/components/factory/__fixtures__/liveRun"; -import { resetFactoryControls } from "@/components/factory/controls/useFactoryControls"; -import { PROJECT_SECTIONS } from "@/components/factory/factoryRoutes"; -import { - UX_PR_ID, - projectScenario, -} from "@/components/factory/project/__fixtures__/projectFixtures"; -import { FactoryPage } from "./FactoryPage"; -import { type FactoryServer, SNAPSHOT, json, serveFactory } from "./testServer"; - -const authenticatedFetch = vi.fn(); -vi.mock("@/lib/identity", () => ({ - authenticatedFetch: (...args: unknown[]) => authenticatedFetch(...args), -})); -vi.mock("@/hooks/useSessionUpdatesConnected", () => ({ useSessionUpdatesConnected: () => true })); - -const PL_API = "b408c5f09ff659e6b907fd9f688829e9"; -const PAGE = `/factory/${LIVE_RUN_FACTORY_ID}/projects`; - -let location = ""; -function LocationProbe() { - const loc = useLocation(); - location = loc.pathname + loc.search + loc.hash; - return null; -} - -function renderPage(slug = "api-version", rest = "") { - const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - render( - - - - - - - - } - /> - - - , - ); -} - -let server: FactoryServer; -function serve(snapshot?: () => Response | Promise) { - server = serveFactory(authenticatedFetch, snapshot ? { snapshot } : {}); -} - -beforeEach(() => { - authenticatedFetch.mockReset(); - resetFactoryControls(); -}); -afterEach(() => vi.unstubAllGlobals()); - -describe("ProjectPage", () => { - it("is a page: breadcrumb, header and every section anchored, no drawer", async () => { - serve(); - renderPage(); - const header = await screen.findByTestId("project-header"); - const crumbs = screen.getByTestId("project-breadcrumb"); - expect(crumbs).toHaveTextContent("ProjectsAPI version endpoint"); - expect(header).toHaveTextContent("P1"); - expect(within(header).getByTestId("project-repo")).toHaveTextContent( - "sample-org/sample-app#main", - ); - expect(within(header).getByTestId("project-open-pl")).toHaveAttribute("href", `/c/${PL_API}`); - for (const section of PROJECT_SECTIONS) { - expect(screen.getByTestId(`project-section-${section}`)).toHaveAttribute("id", section); - expect(screen.getByTestId(`project-nav-${section}`)).toHaveAttribute("href", `#${section}`); - } - expect(screen.getByTestId("project-nav-prs")).toHaveTextContent("PRs2"); - expect(screen.getByTestId("project-nav-demos")).toHaveTextContent("Demos3"); - expect(screen.getByTestId("project-nav-room")).toHaveAttribute( - "href", - `/factory/${LIVE_RUN_FACTORY_ID}/room/project%3Aapi-version`, - ); - expect(screen.queryByRole("dialog")).toBeNull(); - expect(document.querySelector("[data-slot=sheet-content]")).toBeNull(); - }); - - it("shows the charter, progress, plan, team and decisions", async () => { - serve(); - renderPage(); - const charter = await screen.findByTestId("project-charter"); - expect(charter).toHaveTextContent("Expose the deployed commit: GET /version on sample-api"); - expect(within(charter).getByTestId("project-charter-settings")).toHaveTextContent( - "Previews on · 2 slots", - ); - expect(screen.getByTestId("project-progress")).toHaveTextContent("Waiting onnobody"); - expect(screen.getByTestId("project-plan-link")).toHaveAttribute( - "href", - `/c/${PL_API}?file=~%2Ffactory%2Fapi-version%2Fplan.md`, - ); - const team = screen.getByTestId("project-team"); - expect(within(team).getByTestId(`project-member-${PL_API}`)).toHaveTextContent( - "PL · api-version", - ); - expect(screen.getByTestId("decisions-answered-toggle")).toHaveTextContent("Answered (1)"); - fireEvent.click(screen.getByTestId("charter-edit")); - expect(screen.getByTestId("edit-charter-dialog")).toHaveTextContent( - "Edit charter · api-version", - ); - }); - - it("keeps withdrawn and expired decisions, opened on a ?decision= link", async () => { - serve(); - renderPage("api-version", "?decision=D-4"); - expect(await screen.findByTestId("decision-closed-D-4")).toHaveAttribute( - "data-highlighted", - "true", - ); - }); - - it("points a blocked project's D-n at its decision", async () => { - serve(); - renderPage("db-version"); - const card = await screen.findByTestId("decision-card-D-2"); - expect(card).toHaveTextContent("Which table is the source of truth for the DB version"); - fireEvent.click(screen.getByTestId("blocked-link-db-version")); - await waitFor(() => expect(location).toContain("decision=D-2#decisions")); - expect(screen.getByTestId("decision-card-D-2")).toHaveAttribute("data-highlighted", "true"); - expect(screen.getByTestId("project-progress")).toHaveTextContent("D-2"); - }); - - it("plays every demo inline from the project's demos route, older runs per PR", async () => { - serve(); - renderPage("api-version", "#demos"); - expect(await screen.findByTestId("demo-card-9634")).toBeInTheDocument(); - expect(screen.getByTestId("project-demos-summary")).toHaveTextContent("2 PRs · 3 runs"); - expect(server.calls.some((c) => c.path === `${SNAPSHOT}/projects/api-version/demos`)).toBe( - true, - ); - expect(screen.getByTestId("demo-earlier-9630")).toHaveTextContent("1 earlier run"); - expect(screen.getByTestId("demo-review-9634")).toHaveAttribute( - "href", - `/factory/${LIVE_RUN_FACTORY_ID}/prs/${UX_PR_ID}`, - ); - }); - - it("opens the PR page, not a drawer, from a PR row", async () => { - serve(); - renderPage(); - fireEvent.click(await screen.findByTestId("review-open-9634")); - expect(await screen.findByTestId("pr-page")).toHaveAttribute("data-pr", "9634"); - expect(location).toBe(`/factory/${LIVE_RUN_FACTORY_ID}/prs/${UX_PR_ID}`); - expect(screen.queryByRole("dialog")).toBeNull(); - }); - - it("sends an old ?tab= link to its section", async () => { - serve(); - renderPage("api-version", "?tab=demos"); - await waitFor(() => expect(location).toBe(`${PAGE}/api-version#demos`)); - expect(await screen.findByTestId("project-section-demos")).toBeInTheDocument(); - }); - - it("sends an old ?review= link on the project page to the PR page", async () => { - serve(); - renderPage("api-version", "?review=9634"); - await waitFor(() => expect(location).toBe(`/factory/${LIVE_RUN_FACTORY_ID}/prs/${UX_PR_ID}`)); - }); - - it("shows the dev env and each PR's preview, and redeploys one", async () => { - serve(); - renderPage(); - const env = await screen.findByTestId("project-dev-env"); - expect(env).toHaveTextContent("Probed 21s ago · ok"); - const preview = screen.getByTestId("project-preview-9634"); - fireEvent.click(within(preview).getByTestId("project-preview-redeploy-9634")); - await waitFor(() => - expect(server.controls).toEqual([ - { action: "preview", target: "project:api-version", options: { pr_id: UX_PR_ID } }, - ]), - ); - }); - - it("lists only the project's activity", async () => { - serve(); - renderPage("db-version"); - await screen.findAllByTestId("activity-row"); - const pinned = server.calls.filter( - (c) => c.path.startsWith(`${SNAPSHOT}/events`) && c.path.includes("project="), - ); - expect(pinned.length).toBeGreaterThan(0); - expect( - pinned.every((c) => new URL(c.path, "http://x").searchParams.get("project") === "db-version"), - ).toBe(true); - }); - - it("disables the levers for a viewer and shows the read-only banner", async () => { - serve(() => { - const { snapshot } = projectScenario(); - snapshot.viewer = { - user_id: "ops@example.com", - role: "viewer", - level: 1, - is_creator: false, - can: { - post: false, - answer: false, - review: false, - control: false, - intervene: false, - apply_projects: false, - apply_tuning: false, - restart: false, - stop: false, - invite: false, - settings: false, - terminal: false, - start: false, - }, - }; - return json(snapshot); - }); - renderPage("db-version"); - expect(await screen.findByTestId("factory-viewer-banner")).toHaveTextContent( - "You can watch everything", - ); - expect(screen.getByTestId("charter-edit")).toBeDisabled(); - expect(screen.getByTestId("factory-add-project")).toBeDisabled(); - expect(screen.getByTestId("factory-pause-all")).toBeDisabled(); - expect(screen.getByTestId("decision-option-D-2-0")).toBeDisabled(); - }); - - it("says when the slug is not on the board", async () => { - serve(); - renderPage("nope"); - expect(await screen.findByTestId("project-missing")).toHaveTextContent( - "There is no project nope in this factory", - ); - }); - - it("shows a skeleton, never an empty page, while the first read is pending", () => { - serve(() => pendingForever()); - renderPage(); - expect(screen.getByTestId("factory-loading")).toBeInTheDocument(); - expect(screen.queryByTestId("project-header")).toBeNull(); - }); - - it("alerts with Retry when the first read fails", async () => { - let fail = true; - serve(() => - fail ? json({ error: { message: "down" } }, 503) : json(projectScenario().snapshot), - ); - renderPage(); - const alert = await screen.findByTestId("factory-load-error", {}, { timeout: 5000 }); - expect(alert).toHaveTextContent("Couldn’t load this factory"); - fail = false; - fireEvent.click(screen.getByTestId("factory-load-error-retry")); - expect(await screen.findByTestId("project-header")).toBeInTheDocument(); - }); - - it("says when the factory does not exist or access was removed", async () => { - serve(() => json({ error: { message: "not found" } }, 404)); - renderPage(); - expect(await screen.findByTestId("factory-missing")).toHaveTextContent("no longer have access"); - }); -}); diff --git a/web/src/pages/factory/ProjectPageV2.test.tsx b/web/src/pages/factory/ProjectPageV2.test.tsx deleted file mode 100644 index e22c619e1f8..00000000000 --- a/web/src/pages/factory/ProjectPageV2.test.tsx +++ /dev/null @@ -1,358 +0,0 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { configure, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; -import axe from "axe-core"; -import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { preflightOk } from "@/components/factory/__fixtures__/liveRun"; -import { opsRoutes } from "@/components/factory/agents/__fixtures__/opsFixtures"; -import { resetFactoryControls } from "@/components/factory/controls/useFactoryControls"; -import { - EVIDENCE_SLUG, - IDEA_SLUG, - type ProjectV2Options, - REQUESTED_SLUG, - STOPPED_SLUG, - charterVersions, - projectV2Routes, - projectsV2Snapshot, -} from "@/components/factory/project/__fixtures__/projectV2Fixtures"; -import { COCKPIT_STORAGE_KEY } from "@/components/factory/shell/cockpitV2"; -import { resetTeamStaging } from "@/components/factory/team/teamStaging"; -import type { FactorySnapshot } from "@/lib/factoryApi"; -import { FactoryPage } from "./FactoryPage"; -import { type ServedCall, json, serveFactory } from "./testServer"; - -const authenticatedFetch = vi.fn(); -vi.mock("@/lib/identity", () => ({ - authenticatedFetch: (...args: unknown[]) => authenticatedFetch(...args), -})); -vi.mock("@/hooks/useSessionUpdatesConnected", () => ({ useSessionUpdatesConnected: () => true })); -configure({ asyncUtilTimeout: 5000 }); - -let location = ""; -function LocationProbe() { - const loc = useLocation(); - location = loc.pathname + loc.search + loc.hash; - return null; -} - -interface Served { - calls: ServedCall[]; - snapshot: FactorySnapshot; - base: string; -} - -function serve( - options: { - snapshot?: ProjectV2Options; - cockpitV2?: boolean; - discovery?: boolean; - /** Extra answers, `" "` → response, given the factory's API base. */ - overrides?: (base: string, snapshot: FactorySnapshot) => Record Response>; - } = {}, -): Served { - const snapshot = projectsV2Snapshot(options.snapshot); - const fid = snapshot.factory.id; - const base = `/v1/factory/factories/${fid}`; - const server = serveFactory(authenticatedFetch, { - snapshot: () => json(snapshot), - ops: { - ...opsRoutes(fid, { snapshot }), - ...projectV2Routes(snapshot, { discovery: options.discovery }), - [`${base}/asks`]: { error: 404 }, - }, - overrides: { - "GET /v1/factory/preflight": () => - json({ ...preflightOk(), cockpit_v2: options.cockpitV2 ?? true }), - ...options.overrides?.(base, snapshot), - }, - }); - return { calls: server.calls, snapshot, base }; -} - -function renderAt(snapshot: FactorySnapshot, path: string) { - const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - return render( - - - - - - - - } - /> - - - , - ); -} - -function sent(calls: ServedCall[], method: string, suffix: string): unknown[] { - return calls.filter((c) => c.method === method && c.path.endsWith(suffix)).map((c) => c.body); -} - -beforeEach(() => { - authenticatedFetch.mockReset(); - resetFactoryControls(); - resetTeamStaging(); - window.localStorage.clear(); - window.localStorage.setItem(COCKPIT_STORAGE_KEY, "v2"); - location = ""; -}); - -describe("the project page (new cockpit)", () => { - it("leads with the outcome, the facts, why it exists, the team, then four tabs", async () => { - const { snapshot } = serve({ discovery: true }); - const { container } = renderAt(snapshot, `/projects/${EVIDENCE_SLUG}`); - const page = await screen.findByTestId(`project-page-${EVIDENCE_SLUG}`); - expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent( - "(sample) People can export a report as CSV", - ); - const headline = screen.getByTestId("project-headline"); - expect(headline).toHaveTextContent("sample-org/sample-app → main"); - // The lead said on track; a stuck member makes the facts say at risk. - const chip = within(headline).getByTestId("project-health"); - expect(chip).toHaveTextContent("At risk"); - expect(chip).toHaveAttribute( - "title", - "At risk from the facts: 1 member stuck (the lead said on track)", - ); - const why = screen.getByTestId("project-why"); - await waitFor(() => expect(why).toHaveTextContent("41 accounts")); - expect(why).toHaveTextContent("O-3 · moderate"); - expect(why).toHaveTextContent("expects +1 to +4 pp"); - expect(why).toHaveTextContent("day 7 and 28 after release"); - expect(within(page).getAllByRole("region", { name: "Team" })).toHaveLength(1); - const tabs = screen.getAllByRole("tab"); - expect(tabs.map((t) => t.textContent)).toEqual(["Overview", "Work1 PR", "History", "Charter"]); - expect(tabs[0]).toHaveAttribute("aria-selected", "true"); - expect(screen.getByRole("tabpanel")).toHaveAttribute("aria-labelledby", "project-tab-overview"); - const update = screen.getByTestId("project-latest-update"); - expect(update).toHaveTextContent("(sample) T-3 merged; the export endpoint streams rows"); - expect(update).toHaveTextContent("The facts say at risk: checks green, budget at 42 %"); - expect(screen.getByTestId("project-pr-gates-41")).toHaveTextContent( - "Checks passed · Review passed · Verify waiting · Demo waiting", - ); - expect(screen.getByTestId("project-measured")).toHaveTextContent("weekly_exports"); - expect(screen.getByTestId("project-proof")).toHaveTextContent("Demo partly passed"); - const results = await axe.run(container, { - runOnly: ["landmark-unique", "heading-order", "aria-allowed-attr", "aria-required-children"], - }); - expect(results.violations.map((v) => v.id)).toEqual([]); - }); - - it("says requested work has no evidence, offers the levers while discovery runs, and its merge", async () => { - const { snapshot } = serve({ discovery: true, snapshot: { question: true } }); - renderAt(snapshot, `/projects/${REQUESTED_SLUG}?decision=D-41`); - const why = await screen.findByTestId("project-why"); - expect(why).toHaveAttribute("data-kind", "requested"); - expect(screen.getByTestId("project-why-sentence")).toHaveTextContent( - "Requested by mia. No user evidence. Not measured.", - ); - expect(await within(why).findByRole("link", { name: /Link an opportunity/ })).toBeVisible(); - expect(within(why).getByRole("button", { name: /Add a metric/ })).toBeInTheDocument(); - expect(why.querySelectorAll("[data-missing]")).toHaveLength(4); - expect(screen.getByTestId("project-health")).toHaveTextContent("Needs your merge"); - expect(screen.getByTestId("project-waiting")).toHaveTextContent( - "(sample) Keep the old theme toggle for a week?", - ); - const merge = screen.getByTestId("project-ready-merge"); - expect(merge).toHaveTextContent("A person merges every pull request in this repo (sample)."); - expect(within(merge).getByRole("link", { name: /Open on the code host/ })).toHaveAttribute( - "href", - "https://example.com/sample-org/sample-app/pull/38", - ); - expect(screen.getByTestId("project-measured")).toHaveTextContent( - "It will not be: requested work has no metric.", - ); - }); - - it("names an agent's idea and hides the discovery levers while discovery is off", async () => { - const { snapshot } = serve(); - renderAt(snapshot, `/projects/${IDEA_SLUG}`); - expect(await screen.findByTestId("project-why-sentence")).toHaveTextContent( - "Atlas's idea, counted as a hunch. No user evidence. Not measured.", - ); - expect(screen.queryByRole("link", { name: /Link an opportunity/ })).toBeNull(); - expect(screen.getByTestId("project-update-none")).toBeInTheDocument(); - }); - - it("closes a stopped project after a confirm; its PRs stay open", async () => { - const { snapshot, calls } = serve({ - overrides: (base) => ({ - [`POST ${base}/projects/${STOPPED_SLUG}/cancel`]: () => - json({ project: { slug: STOPPED_SLUG, status: "cancelled" } }), - }), - }); - renderAt(snapshot, `/projects/${STOPPED_SLUG}`); - const card = await screen.findByTestId("project-stopped"); - expect(card).toHaveTextContent("(sample) its lead stopped and could not restart"); - fireEvent.click(within(card).getByTestId("project-close")); - fireEvent.click(within(card).getByTestId("project-close-confirm")); - await waitFor(() => - expect(sent(calls, "POST", `/projects/${STOPPED_SLUG}/cancel`)).toEqual([ - { reason: "closed from its page" }, - ]), - ); - expect(await screen.findByText(/Closed search-filters/)).toBeInTheDocument(); - }); - - it("opens the tab that holds an old section link, and every tab has its own address", async () => { - const { snapshot } = serve(); - renderAt(snapshot, `/projects/${EVIDENCE_SLUG}#prs`); - await screen.findByTestId("project-work"); - expect(location).toBe(`/factory/${snapshot.factory.id}/projects/${EVIDENCE_SLUG}/work#prs`); - expect(screen.getByTestId("project-tab-work")).toHaveAttribute("aria-selected", "true"); - fireEvent.click(screen.getByTestId("project-tab-history")); - await screen.findByTestId("project-history"); - expect(location).toBe(`/factory/${snapshot.factory.id}/projects/${EVIDENCE_SLUG}/history`); - expect(await screen.findByTestId("project-charter-history")).toHaveTextContent("C-34"); - }); - - it("lands an old #activity link on the activity card of History", async () => { - const { snapshot } = serve(); - renderAt(snapshot, `/projects/${EVIDENCE_SLUG}#activity`); - const card = await screen.findByTestId("project-activity"); - expect(location).toBe( - `/factory/${snapshot.factory.id}/projects/${EVIDENCE_SLUG}/history#activity`, - ); - expect(document.getElementById("activity")).toBe(card); - }); - - it("lands an old #decisions link on the Overview's Waiting on you card", async () => { - const { snapshot } = serve({ snapshot: { question: true } }); - renderAt(snapshot, `/projects/${REQUESTED_SLUG}#decisions`); - const card = await screen.findByTestId("project-waiting"); - expect(location).toBe(`/factory/${snapshot.factory.id}/projects/${REQUESTED_SLUG}#decisions`); - expect(document.getElementById("decisions")).toBe(card); - }); - - it("keeps the team editor route, and sends a bad tab to the overview", async () => { - const { snapshot } = serve(); - renderAt(snapshot, `/projects/${EVIDENCE_SLUG}/nowhere`); - await screen.findByTestId(`project-page-${EVIDENCE_SLUG}`); - expect(location).toBe(`/factory/${snapshot.factory.id}/projects/${EVIDENCE_SLUG}`); - }); - - it("says in one line that the work comes from user evidence", async () => { - const { snapshot } = serve(); - renderAt(snapshot, `/projects/${EVIDENCE_SLUG}`); - expect(await screen.findByTestId("project-why-sentence")).toHaveTextContent( - /^From user evidence \(O-\d+\)\. (Measured after release|Not measured yet)\.$/, - ); - }); -}); - -describe("the PR page (new cockpit)", () => { - it("says why its project's work exists: evidence, or a person's request", async () => { - const { snapshot } = serve(); - const evidence = snapshot.projects.find((p) => p.slug === EVIDENCE_SLUG)!; - const requested = snapshot.projects.find((p) => p.slug === REQUESTED_SLUG)!; - const first = renderAt(snapshot, `/prs/${evidence.pr_ids[0]}`); - const why = await screen.findByTestId("pr-why"); - expect(why).toHaveAttribute("data-kind", "evidence"); - expect(why).toHaveTextContent(/^from O-\d+/); - first.unmount(); - renderAt(snapshot, `/prs/${requested.pr_ids[0]}`); - const asked = await screen.findByTestId("pr-why"); - expect(asked).toHaveAttribute("data-kind", "requested"); - expect(asked).toHaveTextContent("No user evidence · requested by mia · not measured"); - }); -}); - -describe("the Charter tab", () => { - it("lists every version with what it changed, Undo while open and Revert after", async () => { - const { snapshot } = serve(); - renderAt(snapshot, `/projects/${EVIDENCE_SLUG}/charter`); - const card = await screen.findByTestId("project-charter-card"); - expect(card).toHaveTextContent("A person merges every pull request in this repo (sample)"); - expect(card).toHaveTextContent("kebab-case keys, off by default (sample)"); - const edited = await screen.findByTestId("charter-version-C-34"); - expect(edited).toHaveTextContent("Done when: (sample) the export works →"); - expect(within(edited).getByRole("button", { name: "Revert" })).toBeInTheDocument(); - expect(screen.getByTestId("charter-version-C-29")).toHaveTextContent("Started the project"); - expect(within(screen.getByTestId("charter-version-C-29")).queryByRole("button")).toBeNull(); - }); - - it("saves an edit as a version whose toast undoes it", { timeout: 20_000 }, async () => { - const { snapshot, calls } = serve({ - overrides: (base, snap) => ({ - [`PATCH ${base}/projects/${EVIDENCE_SLUG}`]: () => - json({ - project: {}, - changed: true, - mail_id: "mail_1", - proposal: "C-35", - undo_until: snap.server_time + 600, - }), - [`POST ${base}/proposals/C-35/undo`]: () => - json({ proposal: { ref: "C-35", status: "undone" } }), - }), - }); - renderAt(snapshot, `/projects/${EVIDENCE_SLUG}/charter`); - fireEvent.click(await screen.findByTestId("project-charter-edit")); - const goal = await screen.findByTestId("charter-goal-0"); - fireEvent.change(goal, { target: { value: "(sample) Export any report as CSV" } }); - fireEvent.click(screen.getByTestId("charter-review")); - fireEvent.click(await screen.findByTestId("charter-send")); - await waitFor(() => - expect(sent(calls, "PATCH", `/projects/${EVIDENCE_SLUG}`)).toEqual([ - { goal: "(sample) Export any report as CSV" }, - ]), - ); - expect(await screen.findByText(/^Saved as C-35\./)).toBeVisible(); - fireEvent.click(screen.getByRole("button", { name: "Undo" })); - await waitFor(() => expect(sent(calls, "POST", "/proposals/C-35/undo")).toEqual([{}])); - }); - - it("reverts an older version as a new one", async () => { - const { snapshot, calls } = serve({ - overrides: (base) => ({ - [`POST ${base}/projects/${EVIDENCE_SLUG}/charter/revert`]: () => - json({ project: {}, proposal: { ref: "C-36" }, changed: true, mail_id: null }), - }), - }); - renderAt(snapshot, `/projects/${EVIDENCE_SLUG}/charter`); - const row = await screen.findByTestId("charter-version-C-31"); - fireEvent.click(within(row).getByRole("button", { name: "Revert" })); - await waitFor(() => - expect(sent(calls, "POST", "/charter/revert")).toEqual([{ proposal: "C-31" }]), - ); - expect(await screen.findByText("Reverted C-31 as C-36.")).toBeVisible(); - }); - - it("offers Undo on a fresh edit for the rest of its 10 minutes", async () => { - const { snapshot } = serve({ - overrides: (base, snap) => ({ - [`GET ${base}/projects/${EVIDENCE_SLUG}/charter/versions`]: () => - json(charterVersions(snap.server_time, { justEdited: true })), - }), - }); - renderAt(snapshot, `/projects/${EVIDENCE_SLUG}/charter`); - expect(await screen.findByTestId("charter-undo-C-34")).toHaveTextContent(/Undo \(\d+m left\)/); - }); - - it("lets a viewer read it, with the levers hidden", async () => { - const { snapshot } = serve({ snapshot: { viewer: "viewer" } }); - renderAt(snapshot, `/projects/${EVIDENCE_SLUG}/charter`); - const card = await screen.findByTestId("project-charter-card"); - expect(card).toHaveTextContent("Ask an operator to change this."); - expect(screen.queryByTestId("project-charter-edit")).toBeNull(); - await screen.findByTestId("charter-version-C-34"); - expect(screen.queryByRole("button", { name: "Revert" })).toBeNull(); - }); -}); - -describe("the classic cockpit", () => { - it("sends a new-cockpit tab address to its section", async () => { - window.localStorage.setItem(COCKPIT_STORAGE_KEY, "v1"); - const { snapshot } = serve({ cockpitV2: false }); - renderAt(snapshot, `/projects/${EVIDENCE_SLUG}/work`); - await screen.findByTestId("project-section-nav"); - expect(location).toBe(`/factory/${snapshot.factory.id}/projects/${EVIDENCE_SLUG}#tasks`); - }); -}); diff --git a/web/src/pages/factory/ProjectsPageV2.test.tsx b/web/src/pages/factory/ProjectsPageV2.test.tsx deleted file mode 100644 index 82d292bd31f..00000000000 --- a/web/src/pages/factory/ProjectsPageV2.test.tsx +++ /dev/null @@ -1,184 +0,0 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { configure, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; -import axe from "axe-core"; -import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { preflightOk } from "@/components/factory/__fixtures__/liveRun"; -import { opsRoutes } from "@/components/factory/agents/__fixtures__/opsFixtures"; -import { resetFactoryControls } from "@/components/factory/controls/useFactoryControls"; -import { - EVIDENCE_SLUG, - IDEA_SLUG, - type ProjectV2Options, - REQUESTED_SLUG, - SHIPPED_SLUG, - STOPPED_SLUG, - projectV2Routes, - projectsV2Snapshot, -} from "@/components/factory/project/__fixtures__/projectV2Fixtures"; -import { COCKPIT_STORAGE_KEY } from "@/components/factory/shell/cockpitV2"; -import type { FactorySnapshot } from "@/lib/factoryApi"; -import { FactoryPage } from "./FactoryPage"; -import { json, serveFactory } from "./testServer"; - -const authenticatedFetch = vi.fn(); -vi.mock("@/lib/identity", () => ({ - authenticatedFetch: (...args: unknown[]) => authenticatedFetch(...args), -})); -vi.mock("@/hooks/useSessionUpdatesConnected", () => ({ useSessionUpdatesConnected: () => true })); -configure({ asyncUtilTimeout: 5000 }); - -let location = ""; -function LocationProbe() { - const loc = useLocation(); - location = loc.pathname + loc.search + loc.hash; - return null; -} - -function serve(options: ProjectV2Options = {}): FactorySnapshot { - const snapshot = projectsV2Snapshot(options); - const fid = snapshot.factory.id; - serveFactory(authenticatedFetch, { - snapshot: () => json(snapshot), - ops: { - ...opsRoutes(fid, { snapshot }), - ...projectV2Routes(snapshot), - [`/v1/factory/factories/${fid}/asks`]: { error: 404 }, - }, - overrides: { - "GET /v1/factory/preflight": () => json({ ...preflightOk(), cockpit_v2: true }), - }, - }); - return snapshot; -} - -function renderAt(snapshot: FactorySnapshot, path: string) { - const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - return render( - - - - - - - - } - /> - - - , - ); -} - -function cardSlugs(): string[] { - return [...document.querySelectorAll("article[data-testid^='project-card-']")].map((c) => - c.getAttribute("data-testid")!.replace("project-card-", ""), - ); -} - -beforeEach(() => { - authenticatedFetch.mockReset(); - resetFactoryControls(); - window.localStorage.clear(); - window.localStorage.setItem(COCKPIT_STORAGE_KEY, "v2"); - location = ""; -}); - -describe("Projects (new cockpit)", () => { - it("shows one outcome-first card per live project, and folds shipped work", async () => { - const snapshot = serve({ question: true }); - const { container } = renderAt(snapshot, "/projects"); - await screen.findByTestId("project-cards"); - expect(cardSlugs()).toEqual([EVIDENCE_SLUG, IDEA_SLUG, REQUESTED_SLUG, STOPPED_SLUG]); - const evidence = screen.getByTestId(`project-card-${EVIDENCE_SLUG}`); - expect(within(evidence).getByRole("heading", { level: 2 })).toHaveTextContent( - "(sample) People can export a report as CSV", - ); - expect(within(evidence).getByTestId(`project-card-${EVIDENCE_SLUG}-why`)).toHaveTextContent( - "from O-3 · measured by weekly_exports", - ); - expect(within(evidence).getByTestId(`project-card-${EVIDENCE_SLUG}-update`)).toHaveTextContent( - "nova: (sample) T-3 merged", - ); - expect(within(evidence).getByTestId("project-card-spend")).toHaveTextContent("$25.35 of $60"); - expect(within(evidence).getByTestId("project-team-line")).toHaveTextContent("Lead opus"); - expect(screen.getByTestId(`project-card-${IDEA_SLUG}-why`)).toHaveTextContent( - "No user evidence · Atlas's idea, counted as a hunch · not measured", - ); - expect(screen.getByTestId(`project-card-${REQUESTED_SLUG}`)).toHaveAttribute( - "data-stripe", - "amber", - ); - expect(screen.getByTestId(`project-card-${STOPPED_SLUG}`)).toHaveAttribute( - "data-stripe", - "red", - ); - expect(screen.getByTestId(`projects-shipped-${SHIPPED_SLUG}`)).toHaveTextContent( - "not measured", - ); - const results = await axe.run(container, { - runOnly: ["heading-order", "landmark-unique", "button-name", "link-name"], - }); - expect(results.violations.map((v) => v.id)).toEqual([]); - }); - - it("filters to what needs you, from evidence, requested or stopped, in the address", async () => { - const snapshot = serve({ question: true }); - renderAt(snapshot, "/projects"); - await screen.findByTestId("project-cards"); - expect(screen.getByTestId("projects-filter-all")).toHaveAttribute("aria-pressed", "true"); - expect(screen.getByTestId("projects-filter-needs")).toHaveTextContent("Needs you1"); - fireEvent.click(screen.getByTestId("projects-filter-needs")); - await waitFor(() => expect(cardSlugs()).toEqual([REQUESTED_SLUG])); - expect(location).toContain("f=needs"); - fireEvent.click(screen.getByTestId("projects-filter-requested")); - await waitFor(() => expect(cardSlugs()).toEqual([IDEA_SLUG, REQUESTED_SLUG])); - }); - - it("says when a filter matches nothing, and clears it", async () => { - const snapshot = serve({ only: [REQUESTED_SLUG] }); - renderAt(snapshot, "/projects?f=stopped"); - const empty = await screen.findByTestId("projects-empty"); - fireEvent.click(within(empty).getByRole("button", { name: "Show all projects" })); - await waitFor(() => expect(cardSlugs()).toEqual([REQUESTED_SLUG])); - }); - - it("lays the projects out as a board by stage; stopped work stays off it", async () => { - const snapshot = serve(); - renderAt(snapshot, "/projects?view=board"); - const board = await screen.findByTestId("project-board"); - expect(within(board).getByTestId("project-board-column-verify")).toHaveTextContent( - "(sample) People can export a report as CSV", - ); - expect(within(board).getByTestId("project-board-column-shipped")).toHaveTextContent( - "not measured", - ); - expect(within(board).queryByTestId(`project-board-card-${STOPPED_SLUG}`)).toBeNull(); - expect(screen.getByTestId("project-board-stopped")).toHaveTextContent( - `${STOPPED_SLUG} stopped. Not on the board.`, - ); - expect(screen.getByTestId("projects-view-board")).toHaveAttribute("aria-pressed", "true"); - }); - - it("opens an agent's panel from its ring", async () => { - const snapshot = serve(); - renderAt(snapshot, "/projects"); - const card = await screen.findByTestId(`project-card-${EVIDENCE_SLUG}`); - fireEvent.click(within(card).getByTestId("project-ring-dot")); - await waitFor(() => expect(location).toContain("peek=agent%3Adot")); - expect(await screen.findByTestId("agent-panel")).toBeInTheDocument(); - }); - - it("moves the table to Debug", async () => { - const snapshot = serve(); - renderAt(snapshot, "/projects?view=table"); - await waitFor(() => expect(location).toBe(`/factory/${snapshot.factory.id}/debug/projects`)); - expect(await screen.findByTestId("factory-debug-page")).toHaveAttribute( - "data-view", - "projects", - ); - }); -}); diff --git a/web/src/pages/factory/RoomPage.test.tsx b/web/src/pages/factory/RoomPage.test.tsx deleted file mode 100644 index 876a2a26ba3..00000000000 --- a/web/src/pages/factory/RoomPage.test.tsx +++ /dev/null @@ -1,440 +0,0 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; -import { MemoryRouter, Route, Routes } from "react-router-dom"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { LIVE_RUN_FACTORY_ID } from "@/components/factory/__fixtures__/liveRun"; -import { resetFactoryControls } from "@/components/factory/controls/useFactoryControls"; -import { - afterDeployThread, - busyThread, - chatPage, - longIncidentThread, - manyBaseMovesThread, - roomIssues, - spendIncident, -} from "@/components/factory/room/__fixtures__/roomFixtures"; -import { round3Snapshot } from "@/components/factory/shell/__fixtures__/shellFixtures"; -import type { EventView, FactorySnapshot, ViewerRole } from "@/lib/factoryApi"; -import { FactoryPage } from "./FactoryPage"; -import { type FactoryServer, SNAPSHOT, json, serveFactory } from "./testServer"; - -const authenticatedFetch = vi.fn(); -vi.mock("@/lib/identity", () => ({ - authenticatedFetch: (...args: unknown[]) => authenticatedFetch(...args), -})); -vi.mock("@/hooks/useSessionUpdatesConnected", () => ({ useSessionUpdatesConnected: () => true })); - -const FID = LIVE_RUN_FACTORY_ID; - -function setWidth(wide: boolean) { - vi.stubGlobal( - "matchMedia", - vi.fn((query: string) => ({ - matches: - query.includes("min-width: 1280px") || query.includes("min-width: 1024px") ? wide : false, - media: query, - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - })), - ); -} - -let server: FactoryServer; -function open( - path: string, - options: { - role?: ViewerRole; - snapshot?: FactorySnapshot; - overrides?: Record Response | Promise>; - events?: EventView[]; - } = {}, -) { - server = serveFactory(authenticatedFetch, { - snapshot: () => json(options.snapshot ?? round3Snapshot(options.role ?? "creator")), - overrides: options.overrides, - ...(options.events ? { events: () => ({ data: options.events!, has_more: false }) } : {}), - }); - const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - return render( - - - - } /> - - - , - ); -} - -beforeEach(() => { - authenticatedFetch.mockReset(); - resetFactoryControls(); - setWidth(true); - localStorage.clear(); -}); -afterEach(() => { - vi.unstubAllGlobals(); -}); - -const composer = () => screen.getByTestId("room-composer-input") as HTMLTextAreaElement; -const typeIn = (value: string) => - fireEvent.change(composer(), { target: { value, selectionStart: value.length } }); - -describe("Room page", () => { - it("lists threads with unread counts and shows #factory with its cards", async () => { - open("/room"); - const list = await screen.findByTestId("room-thread-list"); - const factory = await within(list).findByTestId("room-thread-factory"); - expect(factory).toHaveAttribute("aria-current", "page"); - // The open thread reads as read; the others keep their counts. - await waitFor(() => expect(within(factory).queryByTestId("room-thread-unread")).toBeNull()); - expect(within(list).getByTestId("room-thread-project:db-version")).toHaveTextContent("3"); - expect( - within(list).getByTestId(`room-thread-pr:8f15c0f9a98e5831a39615fb58f35036`), - ).toHaveTextContent("!9631"); - expect(await screen.findByTestId("proposal-card-C-4")).toHaveAttribute("data-state", "pending"); - expect(screen.getByTestId("decision-embed-D-34")).toBeInTheDocument(); - const mia = screen.getByTestId("room-message-6"); - expect(mia).toHaveTextContent("Mia"); - expect(within(mia).getByTestId("room-ref-@atlas")).toHaveTextContent("@Atlas"); - expect(mia).toHaveTextContent("ui must wait for the API"); - expect( - within(screen.getByTestId("room-body-2")).getByTestId("room-ref-C-2"), - ).toBeInTheDocument(); - expect(screen.getByTestId("room-context")).toHaveTextContent("Atlas factory"); - }); - - it("opens a project thread with its loop remedies and context", async () => { - open("/room/project%3Adb-version"); - expect(await screen.findByTestId("loop-card-D-31")).toHaveAttribute("data-state", "exhausted"); - expect(screen.getByTestId("loop-remedy-D-31-raise")).toHaveTextContent("+1 attempt"); - const context = screen.getByTestId("room-context"); - expect(context).toHaveTextContent("DB version endpoint"); - expect(within(context).getByTestId("room-open-project")).toHaveAttribute( - "href", - `/factory/${FID}/projects/db-version`, - ); - }); - - it("shows a loop remedy's refusal in the server's words, not as a lost race", async () => { - open("/room/project%3Adb-version", { - overrides: { - "POST /v1/factory/decisions/ae1fb5c75b755a4dbc645077510d63ef/answer": () => - json({ error: { code: "no_task", message: "D-31 has no task to re-dispatch" } }, 409), - }, - }); - fireEvent.click(await screen.findByTestId("loop-remedy-D-31-raise")); - const error = await screen.findByTestId("loop-remedy-error-D-31"); - expect(error).toHaveTextContent("D-31 has no task to re-dispatch"); - expect(error).not.toHaveTextContent("Already answered"); - // Still open: the remedies stay one click away. - expect(screen.getByTestId("loop-remedy-D-31-raise")).toBeEnabled(); - }); - - it("pages back through a long thread with before_seq", async () => { - const messages = busyThread("factory", 120); - open("/room", { - overrides: { - [`GET ${SNAPSHOT}/chat`]: () => - json(chatPage(messages, String(authenticatedFetch.mock.lastCall?.[0]))), - }, - }); - expect(await screen.findByTestId("room-message-1120")).toBeInTheDocument(); - expect(screen.queryByTestId("room-message-1050")).toBeNull(); - fireEvent.click(screen.getByTestId("room-load-earlier")); - expect(await screen.findByTestId("room-message-1050")).toBeInTheDocument(); - const reads = server.calls.filter((c) => c.path.includes("/chat?")); - expect(reads.some((c) => c.path.includes("before_seq=1071"))).toBe(true); - }); - - it("shows a long-running incident as one live card, its older cards as one line", async () => { - const messages = longIncidentThread(); - const issues = [spendIncident(), ...roomIssues()]; - open("/room", { - overrides: { - [`GET ${SNAPSHOT}/chat`]: () => - json(chatPage(messages, String(authenticatedFetch.mock.lastCall?.[0]))), - [`GET ${SNAPSHOT}/issues`]: () => json({ data: issues, count: issues.length, mine: [] }), - }, - }); - const card = await screen.findByTestId("issue-card-INC-14"); - expect(screen.getAllByTestId("issue-card-INC-14")).toHaveLength(1); - expect(card).toHaveTextContent("$18.33/h"); - expect(screen.getByTestId("issue-seen-INC-14")).toHaveTextContent("came back 4×"); - const repeats = screen.getByTestId("room-issue-repeats-INC-14"); - expect(repeats).toHaveAttribute("data-count", "5"); - // People's words between the cards stay where they were. - expect(screen.getByTestId("room-message-2002")).toHaveTextContent("why does the factory"); - expect(screen.getByTestId("room-message-2007")).toHaveTextContent("park the idle previews"); - // The older cards read as one line right above the live card. - const rows = within(screen.getByTestId("room-message-list")).getAllByRole("listitem"); - const at = rows.findIndex((r) => r.contains(repeats)); - expect(rows[at + 1]).toContainElement(card); - }); - - it("cleans up a Room where old incident cards and base lines alternate", async () => { - const messages = afterDeployThread(); - const issues = [spendIncident(), ...roomIssues()]; - open("/room", { - overrides: { - [`GET ${SNAPSHOT}/chat`]: () => - json(chatPage(messages, String(authenticatedFetch.mock.lastCall?.[0]))), - [`GET ${SNAPSHOT}/issues`]: () => json({ data: issues, count: issues.length, mine: [] }), - }, - }); - await screen.findByTestId("issue-card-INC-14"); - expect(screen.getByTestId("room-issue-repeats-INC-14")).toHaveAttribute("data-count", "6"); - const main = screen.getAllByTestId("room-base-moves-main"); - expect(main.map((m) => m.getAttribute("data-moves"))).toEqual(["6", "2", "2"]); - expect(main[1]).toHaveTextContent("base main (sample-infra) moved 2 times"); - expect(main[2]).toHaveTextContent("base main (sample-app) moved 2 times"); - // The older server's lines never named a repo. - expect(main[0]).toHaveTextContent(/^base main moved 6 times/); - }); - - it("folds a busy base into one line per run, every move one click away", async () => { - const messages = manyBaseMovesThread(); - open("/room", { - overrides: { - [`GET ${SNAPSHOT}/chat`]: () => - json(chatPage(messages, String(authenticatedFetch.mock.lastCall?.[0]))), - }, - }); - await screen.findByTestId("room-message-3101"); - const main = screen.getAllByTestId("room-base-moves-main"); - expect(main.map((m) => m.getAttribute("data-moves"))).toEqual(["12", "7"]); - expect(screen.getByTestId("room-base-moves-develop")).toHaveAttribute("data-moves", "3"); - expect(screen.getByTestId("room-system-3106")).toHaveTextContent("(#9631)"); - fireEvent.click(screen.getAllByTestId("room-base-moves-toggle-main")[1]!); - const list = screen.getByTestId("room-base-moves-list-main"); - expect(within(list).getAllByRole("listitem")).toHaveLength(8); - }); - - it("runs /pause as a control, never as a message", async () => { - open("/room"); - await screen.findByTestId("room-message-1"); - typeIn("/pause #db-version"); - expect(screen.getByTestId("room-composer-hint")).toHaveTextContent( - "Pause #db-version · keep previews", - ); - fireEvent.click(screen.getByTestId("room-composer-send")); - await waitFor(() => - expect(server.controls).toEqual([ - { action: "pause", target: "project:db-version", options: { keep_previews: true } }, - ]), - ); - expect(server.posts).toEqual([]); - expect(await screen.findByTestId("room-note")).toHaveTextContent( - "Pause #db-version · keep previews · sent", - ); - }); - - it("shows /status as a note only the viewer sees", async () => { - open("/room"); - await screen.findByTestId("room-message-1"); - typeIn("/status"); - fireEvent.click(screen.getByTestId("room-composer-send")); - expect(await screen.findByTestId("room-status-digest")).toHaveTextContent("Atlas factory"); - expect(server.posts).toEqual([]); - }); - - it("says why a command can't run", async () => { - open("/room"); - await screen.findByTestId("room-message-1"); - typeIn("/pause #nope"); - expect(screen.getByTestId("room-composer-hint")).toHaveTextContent("No project #nope here"); - expect(screen.getByTestId("room-composer-hint")).toHaveAttribute("data-ok", "false"); - }); - - it("completes typed tokens from the snapshot", async () => { - open("/room"); - await screen.findByTestId("room-message-1"); - typeIn("ask @pl:db"); - const menu = await screen.findByTestId("room-composer-menu"); - expect(within(menu).getByTestId("room-composer-option-@pl:db-version")).toBeInTheDocument(); - fireEvent.keyDown(composer(), { key: "Enter" }); - expect(composer().value).toBe("ask @pl:db-version "); - typeIn("see !96"); - expect( - within(await screen.findByTestId("room-composer-menu")).getAllByRole("option").length, - ).toBeGreaterThan(1); - }); - - it("interleaves the bus as one-liners when asked", async () => { - const event: EventView = { - id: "ev-bus-1", - ts: (round3Snapshot().server_time - 30) * 1000, - kind: "message.queued", - severity: "info", - project_slug: null, - from_id: "a", - to_id: "b", - from_title: "Atlas", - to_title: "Lead B · Sentinel", - subject: null, - summary: "verify !9631", - data: { kind: "request", excerpt: "verify !9631" }, - }; - open("/room", { events: [event] }); - await screen.findByTestId("room-message-1"); - expect(screen.queryByTestId("room-bus-ev-bus-1")).toBeNull(); - fireEvent.click(screen.getByTestId("room-bus-toggle")); - expect(await screen.findByTestId("room-bus-ev-bus-1")).toHaveTextContent( - "Atlas → Lead B · Sentinel · request · verify !9631", - ); - const read = server.calls.find( - (c) => c.path.includes("/events?") && c.path.includes("message.queued"), - ); - expect(read).toBeDefined(); - }); - - it("is read-only for a viewer", async () => { - open("/room", { role: "viewer" }); - await screen.findByTestId("room-message-1"); - expect(composer()).toBeDisabled(); - expect(screen.getByTestId("room-composer-identity")).toHaveTextContent( - "Operators can post in the Room", - ); - expect(await screen.findByTestId("proposal-apply-C-4")).toBeDisabled(); - }); - - it("marks the open thread read", async () => { - open("/room"); - await screen.findByTestId("room-message-15"); - await waitFor(() => - expect( - server.calls.find((c) => c.method === "PUT" && c.path.endsWith("/chat/reads"))?.body, - ).toEqual({ - thread: "factory", - last_seq: 15, - }), - ); - }); -}); - -describe("Decisions page", () => { - it("lists what is assigned to me first, then my role's, with proposal cards", async () => { - open("/decisions", { role: "operator" }); - const page = await screen.findByTestId("factory-decisions-page"); - const mine = within(page).getByTestId("decisions-mine"); - expect(within(mine).getByTestId("decision-embed-D-2")).toBeInTheDocument(); - const role = within(page).getByTestId("decisions-role"); - expect(await within(role).findByTestId("proposal-card-C-4")).toBeInTheDocument(); - expect(within(role).getByTestId("decision-embed-D-31")).toBeInTheDocument(); - const others = within(page).getByTestId("decisions-others"); - expect(within(others).getByTestId("decision-embed-D-3")).toBeInTheDocument(); - expect(within(mine).getByTestId("decision-assign-bar-D-2")).toHaveTextContent("for you"); - }); - - it("disables Assign with Claim for a viewer, and on an owners' decision for an operator", async () => { - const viewer = open("/decisions", { role: "viewer" }); - expect(await screen.findByTestId("decision-assign-D-2")).toBeDisabled(); - expect(screen.getByTestId("decision-claim-D-2")).toBeDisabled(); - viewer.unmount(); - open("/decisions", { role: "operator" }); - expect(await screen.findByTestId("decision-assign-D-34")).toBeDisabled(); - expect(screen.getByTestId("decision-assign-D-2")).toBeEnabled(); - }); - - it("filters by source", async () => { - open("/decisions"); - await screen.findByTestId("factory-decisions-page"); - // Radix Select: pick through its hidden native select. - const trigger = screen.getByTestId("decisions-filter-source"); - fireEvent.keyDown(trigger, { key: "ArrowDown" }); - fireEvent.click(await screen.findByRole("option", { name: "Loop" })); - await waitFor(() => expect(screen.queryByTestId("decision-embed-D-2")).toBeNull()); - expect(screen.getByTestId("decision-embed-D-31")).toBeInTheDocument(); - }); - - it("claims a decision", async () => { - open("/decisions", { - role: "operator", - overrides: { - "POST /v1/factory/decisions/ae1fb5c75b755a4dbc645077510d63ef/claim": () => - json({ decision: { ...round3Snapshot().decisions[2]!, claimed_by: "mia@example.com" } }), - }, - }); - fireEvent.click(await screen.findByTestId("decision-claim-D-31")); - await waitFor(() => - expect( - server.calls.some((c) => c.path.endsWith("/ae1fb5c75b755a4dbc645077510d63ef/claim")), - ).toBe(true), - ); - }); -}); - -describe("Dock decisions", () => { - it("draws a proposal as its card, whose Edit & apply opens the form without answering", async () => { - open("", { role: "operator" }); - const dock = await screen.findByTestId("room-dock"); - fireEvent.mouseDown(within(dock).getByTestId("room-dock-tab-decisions")); - const card = await within(dock).findByTestId("proposal-card-C-4"); - expect(within(dock).queryByTestId("decision-card-D-33")).toBeNull(); - fireEvent.click(within(card).getByTestId("proposal-edit-C-4")); - expect(await screen.findByTestId("edit-apply-dialog")).toBeInTheDocument(); - expect(server.calls.some((c) => c.method === "POST" && c.path.endsWith("/answer"))).toBe(false); - expect(server.calls.some((c) => c.method === "POST" && c.path.includes("/proposals/"))).toBe( - false, - ); - }); -}); - -describe("Graph page", () => { - it("draws the work graph and opens a task's inspector", async () => { - vi.stubGlobal( - "ResizeObserver", - class { - observe() {} - unobserve() {} - disconnect() {} - }, - ); - open("/graph?task=T-4"); - // 11 tasks in 5 lanes under 3 wave headers. - expect(await screen.findByTestId("graph-work")).toHaveAttribute("data-nodes", "19"); - const inspector = await screen.findByTestId("graph-task-inspector"); - expect(inspector).toHaveAttribute("data-task", "T-4"); - expect(inspector).toHaveTextContent("critical path"); - expect(within(inspector).getByTestId("graph-task-card")).toBeEnabled(); - }); - - it("scopes to one project without leaving the Graph", async () => { - vi.stubGlobal( - "ResizeObserver", - class { - observe() {} - unobserve() {} - disconnect() {} - }, - ); - open("/graph?scope=db-version"); - expect(await screen.findByTestId("factory-graph-page")).toBeInTheDocument(); - await waitFor(() => - expect( - server.calls.some( - (c) => c.path.includes("/graph?") && c.path.includes("project=db-version"), - ), - ).toBe(true), - ); - expect(screen.queryByTestId("project-page-db-version")).toBeNull(); - }); - - it("switches to the bus, flow and org graphs", async () => { - vi.stubGlobal( - "ResizeObserver", - class { - observe() {} - unobserve() {} - disconnect() {} - }, - ); - open("/graph?kind=bus"); - expect(await screen.findByTestId("graph-bus")).toHaveAttribute("data-nodes", "12"); - expect(screen.getByTestId("graph-bus-flags")).toHaveTextContent("ping-pong"); - fireEvent.mouseDown(screen.getByTestId("graph-kind-flow")); - expect(await screen.findByTestId("graph-flow")).toHaveAttribute("data-nodes", "7"); - fireEvent.mouseDown(screen.getByTestId("graph-kind-org")); - expect(await screen.findByTestId("graph-org-summary")).toHaveTextContent("41 agents"); - }); -}); diff --git a/web/src/pages/factory/TeamsPage.test.tsx b/web/src/pages/factory/TeamsPage.test.tsx index f42e7451176..8d67bbfefd6 100644 --- a/web/src/pages/factory/TeamsPage.test.tsx +++ b/web/src/pages/factory/TeamsPage.test.tsx @@ -71,44 +71,6 @@ beforeEach(() => { location = ""; }); -describe("the new cockpit switch", () => { - it("keeps Teams dark: without the server flag /teams lands on the fleet", async () => { - serve(false); - renderAt("/teams?cockpit=v2"); - await screen.findByTestId("factory-agents-page"); - expect(location).toBe(`${BASE}/agents`); - expect(screen.queryByTestId("try-teams-view")).toBeNull(); - }); - - it("offers the new view on the fleet, and a link switches this person to it", async () => { - serve(true); - renderAt("/agents"); - const link = await screen.findByTestId("try-teams-view"); - fireEvent.click(link); - await screen.findByTestId("factory-teams-page"); - expect(window.localStorage.getItem(COCKPIT_STORAGE_KEY)).toBe("v2"); - }); - - it("links back to Teams from the fleet once this person switched", async () => { - window.localStorage.setItem(COCKPIT_STORAGE_KEY, "v2"); - serve(true); - // The new cockpit keeps the fleet table in Debug (`/agents` lands on Teams › All agents). - renderAt("/debug/fleet"); - fireEvent.click(await screen.findByTestId("back-to-teams")); - await screen.findByTestId("factory-teams-page"); - expect(location).toBe(`${BASE}/teams`); - }); - - it("switches back to the classic view", async () => { - window.localStorage.setItem(COCKPIT_STORAGE_KEY, "v2"); - serve(true); - renderAt("/teams"); - fireEvent.click(await screen.findByRole("button", { name: "Back to the classic view" })); - await screen.findByTestId("factory-agents-page"); - expect(window.localStorage.getItem(COCKPIT_STORAGE_KEY)).toBe("v1"); - }); -}); - describe("Teams", () => { beforeEach(() => { window.localStorage.setItem(COCKPIT_STORAGE_KEY, "v2"); diff --git a/web/src/pages/factory/cockpitV2Routes.tsx b/web/src/pages/factory/cockpitV2Routes.tsx index 990d5e5a86e..c87a6764909 100644 --- a/web/src/pages/factory/cockpitV2Routes.tsx +++ b/web/src/pages/factory/cockpitV2Routes.tsx @@ -16,9 +16,6 @@ import { TeamEditor } from "@/components/factory/team/TeamEditor"; import { useParams, useSearchParams } from "@/lib/routing"; import { AgentPage } from "./AgentPage"; import { AgentProfilePage } from "./AgentProfilePage"; -import { HomePage } from "./HomePage"; -import { NodeRoute } from "./NodeRoute"; -import { OverviewPage } from "./OverviewPage"; import { TeamsPage } from "./TeamsPage"; function Pending() { @@ -27,14 +24,6 @@ function Pending() { ); } -/** `/`: the product's node page with cockpit v3, Home with the new cockpit, else the Overview. */ -export function HomeRoute() { - const cockpit = useCockpitV2(); - if (cockpit.loading) return ; - if (cockpit.v3) return ; - return cockpit.on ? : ; -} - /** `/teams[/:tab]`: Teams with the new cockpit, else the fleet. */ export function TeamsRoute() { const { snapshot } = useFactoryShell();