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