diff --git a/clients/room/src/components/ui/Hold.tsx b/clients/room/src/components/ui/Hold.tsx
new file mode 100644
index 00000000..f2662533
--- /dev/null
+++ b/clients/room/src/components/ui/Hold.tsx
@@ -0,0 +1,82 @@
+// The hold — a wait that fills a place, in the host's mark (R10,
+// `RoomWaitMark`). The mark at the centre of its place, one line 26 under it
+// and its count 4 under that, so a host's own load and the room's read as one
+// look (VibeField holds its Setup Assistant's loads so). Late, as the veil is:
+// nothing for 120ms, then the words over 180ms and the mark over 400ms — at
+// once when the host's own hold already stands there (`carried`). A new line
+// crossfades in place, 140ms out and then 240ms in; a new count under the same
+// line only counts. The mark only ever fades: it never moves or scales, and it
+// stands at rest while its work is paused. Drawn only while the host gives a
+// mark: without one the room's own waits stand, as rosetta's app shows them.
+import { useEffect, useRef, useState } from "react";
+import type { ReactNode } from "react";
+import { useHostInputs } from "../../host-inputs";
+import type { RoomWaitPlace } from "../../host-inputs";
+import "../../styles/hold.css";
+
+/** The words' leaving, before a new line comes in. */
+const LINE_OUT_MS = 140;
+
+/** A hold's words: its line, and the count under it. */
+export interface HoldWords {
+ /** What makes a new line, and so its crossfade: the words' identity, never
+ * their render (a count under the same line only counts). */
+ readonly key: string;
+ readonly line: ReactNode;
+ readonly sub?: ReactNode;
+}
+
+type LinePhase = "first" | "out" | "in";
+
+/** The line as it shows: the old words while they leave, then the new. Words
+ * that come back while their line is leaving come in again at once. */
+function useCrossfade(words: HoldWords): { readonly words: HoldWords; readonly phase: LinePhase } {
+ const [shown, setShown] = useState<{ words: HoldWords; phase: LinePhase }>({ words, phase: "first" });
+ const latest = useRef(words);
+ latest.current = words;
+ const changed = words.key !== shown.words.key;
+ const leaving = shown.phase === "out";
+ useEffect(() => {
+ if (!changed) {
+ if (leaving) setShown({ words: latest.current, phase: "in" });
+ return undefined;
+ }
+ if (!leaving) {
+ setShown((now) => ({ words: now.words, phase: "out" }));
+ return undefined;
+ }
+ const timer = setTimeout(() => setShown({ words: latest.current, phase: "in" }), LINE_OUT_MS);
+ return () => clearTimeout(timer);
+ }, [changed, leaving, words.key]);
+ return changed ? shown : { words, phase: shown.phase };
+}
+
+export function Hold({ place, words, resting = false, carried = false, leaving = false, face, act }: {
+ readonly place: Exclude
;
+ readonly words: HoldWords;
+ /** Its work is paused: the mark stands at rest. */
+ readonly resting?: boolean;
+ /** The host's own hold stood here as this one came: at once, no fade. */
+ readonly carried?: boolean;
+ /** Its wait is over and it fades away (280ms) before what comes next. */
+ readonly leaving?: boolean;
+ /** The face it stands for, as the room's other faces name theirs. */
+ readonly face: string;
+ /** An act under its words, where the wait offers one. */
+ readonly act?: ReactNode;
+}) {
+ const { waitMark } = useHostInputs();
+ const shown = useCrossfade(words);
+ return (
+
+
{waitMark?.mark(place, { resting })}
+
+
{shown.words.line}
+
{shown.words.sub}
+ {act !== undefined && act !== null &&
{act}
}
+
+
+ );
+}
diff --git a/clients/room/src/components/ui/Meter.tsx b/clients/room/src/components/ui/Meter.tsx
index b9ea454d..3e39a560 100644
--- a/clients/room/src/components/ui/Meter.tsx
+++ b/clients/room/src/components/ui/Meter.tsx
@@ -1,7 +1,10 @@
// The room's colourless meter. A real fraction fills it; an amount nobody
// knows sweeps instead (`data-indeterminate`, styled in ingest.css). A fixed
// sliver is a claim of progress that was never measured, so the room does
-// not draw one: readiness is data, never a timer.
+// not draw one: readiness is data, never a timer. Beside a host's mark (R10)
+// nothing sweeps — the mark is the sign of working — so an amount nobody knows
+// draws no meter at all; a real fraction still fills.
+import { useHostInputs } from "../../host-inputs";
export interface MeterProps {
/** 0..1 fills the meter (a sliver stays visible); null sweeps. */
@@ -15,6 +18,8 @@ export function meterWidth(fraction: number): string {
}
export function Meter({ fraction }: MeterProps) {
+ const { waitMark } = useHostInputs();
+ if (fraction === null && waitMark !== undefined) return null;
return fraction === null ? (
diff --git a/clients/room/src/components/ui/index.ts b/clients/room/src/components/ui/index.ts
index 50fdc528..91199fca 100644
--- a/clients/room/src/components/ui/index.ts
+++ b/clients/room/src/components/ui/index.ts
@@ -6,6 +6,8 @@ export { DialogShell } from "./DialogShell";
export type { DialogShellProps } from "./DialogShell";
export { EmptyState } from "./EmptyState";
export type { EmptyStateProps } from "./EmptyState";
+export { Hold } from "./Hold";
+export type { HoldWords } from "./Hold";
export { ListSummaryHeader } from "./ListSummaryHeader";
export type { ListSummaryHeaderProps } from "./ListSummaryHeader";
export { ListToolbar } from "./ListToolbar";
diff --git a/clients/room/src/host-inputs.ts b/clients/room/src/host-inputs.ts
index 79e0a67f..4b139047 100644
--- a/clients/room/src/host-inputs.ts
+++ b/clients/room/src/host-inputs.ts
@@ -231,6 +231,31 @@ export interface RoomRename {
readonly label?: string | undefined;
}
+/* ── R10: the mark the room's waits wear ───────────────────────────────── */
+
+/** Where the room waits with a host's mark: the view (its hold while it
+ * reaches the index, and through the cold start before the first project),
+ * the transcript's column (a conversation opening) and the import card's head. */
+export type RoomWaitPlace = "view" | "column" | "card";
+
+/** R10 — the host's mark for the room's waits (VibeField's living mark). With
+ * one, a wait that fills a place is a HOLD — the mark, one line under it, and
+ * its count under that — and the import card wears the mark at its head, its
+ * words kept to the step's title and its count, a fraction it knows drawn as
+ * a colourless hairline. The room's meters sweep no more beside the mark (the
+ * mark is the sign of working); a fraction the room knows still fills. The
+ * room draws the mark where it is put and never reaches into it. Absent: the
+ * room's own waits, exactly as rosetta's app shows them. */
+export interface RoomWaitMark {
+ /** The mark for one place, at rest while the work it stands for is paused
+ * (a mark moves only while its work does). */
+ readonly mark: (place: RoomWaitPlace, state: { readonly resting: boolean }) => ReactNode;
+ /** The host already shows its own hold where the room's first one stands
+ * (its load before the room's): the room's first hold stands at once, so
+ * the mark is carried, never faded out and shown again. */
+ readonly carried?: boolean | undefined;
+}
+
/* ── everything, as `HistoryRoomProps` carries it ───────────────────────── */
export interface RoomHostInputs {
@@ -265,6 +290,8 @@ export interface RoomHostInputs {
/** R9 — the address ②'s repository act opens. Absent, or undefined for a
* project: the room's own reading of `git_repo`; null: no act. */
readonly repositoryUrl?: ((projectId: string) => string | null | undefined) | undefined;
+ /** R10 — the mark the room's waits wear (`RoomWaitMark`). */
+ readonly waitMark?: RoomWaitMark | undefined;
}
/** Only the host's inputs, picked out of the room's props. */
@@ -283,6 +310,7 @@ export function hostInputsOf(props: RoomHostInputs): RoomHostInputs {
projectFilters: props.projectFilters,
sessionFilters: props.sessionFilters,
repositoryUrl: props.repositoryUrl,
+ waitMark: props.waitMark,
};
}
diff --git a/clients/room/src/index.ts b/clients/room/src/index.ts
index 8f625506..b8ddcdc7 100644
--- a/clients/room/src/index.ts
+++ b/clients/room/src/index.ts
@@ -34,6 +34,8 @@ export type {
RoomRename,
RoomSection,
RoomSessionRow,
+ RoomWaitMark,
+ RoomWaitPlace,
} from "./host-inputs";
/** The reader (PK3): one session's transcript, read-only, at a line. */
diff --git a/clients/room/src/lib/ingest-activity.ts b/clients/room/src/lib/ingest-activity.ts
index 09078dd7..3bf0b66d 100644
--- a/clients/room/src/lib/ingest-activity.ts
+++ b/clients/room/src/lib/ingest-activity.ts
@@ -127,6 +127,13 @@ export function resumedRun(previous: string | null, feed: ActivityFeed): string
const BORN_DURING_IMPORT = "Conversations you start now appear when the import finishes.";
const ARCHIVE_PAUSE = "Conversations may pause for a few seconds while the archive is saved.";
+/** What the card says while the service does not answer: its title, and the
+ * line under it (the hold says the same under a host's mark, R10). */
+export const PAUSED_UPDATES = {
+ title: "Progress updates paused",
+ detail: "Waiting for the background service. Reconnecting automatically…",
+} as const;
+
/** Percentages describe the named step, never an invented overall ETA. */
export function activityPhase(feed: ActivityFeed): ActivityPhase | null {
const ingest = feed.ingest;
diff --git a/clients/room/src/lib/use-standing-hold.ts b/clients/room/src/lib/use-standing-hold.ts
new file mode 100644
index 00000000..baccbb93
--- /dev/null
+++ b/clients/room/src/lib/use-standing-hold.ts
@@ -0,0 +1,88 @@
+// How long a hold stands (R10): it is late (nothing for 120ms, so a wait
+// answered at once shows no hold at all), and a hold that has begun to show
+// stands at least 800ms — its mark's 400 in, then a beat to read the line —
+// before it fades away over 280ms (120ms under reduced motion, as hold.css
+// fades it). What comes next waits for it, so the room's next face never draws
+// under a hold that is still there. A carried hold (the host's own stood where
+// it stands) is shown from its first frame.
+import { useEffect, useRef, useState } from "react";
+import { REDUCED_MOTION_QUERY } from "./motion";
+
+export const HOLD_LATE_MS = 120;
+export const HOLD_FLOOR_MS = 800;
+export const HOLD_LEAVE_MS = 280;
+export const HOLD_LEAVE_REDUCED_MS = 120;
+
+/** The leave as hold.css draws it: one short fade under reduced motion. */
+function leaveMs(): number {
+ const reduced = typeof window !== "undefined" && window.matchMedia?.(REDUCED_MOTION_QUERY).matches === true;
+ return reduced ? HOLD_LEAVE_REDUCED_MS : HOLD_LEAVE_MS;
+}
+
+type Phase = "hold" | "floor" | "leave" | "gone";
+
+interface Standing {
+ readonly phase: Phase;
+ /** When the hold began to show (its late passed), epoch ms. */
+ readonly shownAt: number;
+}
+
+/** What a hold does when its wait is over at `now`: a hold that never began
+ * to show is gone at once; one that has stands out its floor; past its floor
+ * it leaves. */
+export function afterWait(now: number, shownAt: number): "gone" | "floor" | "leave" {
+ if (now < shownAt) return "gone";
+ return now < shownAt + HOLD_FLOOR_MS ? "floor" : "leave";
+}
+
+/** The hold that stands now — the one asked for, or the last one while its
+ * floor and its leave run — whether it is leaving, and whether it is carried
+ * (only the room's first hold can be: one that comes back after it went is
+ * late again); null when none stands. */
+export function useStandingHold(hold: T | null, carried: boolean): {
+ readonly hold: T;
+ readonly leaving: boolean;
+ readonly carried: boolean;
+} | null {
+ const last = useRef(hold);
+ if (hold !== null) last.current = hold;
+ const shownOnce = useRef(false);
+ const wentAway = useRef(false);
+ const asked = hold !== null;
+ const [standing, setStanding] = useState(() => ({
+ phase: asked ? "hold" : "gone",
+ shownAt: Date.now() + (carried ? 0 : HOLD_LATE_MS),
+ }));
+ if (asked && standing.phase !== "hold") {
+ // a wait (again): a hold that was gone is late again; one still standing keeps its clock
+ setStanding({
+ phase: "hold",
+ shownAt: standing.phase === "gone" ? Date.now() + HOLD_LATE_MS : standing.shownAt,
+ });
+ } else if (!asked && standing.phase === "hold") {
+ setStanding({ phase: afterWait(Date.now(), standing.shownAt), shownAt: standing.shownAt });
+ }
+ useEffect(() => {
+ if (standing.phase === "floor") {
+ const timer = setTimeout(
+ () => setStanding((now) => (now.phase === "floor" ? { ...now, phase: "leave" } : now)),
+ Math.max(0, standing.shownAt + HOLD_FLOOR_MS - Date.now()),
+ );
+ return () => clearTimeout(timer);
+ }
+ if (standing.phase === "leave") {
+ const timer = setTimeout(
+ () => setStanding((now) => (now.phase === "leave" ? { ...now, phase: "gone" } : now)),
+ leaveMs(),
+ );
+ return () => clearTimeout(timer);
+ }
+ return undefined;
+ }, [standing.phase, standing.shownAt]);
+ if (standing.phase === "gone" || last.current === null) {
+ if (shownOnce.current) wentAway.current = true;
+ return null;
+ }
+ shownOnce.current = true;
+ return { hold: last.current, leaving: standing.phase === "leave", carried: carried && !wentAway.current };
+}
diff --git a/clients/room/src/styles/hold.css b/clients/room/src/styles/hold.css
new file mode 100644
index 00000000..5f523715
--- /dev/null
+++ b/clients/room/src/styles/hold.css
@@ -0,0 +1,47 @@
+/* The hold (R10, `components/ui/Hold.tsx`): a wait that fills a place, in the
+ host's mark. Rows `1fr | the mark | 1fr`: the mark at the place's centre and
+ its words 26 under it — the line 14/1.4 in the ramp's second ink, its count
+ 4 under that, 12/1.4 in the third, its 17 kept so a count arriving moves
+ nothing — in tabular figures. Late, as the veil is (120ms, then the words
+ over 180ms and the mark over 400ms); carried, at once. The mark only fades.
+ Loading is colourless: the mark's own lights are the hold's only hue. */
+.ah-hold {
+ position: absolute; inset: 0; z-index: 6;
+ display: grid; grid-template-rows: minmax(0, 1fr) auto minmax(0, 1fr); justify-items: center;
+ padding: 0 24px; text-align: center; pointer-events: none;
+}
+.ah-hold__mark {
+ grid-row: 2; display: flex; align-items: center; justify-content: center; min-height: 44px;
+ animation: ah-hold-in 400ms ease 120ms both;
+}
+.ah-hold__words {
+ grid-row: 3; display: flex; flex-direction: column; align-items: center; padding-top: 26px;
+ animation: ah-hold-in 180ms ease 120ms both;
+}
+.ah-hold__line {
+ margin: 0; color: var(--vf-text-secondary);
+ font-size: 14px; font-weight: 400; line-height: 1.4; font-variant-numeric: tabular-nums;
+}
+.ah-hold__sub {
+ margin: 4px 0 0; min-height: 17px; color: var(--vf-text-tertiary);
+ font-size: 12px; line-height: 1.4; font-variant-numeric: tabular-nums;
+}
+.ah-hold__act { margin-top: 18px; pointer-events: auto; }
+.ah-hold[data-carried] .ah-hold__mark,
+.ah-hold[data-carried] .ah-hold__words { animation: none; }
+/* A new line: the words leave (140ms), then the new ones come in (240ms). */
+.ah-hold__words[data-line="out"] { animation: ah-hold-out 140ms ease both; }
+.ah-hold__words[data-line="in"] { animation: ah-hold-in 240ms ease both; }
+/* The wait is over: the hold fades (280ms) before what comes next. */
+.ah-hold[data-leaving] .ah-hold__mark,
+.ah-hold[data-leaving] .ah-hold__words { animation: ah-hold-out 280ms ease both; }
+@keyframes ah-hold-in { from { opacity: 0; } }
+@keyframes ah-hold-out { to { opacity: 0; } }
+@media (prefers-reduced-motion: reduce) {
+ .ah-hold__mark { animation-duration: 160ms; }
+ .ah-hold__words { animation-duration: 120ms; }
+ .ah-hold__words[data-line="out"] { animation-duration: 60ms; }
+ .ah-hold__words[data-line="in"] { animation-duration: 120ms; }
+ .ah-hold[data-leaving] .ah-hold__mark,
+ .ah-hold[data-leaving] .ah-hold__words { animation-duration: 120ms; }
+}
diff --git a/clients/room/src/styles/ingest.css b/clients/room/src/styles/ingest.css
index e7ab96c3..589a981f 100644
--- a/clients/room/src/styles/ingest.css
+++ b/clients/room/src/styles/ingest.css
@@ -36,6 +36,20 @@
.ah-ingest-status[data-state="complete"] .ah-ingest-meter i { background: var(--vf-green); }
.ah-ingest-status[data-state="disconnected"] .ah-ingest-meter i,
.ah-ingest-status[data-state="search_failed"] .ah-ingest-meter i { background: var(--vf-orange); }
+/* R10 — the card in the host's mark: the mark where IMPORTING HISTORY stood,
+ the step beside it; the step's title, its count under it; a colourless 2px
+ hairline for a fraction the step knows, none for one it does not, and no
+ meter in it sweeps (the mark is the sign of working). The rest is under
+ Agent details. Loading is colourless: no step's colour fills it. */
+.ah-ingest-status[data-marked] .ah-ingest-status__eyebrow { font-size: 10px; font-weight: 500; letter-spacing: 0; color: var(--vf-text-tertiary); }
+.ah-ingest-status__mark { display: flex; align-items: center; min-height: 18px; }
+.ah-ingest-status[data-marked] h3 { margin: 10px 0 0; font-size: 13px; font-weight: 500; line-height: 1.35; letter-spacing: -.01em; }
+.ah-ingest-status__sub { margin: 3px 0 0; color: var(--vf-text-secondary); font-size: 11px; line-height: 1.45; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
+.ah-ingest-status[data-marked] .ah-ingest-meter { height: 2px; margin-top: 11px; background: var(--vf-hairline-strong); }
+.ah-ingest-status[data-marked][data-state] .ah-ingest-meter i { background: var(--vf-text-secondary); }
+.ah-ingest-status[data-marked] .ah-ingest-meter[data-indeterminate] { display: none; }
+.ah-ingest-status[data-marked] .ah-ingest-status__agents { margin-top: 10px; }
+.ah-ingest-status[data-marked] .ah-ingest-status__agents > .ah-ingest-status__detail { margin: 6px 0 8px; }
.ah-cold-start { flex: 1; }
.ah-cold-start > .ah-ingest-status { width: min(100%, 340px); box-sizing: border-box; margin: 22px auto 0; padding: 18px 20px 10px; }
.ah-cold-start .ah-ingest-status h3 { font-size: 15px; }