From 196b19c0d147fdf20a013fe70ef2ceafadc14eba Mon Sep 17 00:00:00 2001 From: James Yong Date: Sun, 8 Nov 2026 01:07:49 -0800 Subject: [PATCH] =?UTF-8?q?feat(room):=20a=20host's=20mark=20for=20the=20r?= =?UTF-8?q?oom's=20waits=20(waitMark,=20R10)=20=E2=80=94=20the=20view's=20?= =?UTF-8?q?waits=20one=20hold,=20the=20import=20card=20in=20the=20mark,=20?= =?UTF-8?q?a=20conversation's=20wait=20a=20hold=20in=20its=20column?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A host that loads in its own mark can hand the room that mark, and the room's waits then read as the host's. VibeField loads in its living mark with one line under it (track HG: HG-W1…W5 and HG-L1, its DESIGN.md §8 "History"). With no mark every wait is as rosetta's app shows it, and the reference host passes none. - `RoomWaitMark` (`host-inputs.ts`, exported with `RoomWaitPlace`): - `mark(place, { resting })` draws the mark for `"view"`, `"column"` or `"card"`, at rest while the work it stands for is paused; - `carried` says the host's own hold already stands where the room's first one comes. - The view's waits are one hold (`HistoryRoom.tsx`, `components/ui/Hold.tsx`, `styles/hold.css`): - reaching the index: `connectingTitle` over `connectionError ?? unavailable`, the face's own words; - then the cold start before the first project (`coldStartHold`): the step's title, with the ellipsis of work under way, over its amount. The first step's title stands alone. A paused import and paused updates stand at rest. - An index that has stopped answering keeps its face: a failure is no wait. - The hold's timing (`lib/use-standing-hold.ts`; `afterWait` is its pure part): - late 120ms; once shown it stands 800ms, then fades over 280ms (120ms under reduced motion); - what comes next waits for it; - a new line crossfades in place, 140ms out and 240ms in; words that come back while their line leaves come in again at once. - The import card in the mark (`IngestStatus.tsx`, `styles/ingest.css`): - the mark where IMPORTING HISTORY stood, the step beside it; - the title over its count (`readableText` where the step counts conversations, else its amount); - a fraction the step knows as a colourless 2px hairline, none for one it does not; - the detail, the amount, the recency line, the connection, the timer, the controls and the agents under Agent details. - The unmarked card is unchanged. Its live regions, footer and agent list are now shared parts (`StatusLive`, `StatusFooter`, `AgentList`), drawn by both cards. - A conversation's wait (`TranscriptLoading.tsx`) is a hold in its column, with Read available messages under its words. A failed load keeps its own face and Try again. - `Meter.tsx`: beside a mark, an amount nobody knows draws nothing. A fraction still fills, and a veil keeps its words. - `PAUSED_UPDATES` (`lib/ingest-activity.ts`) holds the paused words, which the card and the hold both read. - `README.md` documents the input. - Tests: `clients/app/test/room-wait-mark.test.mjs`, 9 tests (server renders and `afterWait`). - Run here, each file alone: room-wait-mark 9/9, dom-contract 15/15, import-state 21/21, ingest-progress 11/11, room-meters 5/5, room-not-answering 3/3, room-package 4/4, room-search-key 4/4. - The protocol's and the room's builds (`tools/npm/build.mjs`) ran too; the room's is its typecheck, with declarations. - Not run here, so CI's: the rest of `npm test`, the app's build, the docs check, `check:packages` and the Electron fixtures (room-host, cold-import). - No server render drives the crossfade's effect, and no fixture covers it. Co-Authored-By: Claude Opus 5.5 --- clients/app/test/room-wait-mark.test.mjs | 175 ++++++++++++++++++ clients/room/README.md | 24 +++ clients/room/src/HistoryRoom.tsx | 37 +++- clients/room/src/components/ColdStart.tsx | 31 ++++ clients/room/src/components/IngestStatus.tsx | 141 ++++++++++---- .../transcript/TranscriptLoading.tsx | 12 ++ clients/room/src/components/ui/Hold.tsx | 82 ++++++++ clients/room/src/components/ui/Meter.tsx | 7 +- clients/room/src/components/ui/index.ts | 2 + clients/room/src/host-inputs.ts | 28 +++ clients/room/src/index.ts | 2 + clients/room/src/lib/ingest-activity.ts | 7 + clients/room/src/lib/use-standing-hold.ts | 88 +++++++++ clients/room/src/styles/hold.css | 47 +++++ clients/room/src/styles/ingest.css | 14 ++ 15 files changed, 660 insertions(+), 37 deletions(-) create mode 100644 clients/app/test/room-wait-mark.test.mjs create mode 100644 clients/room/src/components/ui/Hold.tsx create mode 100644 clients/room/src/lib/use-standing-hold.ts create mode 100644 clients/room/src/styles/hold.css diff --git a/clients/app/test/room-wait-mark.test.mjs b/clients/app/test/room-wait-mark.test.mjs new file mode 100644 index 00000000..8cf3e024 --- /dev/null +++ b/clients/app/test/room-wait-mark.test.mjs @@ -0,0 +1,175 @@ +// R10 — the host's mark for the room's waits (`RoomWaitMark`). With one, a +// wait that fills a place is a HOLD — the mark, one line under it, its count +// under that — the import card wears the mark at its head, its words kept to +// the step's title and its count and a fraction it knows drawn as a +// colourless hairline, and nothing sweeps beside the mark; a paused wait's +// mark stands at rest. With none, every wait is the room's own, as rosetta's +// app shows it (room-meters and room-not-answering hold those). The server +// render covers the faces; the hold's timing is `afterWait`'s. +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; +import { HistoryRoom } from "../../room/src/HistoryRoom.tsx"; +import { coldStartHold } from "../../room/src/components/ColdStart.tsx"; +import { IngestStatus } from "../../room/src/components/IngestStatus.tsx"; +import { TranscriptLoading } from "../../room/src/components/transcript/TranscriptLoading.tsx"; +import { Meter, Veil } from "../../room/src/components/ui/index.ts"; +import { createDemoPorts } from "../../room/src/fixtures/index.ts"; +import { HostInputsProvider } from "../../room/src/host-inputs.ts"; +import { PAUSED_UPDATES, activityPhase } from "../../room/src/lib/ingest-activity.ts"; +import { HOLD_FLOOR_MS, HOLD_LATE_MS, afterWait } from "../../room/src/lib/use-standing-hold.ts"; +import { RoomProvider } from "../../room/src/room-context.tsx"; + +const ROOM = fileURLToPath(new URL("../../room/", import.meta.url)); +const fromRoom = createRequire(path.join(ROOM, "package.json")); +const { createElement } = fromRoom("react"); +const { renderToStaticMarkup } = fromRoom("react-dom/server"); + +/** A host's mark that says where it was put and whether it rests. */ +const waitMark = (extra = {}) => ({ + mark: (place, { resting }) => createElement("i", { "data-mark": place, "data-resting": String(resting) }), + ...extra, +}); +/** A part in the room, as HistoryRoom places it (the import card's controls read the room's ports). */ +const inRoom = (element) => createElement(RoomProvider, { ports: createDemoPorts(), options: { storage: null } }, element); +const marked = (element) => createElement(HostInputsProvider, { value: { waitMark: waitMark() } }, inRoom(element)); + +const runtime = (overrides = {}) => ({ kind: "codex", state: "streaming", files_total: 100, + files_skimmed: 100, sessions_total: 80, bytes_total: 1_000_000, bytes_streamed: 400_000, + records: 300, ...overrides }); +const feed = (overrides = {}) => ({ ingest: { phase: "backfilling", projects: [], runtimes: [runtime()] }, + canonicalReady: false, searchReadiness: "building", searchProgress: null, connectionError: null, + startedAt: 1, uptimeMs: 5_000, receivedAt: 1_000, ...overrides }); +const activityOf = (overrides = {}) => { + const given = feed(overrides); + return { phase: activityPhase(given), feed: given, footer: "Connected", elapsed: "2m 10s" }; +}; +const loading = (overrides = {}) => ({ + error: null, waitingForTeam: false, seeking: false, canContinue: false, indexing: false, + onRetry() {}, onContinue() {}, ...overrides, +}); + +test("with no mark the room keeps its own waits: the connecting face sweeps and the card says IMPORTING HISTORY", () => { + const room = renderToStaticMarkup(createElement(HistoryRoom, { ...createDemoPorts(), storage: null })); + assert.doesNotMatch(room, /ah-hold/); + assert.match(room, /data-face="connecting"/); + assert.match(room, /data-indeterminate=""/); + const card = renderToStaticMarkup(inRoom(createElement(IngestStatus, { activity: activityOf() }))); + assert.match(card, /IMPORTING HISTORY/); + assert.doesNotMatch(card, /data-marked/); +}); + +test("in the host's mark, reaching the index is a hold: the mark, the host's title, its words under it, no sweep", () => { + const markup = renderToStaticMarkup(createElement(HistoryRoom, { + ...createDemoPorts(), + storage: null, + connectingTitle: "Opening History…", + unavailable: "The agent index is starting", + waitMark: waitMark(), + })); + assert.match(markup, /
<\/i>/); + assert.match(markup, + /

Opening History…<\/p>

The agent index is starting<\/p>/); + assert.doesNotMatch(markup, /data-indeterminate/, "the mark is the sign of working: nothing sweeps beside it"); + assert.doesNotMatch(markup, /class="ah-empty"/, "the hold stands in the face's place"); + assert.doesNotMatch(markup, /data-carried/, "late, as the veil is, unless the host's own hold stands there"); +}); + +test("a host whose own hold already stands has the room's first hold carried: no late, no fade", () => { + const markup = renderToStaticMarkup(createElement(HistoryRoom, { + ...createDemoPorts(), storage: null, waitMark: waitMark({ carried: true }), + })); + assert.match(markup, /

{ + const asked = 10_000; + const shownAt = asked + HOLD_LATE_MS; + assert.equal(afterWait(asked + 50, shownAt), "gone", "a wait answered before the hold showed shows none"); + assert.equal(afterWait(shownAt, shownAt), "floor"); + assert.equal(afterWait(shownAt + HOLD_FLOOR_MS - 1, shownAt), "floor", "a hold that showed stands 800ms"); + assert.equal(afterWait(shownAt + HOLD_FLOOR_MS, shownAt), "leave"); +}); + +test("the cold start holds the room's own step words, the ellipsis of work under way, its count under them", () => { + assert.deepEqual(coldStartHold(null), { words: { key: "discovering", line: "Finding your history…" }, resting: false }); + const discovering = { + phase: { key: "discovering", title: "Finding your history", detail: "", amount: "Looking for conversations…", + step: 1, current: 0, total: 0, unit: null, fraction: null, active: true }, + feed: feed(), footer: "", elapsed: "", + }; + assert.deepEqual(coldStartHold(discovering).words, + { key: "discovering", line: "Finding your history…", sub: null }, + "the first step's title stands alone: its amount counts nothing and says the same wait again"); + const metadata = activityOf({ ingest: { phase: "backfilling", projects: [], + runtimes: [runtime({ state: "skimming", files_skimmed: 1_333, files_total: 1_667 })] } }); + assert.deepEqual(coldStartHold(metadata), + { words: { key: "metadata", line: "Discovering conversations…", sub: "1,333 files checked" }, resting: false }); + // motion means working: a paused import says its own title, at rest; silent updates the card's words, at rest + const paused = activityOf({ ingest: { phase: "backfilling", projects: [], runtimes: [runtime()], + run: { state: "paused" } } }); + assert.deepEqual(coldStartHold(paused), + { words: { key: "paused", line: "History import paused", sub: paused.phase.amount }, resting: true }); + const silent = activityOf({ connectionError: "connection refused" }); + assert.deepEqual(coldStartHold(silent), + { words: { key: "paused-updates", line: PAUSED_UPDATES.title, sub: PAUSED_UPDATES.detail }, resting: true }); +}); + +test("the import card wears the mark where IMPORTING HISTORY stood and keeps its words to the title and its count", () => { + const reading = activityOf(); + const markup = renderToStaticMarkup(marked(createElement(IngestStatus, { activity: reading }))); + assert.match(markup, /
<\/i><\/span>Step 2 of 4<\/span>/); + assert.doesNotMatch(markup, /IMPORTING HISTORY/); + const head = markup.slice(0, markup.indexOf("${reading.phase.title}

${reading.phase.amount}

`)); + // a fraction the step knows is the hairline; the detail, the amount, the connection and the timer fold away + assert.match(head, /role="progressbar"[^>]*aria-valuenow="40"/); + assert.doesNotMatch(head, /ah-ingest-status__detail|ah-ingest-status__amount|ah-ingest-status__footer/); + const folded = markup.slice(markup.indexOf("Agent details/); + for (const part of ["ah-ingest-status__detail", "ah-ingest-status__amount", "ah-ingest-status__footer"]) { + assert.match(folded, new RegExp(part), `${part} under Agent details`); + } + assert.match(folded, /2m 10s/); +}); + +test("beside the mark a step that knows no fraction draws no bar, and silent updates stand the mark at rest", () => { + const organizing = activityOf({ ingest: { phase: "backfilling", projects: [], runtimes: [runtime({ state: "applying" })] } }); + assert.equal(organizing.phase.fraction, null); + const quiet = renderToStaticMarkup(marked(createElement(IngestStatus, { activity: organizing }))); + assert.doesNotMatch(quiet.slice(0, quiet.indexOf("<\/i>/); + assert.match(silent, new RegExp(`

${PAUSED_UPDATES.title}

${PAUSED_UPDATES.detail}

`)); +}); + +test("beside the mark a meter that knows no amount draws nothing, one that knows fills, and a veil keeps its words", () => { + assert.equal(renderToStaticMarkup(marked(createElement(Meter, { fraction: null }))), ""); + assert.equal( + renderToStaticMarkup(marked(createElement(Meter, { fraction: 0.25 }))), + '', + ); + const veil = renderToStaticMarkup(marked(createElement(Veil, { label: "Preparing activity summaries…", progress: null }))); + assert.match(veil, /Preparing activity summaries…<\/span>/); + assert.doesNotMatch(veil, /data-indeterminate/); +}); + +test("a conversation's wait is a hold in its column; a load that failed keeps its own face and Try again", () => { + const waiting = renderToStaticMarkup(marked(createElement(TranscriptLoading, loading()))); + assert.match(waiting, /
/); + assert.match(waiting, /
<\/i>/); + assert.match(waiting, /

Loading messages…<\/p>/); + assert.doesNotMatch(waiting, /tr-loading__sketch|tr-loading__meter/); + const continuing = renderToStaticMarkup(marked(createElement(TranscriptLoading, loading({ waitingForTeam: true, canContinue: true })))); + assert.match(continuing, /

{searchOption !== null && ); } + +/** + * The cold start as a hold (R10): before the first project the room's own + * words for the step stand under the host's mark — the step's title, with the + * ellipsis of work under way, over its amount ("Discovering conversations…" + * over "1,333 files checked"; "History import paused" over "80 sessions + * found"). The first step's title stands alone: its amount ("Looking for + * conversations…") counts nothing and says the same wait again. Paused updates + * say the card's words, the mark at rest; a paused import its own title, at + * rest too. + */ +export function coldStartHold(activity: IngestActivity | null): { + readonly words: HoldWords; + readonly resting: boolean; +} { + if (activity === null) return { words: { key: "discovering", line: "Finding your history…" }, resting: false }; + const { phase, feed } = activity; + if (feed.connectionError !== null) { + return { words: { key: "paused-updates", line: PAUSED_UPDATES.title, sub: PAUSED_UPDATES.detail }, resting: true }; + } + return { + words: { + key: phase.key, + line: phase.active ? `${phase.title}…` : phase.title, + sub: phase.key === "discovering" ? null : phase.amount, + }, + resting: !phase.active, + }; +} diff --git a/clients/room/src/components/IngestStatus.tsx b/clients/room/src/components/IngestStatus.tsx index 5c784db0..fffa95b7 100644 --- a/clients/room/src/components/IngestStatus.tsx +++ b/clients/room/src/components/IngestStatus.tsx @@ -1,8 +1,12 @@ import { useId, useLayoutEffect, useRef } from "react"; import type { ReactNode } from "react"; +import { useHostInputs } from "../host-inputs"; +import type { RoomWaitMark } from "../host-inputs"; import { AgentMark, agentLabel } from "../lib/agents"; -import { phaseHeading, readyConversations, runtimeActivity } from "../lib/ingest-activity"; -import type { IngestActivity } from "../lib/ingest-activity"; +import { + PAUSED_UPDATES, phaseHeading, readableText, readyConversations, runtimeActivity, +} from "../lib/ingest-activity"; +import type { ActivityFeed, ActivityPhase, IngestActivity } from "../lib/ingest-activity"; import { IngestionControls } from "./IngestionControls"; function ProgressMeter({ value, label, paused = false }: { @@ -18,6 +22,7 @@ function ProgressMeter({ value, label, paused = false }: { export function IngestStatus({ activity }: { readonly activity: IngestActivity | null }) { const titleId = useId(); + const { waitMark } = useHostInputs(); if (!activity) return null; const { phase, feed, footer, elapsed } = activity; const interrupted = feed.connectionError !== null; @@ -33,6 +38,7 @@ export function IngestStatus({ activity }: { readonly activity: IngestActivity |

{phase.title}

); + if (waitMark !== undefined) return ; return ( // `data-ready-count` / `data-ready-total` are the readable conversations // and their total, carried (inert) even where the copy leaves them out. @@ -43,21 +49,13 @@ export function IngestStatus({ activity }: { readonly activity: IngestActivity | {ready ? "ALL SET" : "IMPORTING HISTORY"} {ready ? "✓" : `Step ${phase.step} of 4`}
- {/* The counts move with every tree the import opens: drawn, never - announced. The live regions say the step and each recency span - reached, nothing else. */} - - {interrupted ? "Progress updates paused" : phase.spoken ?? phase.title} - - - {phase.horizon?.reached ?? ""} - +

- {interrupted ? "Progress updates paused" : phase.title} + {interrupted ? PAUSED_UPDATES.title : phase.title} {!interrupted && phase.count ? {phase.count} : null}

- {interrupted ? "Waiting for the background service. Reconnecting automatically…" : phase.detail} + {interrupted ? PAUSED_UPDATES.detail : phase.detail}

{phase.amount} @@ -65,37 +63,114 @@ export function IngestStatus({ activity }: { readonly activity: IngestActivity |
{horizon && } -
- {footer} - {elapsed} -
+ {/* Past publication (search, step 4) every agent is done: the card keeps to the search it waits for, and leaves the column room for the portfolio above it. */} {phase.step < 4 && (feed.ingest?.runtimes.length ?? 0) > 0 &&
Agent details -
    - {feed.ingest?.runtimes.map((runtime) => { - const status = runtimeActivity(runtime, feed.ingest?.phase === "restoring"); - return
  • - -
    -
    {agentLabel(runtime.kind)} - {runtime.state === "done" && ✓} -
    - {status.label} - {runtime.state !== "done" && } -
    -
  • ; - })} -
+
} ); } +/** The counts move with every tree the import opens: drawn, never announced. + * The live regions say the step and each recency span reached, nothing else. */ +function StatusLive({ phase, interrupted }: { readonly phase: ActivityPhase; readonly interrupted: boolean }) { + return <> + + {interrupted ? PAUSED_UPDATES.title : phase.spoken ?? phase.title} + + + {phase.horizon?.reached ?? ""} + + ; +} + +/** The connection and the timer. */ +function StatusFooter({ feed, footer, elapsed }: { + readonly feed: ActivityFeed; readonly footer: string; readonly elapsed: string; +}) { + return
+ {footer} + {elapsed} +
; +} + +/** Each agent's own step, under Agent details. */ +function AgentList({ feed, paused }: { readonly feed: ActivityFeed; readonly paused: boolean }) { + return
    + {feed.ingest?.runtimes.map((runtime) => { + const status = runtimeActivity(runtime, feed.ingest?.phase === "restoring"); + return
  • + +
    +
    {agentLabel(runtime.kind)} + {runtime.state === "done" && ✓} +
    + {status.label} + {runtime.state !== "done" && } +
    +
  • ; + })} +
; +} + +/** + * The card in the host's mark (R10): it keeps its place at the head of the + * first column and wears the mark where IMPORTING HISTORY stood, the step + * beside it. Its words keep to the step's title and its count; a fraction the + * step knows is a colourless hairline, and a step that knows none draws no + * bar, the mark being the sign of working. The detail, the amount, the + * recency line, the connection and the timer fold under Agent details, which + * stand through search as well. Paused updates stand the mark at rest. + */ +function MarkedIngestStatus({ activity, waitMark, titleId }: { + readonly activity: IngestActivity; + readonly waitMark: RoomWaitMark; + readonly titleId: string; +}) { + const { phase, feed, footer, elapsed } = activity; + const interrupted = feed.connectionError !== null; + const paused = interrupted || !phase.active; + const count = readyConversations(feed); + const horizon = interrupted ? null : phase.horizon ?? null; + const runtimes = phase.step < 4 ? (feed.ingest?.runtimes.length ?? 0) : 0; + const sub = interrupted ? PAUSED_UPDATES.detail + : phase.count ? readableText(count) : phase.amount; + return ( +
+
+ {waitMark.mark("card", { resting: paused })} + {`Step ${phase.step} of 4`} +
+ +

{interrupted ? PAUSED_UPDATES.title : phase.title}

+

{sub}

+ {phase.fraction !== null && ( + + )} +
+ Agent details + {!interrupted &&

{phase.detail}

} +
+ {phase.amount} + {phase.fraction !== null && {Math.floor(phase.fraction * 100)}%} +
+ {horizon && } + + + {runtimes > 0 && } +
+
+ ); +} + /** * The card's place in the projects column, whose height follows the card's * and eases when the end changes it: ALL SET's collapse to one line, then diff --git a/clients/room/src/components/transcript/TranscriptLoading.tsx b/clients/room/src/components/transcript/TranscriptLoading.tsx index a41b2201..7b0eabe2 100644 --- a/clients/room/src/components/transcript/TranscriptLoading.tsx +++ b/clients/room/src/components/transcript/TranscriptLoading.tsx @@ -1,3 +1,5 @@ +import { useHostInputs } from "../../host-inputs"; +import { Hold } from "../ui/Hold"; import "../../styles/transcript-loading.css"; /** Which wait the loader shows: its words, keyed by the inert @@ -24,6 +26,16 @@ export function TranscriptLoading({ error, waitingForTeam, seeking, canContinue, : seeking ? "seeking" : indexing ? "preparing" : "loading"; const label = LOADING_LABELS[shown]; + const { waitMark } = useHostInputs(); + // R10 — in the host's mark, a conversation's wait is a hold in its column: + // the mark and its words, no sketch and no sweep. A load that failed is no + // wait, so it keeps the loader's own face with Try again. + if (waitMark !== undefined && error === null) { + return
+ Read available messages : null} /> +
; + } return