@vibefield/rosetta-room 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -23,7 +23,7 @@ import { sessionFilePaths } from "./components/git/index.js";
23
23
  import { useWorktreeGuest, WorktreeColumn } from "./components/worktree/index.js";
24
24
  import { useDaemonStatus, useMeta, useProjectStats, useProjectUsage, useSearch, useTimeline, } from "./lib/hooks.js";
25
25
  import { useIngestActivity } from "./lib/use-ingest-activity.js";
26
- import { isIngestActive } from "./lib/ingest.js";
26
+ import { isAwaitingConsent, isIngestActive } from "./lib/ingest.js";
27
27
  import { UNASSIGNED_PROJECT_ID, buildProjectViewModel, getActivityGridStartTimestamp, projectKey, unassignedProject, } from "./lib/model.js";
28
28
  import { compareOrderedSessions } from "./lib/activity-order.js";
29
29
  import { useActivityOrder } from "./lib/use-activity-order.js";
@@ -423,7 +423,7 @@ function RoomBody({ handle, surface, search: searchOption, unavailable, consent,
423
423
  : { query: search.query, at: stepper.at, total: stepper.hits.length };
424
424
  const disconnected = meta === null;
425
425
  // The roll call: discovery is on the board and nothing has been read.
426
- const awaitingConsent = ingest?.phase === "awaiting_consent";
426
+ const awaitingConsent = isAwaitingConsent(ingest);
427
427
  // The first run before its first metadata batch: the daemon answers and
428
428
  // meters move, but there is not yet a canonical project to render. Once
429
429
  // RAM metadata arrives the ordinary room takes over progressively.
@@ -434,7 +434,7 @@ function RoomBody({ handle, surface, search: searchOption, unavailable, consent,
434
434
  setProjectId(id);
435
435
  setSessionId(null);
436
436
  setInspectedThread(null);
437
- } }), _jsx(SessionsColumn, { project: projectId ? (projectById.get(projectId) ?? null) : null, sessions: projectSessions, factsBySessionId: facts, getRuntimeKind: kindOf, selectedSessionId: sessionId, usage: usage, activityGridStartTimestamp: getActivityGridStartTimestamp(nowMs), currentTimestamp: nowMs, ingest: ingest, isWorktreeVisible: worktree.available && projectRoot !== null ? worktree.visible : null, onToggleWorktree: worktree.toggle, onSessionSelect: (selectedSession) => { setSessionId(selectedSession.id); setInspectedThread(null); }, hostActs: projectId === null ? null : (hostProjectActs?.(projectId, { folder: shownFolderKey }) ?? null), hostCard: projectId === null || hostProjectCard === undefined ? null : hostProjectCard(projectId) }), _jsx(WorktreeColumn, { projectRoot: worktreeRoot, folders: folderMenu === null ? null : { menu: folderMenu, shown: shownFolder }, tint: projectId ? (projectById.get(projectId)?.tint ?? null) : null, homeDirectory: home, sessionPaths: sessionPaths, currentTimestamp: nowMs, onReceipt: showReceipt, requestError: worktree.error, onRetry: worktree.retry }), _jsx(TranscriptColumn, { session: session, duringBackfill: ingestActive, sessions: meta?.sessions ?? [], project: session ? (projectById.get(projectKey(session)) ?? null) : null, feed: feed, focus: focus, onFocusConsumed: () => setFocus(null), onReceipt: showReceipt, emptyPrompt: emptyPrompt === undefined ? emptyTranscriptPrompt(searchKey) : emptyPrompt, stepper: stepperState, onStepMatch: stepMatch, onDismissStepper: () => setStepper(null), getRuntimeKind: kindOf, homeDirectory: home, onOpenThread: openThread, onBackToParent: session?.parent_session_id == null ? null : () => openThread(session.parent_session_id), headActs: session === null ? null : (hostSessionActs?.(session) ?? null) })] }))] }), searchOption !== null && _jsx(SearchSheet, { open: sheetOpen, onClose: () => setSheetOpen(false), query: query, onQuery: setQuery, filters: filters, onFilters: setFilters, feed: search, results: searchResults, readiness: readiness, progress: searchProgress, canonicalReady: canonicalReady, ingestPaused: ingest?.phase === "metadata_ready", agentKinds: kinds, currentProject: projectId !== null && projectId !== UNASSIGNED_PROJECT_ID
437
+ } }), _jsx(SessionsColumn, { project: projectId ? (projectById.get(projectId) ?? null) : null, sessions: projectSessions, factsBySessionId: facts, getRuntimeKind: kindOf, selectedSessionId: sessionId, usage: usage, activityGridStartTimestamp: getActivityGridStartTimestamp(nowMs), currentTimestamp: nowMs, ingest: ingest, isWorktreeVisible: worktree.available && projectRoot !== null ? worktree.visible : null, onToggleWorktree: worktree.toggle, onSessionSelect: (selectedSession) => { setSessionId(selectedSession.id); setInspectedThread(null); }, hostActs: projectId === null ? null : (hostProjectActs?.(projectId, { folder: shownFolderKey }) ?? null), hostCard: projectId === null || hostProjectCard === undefined ? null : hostProjectCard(projectId) }), _jsx(WorktreeColumn, { projectRoot: worktreeRoot, folders: folderMenu === null ? null : { menu: folderMenu, shown: shownFolder }, tint: projectId ? (projectById.get(projectId)?.tint ?? null) : null, homeDirectory: home, sessionPaths: sessionPaths, currentTimestamp: nowMs, onReceipt: showReceipt, requestError: worktree.error, onRetry: worktree.retry }), _jsx(TranscriptColumn, { session: session, duringBackfill: ingestActive, sessions: meta?.sessions ?? [], project: session ? (projectById.get(projectKey(session)) ?? null) : null, feed: feed, focus: focus, onFocusConsumed: () => setFocus(null), onReceipt: showReceipt, emptyPrompt: emptyPrompt === undefined ? emptyTranscriptPrompt(searchKey) : emptyPrompt, stepper: stepperState, onStepMatch: stepMatch, onDismissStepper: () => setStepper(null), getRuntimeKind: kindOf, homeDirectory: home, onOpenThread: openThread, onBackToParent: session?.parent_session_id == null ? null : () => openThread(session.parent_session_id), headActs: session === null ? null : (hostSessionActs?.(session) ?? null) })] }))] }), searchOption !== null && _jsx(SearchSheet, { open: sheetOpen, onClose: () => setSheetOpen(false), query: query, onQuery: setQuery, filters: filters, onFilters: setFilters, feed: search, results: searchResults, readiness: readiness, progress: searchProgress, canonicalReady: canonicalReady, ingestPaused: ingest?.phase === "metadata_ready" || ingest?.run?.state === "paused" || ingest?.run?.state === "failed", agentKinds: kinds, currentProject: projectId !== null && projectId !== UNASSIGNED_PROJECT_ID
438
438
  ? (projectById.get(projectId) ?? null)
439
439
  : null, homeDirectory: home, nowMs: nowMs, getRuntimeKind: kindOf, onOpenHit: openHit }), receipt !== null && _jsx("div", { className: "ah-receipt", children: receipt })] }) }));
440
440
  }
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useId } from "react";
3
3
  import { AgentMark, agentLabel } from "../lib/agents.js";
4
4
  import { runtimeActivity } from "../lib/ingest-activity.js";
5
+ import { IngestionControls } from "./IngestionControls.js";
5
6
  function ProgressMeter({ value, label, paused = false }) {
6
7
  return _jsx("div", { className: "ah-ingest-meter", role: "progressbar", "aria-label": label, "aria-valuemin": 0, "aria-valuemax": 100, "aria-valuenow": value === null ? undefined : Math.floor(value * 100), "data-indeterminate": value === null ? "" : undefined, "data-paused": paused ? "" : undefined, children: _jsx("i", { style: value === null ? undefined : { width: `${value * 100}%` } }) });
7
8
  }
@@ -13,7 +14,7 @@ export function IngestStatus({ activity }) {
13
14
  const interrupted = feed.connectionError !== null;
14
15
  const ready = phase.key === "ready";
15
16
  const paused = interrupted || !phase.active;
16
- return (_jsxs("section", { className: "ah-ingest-status", "aria-labelledby": titleId, "data-state": interrupted ? "disconnected" : ready ? "complete" : phase.key, children: [_jsxs("div", { className: "ah-ingest-status__eyebrow", children: [_jsx("span", { children: ready ? "ALL SET" : "IMPORTING HISTORY" }), _jsx("span", { children: ready ? "✓" : `Step ${phase.step} of 4` })] }), _jsx("h3", { id: titleId, "aria-live": "polite", "aria-atomic": "true", children: interrupted ? "Progress updates paused" : phase.title }), _jsx("p", { className: "ah-ingest-status__detail", children: interrupted ? "Waiting for the background service. Reconnecting automatically…" : phase.detail }), _jsxs("div", { className: "ah-ingest-status__amount", children: [_jsx("span", { children: phase.amount }), phase.fraction !== null && !ready && _jsxs("b", { children: [Math.floor(phase.fraction * 100), "%"] })] }), _jsx(ProgressMeter, { value: phase.fraction, label: phase.title, paused: paused }, phase.key), _jsxs("div", { className: "ah-ingest-status__footer", children: [_jsx("span", { title: feed.connectionError ?? (footer.includes("No new") ? "The service is connected; its work counters have not changed." : undefined), children: footer }), _jsx("span", { children: elapsed })] }), !ready && (feed.ingest?.runtimes.length ?? 0) > 0 && _jsxs("details", { className: "ah-ingest-status__agents", children: [_jsxs("summary", { children: ["Agent details ", _jsx("span", { "aria-hidden": "true", children: "\u2304" })] }), _jsx("ul", { children: feed.ingest?.runtimes.map((runtime) => {
17
+ return (_jsxs("section", { className: "ah-ingest-status", "aria-labelledby": titleId, "data-state": interrupted ? "disconnected" : ready ? "complete" : phase.key, children: [_jsxs("div", { className: "ah-ingest-status__eyebrow", children: [_jsx("span", { children: ready ? "ALL SET" : "IMPORTING HISTORY" }), _jsx("span", { children: ready ? "✓" : `Step ${phase.step} of 4` })] }), _jsx("h3", { id: titleId, "aria-live": "polite", "aria-atomic": "true", children: interrupted ? "Progress updates paused" : phase.title }), _jsx("p", { className: "ah-ingest-status__detail", children: interrupted ? "Waiting for the background service. Reconnecting automatically…" : phase.detail }), _jsxs("div", { className: "ah-ingest-status__amount", children: [_jsx("span", { children: phase.amount }), phase.fraction !== null && !ready && _jsxs("b", { children: [Math.floor(phase.fraction * 100), "%"] })] }), _jsx(ProgressMeter, { value: phase.fraction, label: phase.title, paused: paused }, phase.key), _jsxs("div", { className: "ah-ingest-status__footer", children: [_jsx("span", { title: feed.connectionError ?? (footer.includes("No new") ? "The service is connected; its work counters have not changed." : undefined), children: footer }), _jsx("span", { children: elapsed })] }), _jsx(IngestionControls, { run: feed.ingest?.run }), !ready && (feed.ingest?.runtimes.length ?? 0) > 0 && _jsxs("details", { className: "ah-ingest-status__agents", children: [_jsxs("summary", { children: ["Agent details ", _jsx("span", { "aria-hidden": "true", children: "\u2304" })] }), _jsx("ul", { children: feed.ingest?.runtimes.map((runtime) => {
17
18
  const status = runtimeActivity(runtime, feed.ingest?.phase === "restoring");
18
19
  return _jsxs("li", { children: [_jsx(AgentMark, { kind: runtime.kind }), _jsxs("div", { children: [_jsxs("div", { className: "ah-ingest-status__agent-name", children: [_jsx("strong", { children: agentLabel(runtime.kind) }), runtime.state === "done" && _jsx("span", { "aria-label": "Ready to browse", children: "\u2713" })] }), _jsx("span", { className: "ah-ingest-status__agent-caption", children: status.label }), runtime.state !== "done" && _jsx(ProgressMeter, { value: status.fraction, label: `${agentLabel(runtime.kind)}: ${status.label}`, paused: paused || runtime.state === "queued" })] })] }, runtime.kind);
19
20
  }) })] })] }));
@@ -0,0 +1,6 @@
1
+ import type { Ingestion } from "@vibefield/rosetta-protocol";
2
+ /** A host may supply observation without mutation authority. Pending intent
3
+ * keeps its key, revision and index across uncertain transport outcomes. */
4
+ export declare function IngestionControls({ run }: {
5
+ readonly run: Ingestion | null | undefined;
6
+ }): import("react").JSX.Element | null;
@@ -0,0 +1,75 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useRef, useState } from "react";
3
+ import { ApiRequestError } from "@vibefield/rosetta-protocol";
4
+ import { useRoom } from "../room-context.js";
5
+ /** A host may supply observation without mutation authority. Pending intent
6
+ * keeps its key, revision and index across uncertain transport outcomes. */
7
+ export function IngestionControls({ run }) {
8
+ const { ingestion } = useRoom();
9
+ const [intent, setIntent] = useState(null);
10
+ const [busy, setBusy] = useState(false);
11
+ const [error, setError] = useState(null);
12
+ const pending = useRef(null);
13
+ useEffect(() => {
14
+ setIntent(null);
15
+ setBusy(false);
16
+ setError(null);
17
+ return () => {
18
+ pending.current?.abort();
19
+ pending.current = null;
20
+ };
21
+ }, [run?.index_id, run?.id, ingestion]);
22
+ useEffect(() => {
23
+ if (!intent || !run || run.revision <= intent.command.expected_revision)
24
+ return;
25
+ const settled = intent.action === "pause" ? run.state === "paused" || run.state === "pausing" : run.state === "running";
26
+ if (settled) {
27
+ pending.current?.abort();
28
+ pending.current = null;
29
+ setIntent(null);
30
+ setBusy(false);
31
+ setError(null);
32
+ }
33
+ }, [intent, run]);
34
+ if (!ingestion || !run)
35
+ return null;
36
+ const action = intent?.action ?? run.allowed_actions[0];
37
+ if (!action)
38
+ return error ? _jsx("p", { role: "alert", children: error }) : null;
39
+ const submit = async () => {
40
+ if (pending.current)
41
+ return;
42
+ const next = intent ?? { id: run.id, action, command: {
43
+ indexId: run.index_id, expected_revision: run.revision, idempotencyKey: crypto.randomUUID(),
44
+ } };
45
+ const request = new AbortController();
46
+ pending.current = request;
47
+ setIntent(next);
48
+ setBusy(true);
49
+ setError(null);
50
+ try {
51
+ await ingestion[next.action](next.id, next.command, request.signal);
52
+ // The ordinary status poll supplies the observed state and progress.
53
+ if (pending.current === request)
54
+ setIntent(null);
55
+ }
56
+ catch (cause) {
57
+ if (pending.current !== request)
58
+ return;
59
+ if (cause instanceof ApiRequestError && cause.status >= 400 && cause.status < 500) {
60
+ setIntent(null);
61
+ setError("Import controls changed. Check the current state and try again.");
62
+ }
63
+ else {
64
+ setError("The service did not confirm this request. Retry to check its outcome.");
65
+ }
66
+ }
67
+ finally {
68
+ if (pending.current === request) {
69
+ pending.current = null;
70
+ setBusy(false);
71
+ }
72
+ }
73
+ };
74
+ return _jsxs("div", { className: "ah-ingest-controls", children: [_jsx("button", { type: "button", disabled: busy, onClick: () => { void submit(); }, children: busy ? "Sending…" : `${intent && error ? "Retry " : ""}${action === "pause" ? "Pause import" : "Resume import"}` }), error && _jsx("p", { role: "alert", children: error })] });
75
+ }
@@ -60,6 +60,7 @@ function Quiet({ title, children }) {
60
60
  return (_jsxs("div", { className: "ah-sheet__quiet", role: "presentation", children: [_jsx("strong", { children: title }), _jsx("p", { children: children })] }));
61
61
  }
62
62
  const GRAMMAR_HINT = (_jsxs(_Fragment, { children: ["Every word must match. Put a phrase in quotes to match it exactly \u2014 ", _jsx("code", { children: "\"cargo test\"" }), ". Opening a result lands the transcript on that message."] }));
63
+ // @atlas step search-query.sheet
63
64
  export function SearchSheet({ open, onClose, query, onQuery, filters, onFilters, feed, results, readiness, progress, canonicalReady, ingestPaused, agentKinds, currentProject, homeDirectory, nowMs, getRuntimeKind, onOpenHit, }) {
64
65
  useRoomOverlay(open);
65
66
  const container = useRoomPortal();
package/dist/index.d.ts CHANGED
@@ -5,7 +5,7 @@ export type { RoomAct, RoomFilter, RoomFolder, RoomFolderGroup, RoomFolders, Roo
5
5
  /** The reader (PK3): one session's transcript, read-only, at a line. */
6
6
  export { TranscriptReader } from "./TranscriptReader.js";
7
7
  export type { ReaderAddress, TranscriptReaderProps } from "./TranscriptReader.js";
8
- export type { AssetSource, AssetUrl, CatalogPort, CatalogSearch, CatalogStatus, Closable, CollaborationFeed, FigureWindow, GitFacts, GitWorktree, HostPort, InstalledBrowser, RoomPorts, SessionClient, SessionFeed, SessionPort, WorktreeFileActionRequest, WorktreeFileActionResult, WorktreeHostEvent, WorktreeHostPort, WorktreeOpenResult, } from "./ports.js";
8
+ export type { AssetSource, AssetUrl, CatalogPort, CatalogSearch, CatalogStatus, Closable, CollaborationFeed, FigureWindow, GitFacts, GitWorktree, HostPort, InstalledBrowser, IngestionPort, RoomPorts, SessionClient, SessionFeed, SessionPort, WorktreeFileActionRequest, WorktreeFileActionResult, WorktreeHostEvent, WorktreeHostPort, WorktreeOpenResult, } from "./ports.js";
9
9
  export type { SummaryTab } from "./room-context.js";
10
10
  export type { ProjectStats } from "./lib/model.js";
11
11
  export type { KeyValueStore } from "./lib/room-storage.js";
package/dist/index.js CHANGED
@@ -1,6 +1,11 @@
1
1
  // @vibefield/rosetta-room — rosetta's History room behind injected ports. A host hands
2
2
  // it a catalog, a way to open one session's body, an asset source and, on a
3
3
  // desktop, a host port; the room dials nothing itself. See README.md.
4
+ //
5
+ // @atlas rule room-dials-nothing "The room dials nothing: every byte arrives through a port" {
6
+ // forbid text "fetch(", "new WebSocket(", "new EventSource(", "XMLHttpRequest" in pkg:@vibefield/rosetta-room
7
+ // see "clients/room/README.md#what-the-package-never-does"
8
+ // }
4
9
  export { HistoryRoom } from "./HistoryRoom.js";
5
10
  /** The reader (PK3): one session's transcript, read-only, at a line. */
6
11
  export { TranscriptReader } from "./TranscriptReader.js";
package/dist/lib/hooks.js CHANGED
@@ -72,7 +72,8 @@ export function useDaemonStatus(catalog) {
72
72
  const phase = status.ingest?.phase;
73
73
  // Keep search visible after canonical publication. Audit/consent are
74
74
  // intentional pauses; an unanswered first request retries promptly.
75
- const paused = phase === "metadata_ready" || phase === "awaiting_consent";
75
+ const paused = status.ingest?.run?.state === "paused" || status.ingest?.run?.state === "failed" ||
76
+ phase === "metadata_ready" || phase === "awaiting_consent";
76
77
  const complete = status.canonicalReady && status.searchReadiness === "ready";
77
78
  delay = complete ? 30_000 : paused ? 2_500 : 500;
78
79
  }
@@ -222,6 +223,7 @@ export function useProjectUsage(catalog, projectId, ready = false) {
222
223
  }, [catalog, projectId, ready]);
223
224
  return usage;
224
225
  }
226
+ // @atlas step live.render
225
227
  export function useTimeline(sessions, sessionId) {
226
228
  const [owner, setOwner] = useState(sessionId);
227
229
  const [attempt, setAttempt] = useState(0);
@@ -554,6 +556,7 @@ export function useTeamTimelines(sessions, sessionIds) {
554
556
  }, [ids, revision, retry]);
555
557
  }
556
558
  export const SEARCH_LIMIT = 200;
559
+ // @atlas step search-query.feed
557
560
  /**
558
561
  * One ranked search per settled query. The previous response stays on screen
559
562
  * while its successor is in flight — a list that blanks on every keystroke
@@ -39,6 +39,7 @@ export interface ActivitySamples {
39
39
  readonly changedAt: number;
40
40
  readonly rate: number | null;
41
41
  }
42
+ export declare function activityIdentity(feed: ActivityFeed, phase: ActivityPhase): string;
42
43
  /** Eight seconds of actual observations smooth variable batch sizes. A restart,
43
44
  * phase transition, retry, or regressing counter starts a new sample window. */
44
45
  export declare function sampleActivity(previous: ActivitySamples | null, feed: ActivityFeed, phase: ActivityPhase): ActivitySamples;
@@ -21,7 +21,7 @@ export function progressDuration(ms) {
21
21
  /** Percentages describe the named step, never an invented overall ETA. */
22
22
  export function activityPhase(feed) {
23
23
  const ingest = feed.ingest;
24
- if (ingest?.phase === "awaiting_consent" || feed.receivedAt === null)
24
+ if (feed.receivedAt === null)
25
25
  return null;
26
26
  const runtimes = ingest?.runtimes ?? [];
27
27
  const sessions = runtimes.reduce((sum, row) => sum + row.sessions_total, 0);
@@ -31,6 +31,19 @@ export function activityPhase(feed) {
31
31
  amount: "Looking for conversations…", current: 0, total: 0,
32
32
  unit: null, fraction: null, active: true,
33
33
  };
34
+ const run = ingest?.run;
35
+ if (run?.state === "paused" || run?.state === "pausing" || run?.state === "failed") {
36
+ const paused = run.state === "paused";
37
+ const failed = run.state === "failed";
38
+ return { ...base, key: run.state, active: !paused && !failed,
39
+ title: paused ? "History import paused" : failed ? "History import needs attention" : "Pausing history import",
40
+ detail: paused ? "Saved progress is retained. Resume when you are ready."
41
+ : failed ? run.error ?? "Import stopped. You can resume after resolving the problem."
42
+ : "Finishing in-flight work and saving progress. Your history stays available.",
43
+ amount: `${number(sessions)} sessions found` };
44
+ }
45
+ if (ingest?.phase === "awaiting_consent")
46
+ return null;
34
47
  if (ingest?.phase === "discovering")
35
48
  return base;
36
49
  if (ingest?.phase === "restoring") {
@@ -131,11 +144,14 @@ export function runtimeActivity(row, restoring = false) {
131
144
  case "done": return { label: "Ready to browse", fraction: 1 };
132
145
  }
133
146
  }
147
+ export function activityIdentity(feed, phase) {
148
+ return `${feed.startedAt}:${feed.ingest?.run?.progress_epoch ?? ""}:${phase.key}:${phase.unit}`;
149
+ }
134
150
  /** Eight seconds of actual observations smooth variable batch sizes. A restart,
135
151
  * phase transition, retry, or regressing counter starts a new sample window. */
136
152
  export function sampleActivity(previous, feed, phase) {
137
153
  const at = feed.receivedAt ?? 0;
138
- const identity = `${feed.startedAt}:${phase.key}:${phase.unit}`;
154
+ const identity = activityIdentity(feed, phase);
139
155
  const last = previous?.points.at(-1);
140
156
  if (!previous || previous.identity !== identity || !last || phase.current < last.value || at < last.at) {
141
157
  return { identity, points: [{ at, value: phase.current }], changedAt: at, rate: null };
@@ -1,6 +1,9 @@
1
1
  import type { IngestProgress, ProjectBackfill, SessionRow } from "@vibefield/rosetta-protocol";
2
2
  /** The backfill is still running: veils may be down, cards may cook. */
3
3
  export declare function isIngestActive(ingest: IngestProgress | null): boolean;
4
+ /** Keep resume visible if discovery reaches the consent gate while paused.
5
+ * Once resumed, the host's ordinary consent screen can take over again. */
6
+ export declare function isAwaitingConsent(ingest: IngestProgress | null): boolean;
4
7
  /** The census (skim) has not yet named every session: the LISTS themselves
5
8
  * are still incomplete, so the list sections stay veiled. */
6
9
  export declare function isCensusRunning(ingest: IngestProgress | null): boolean;
@@ -3,6 +3,12 @@ import { UNASSIGNED_PROJECT_ID } from "./model.js";
3
3
  export function isIngestActive(ingest) {
4
4
  return ingest !== null && ingest.phase !== "live";
5
5
  }
6
+ /** Keep resume visible if discovery reaches the consent gate while paused.
7
+ * Once resumed, the host's ordinary consent screen can take over again. */
8
+ export function isAwaitingConsent(ingest) {
9
+ return ingest?.phase === "awaiting_consent" &&
10
+ (ingest.run == null || ingest.run.state === "running");
11
+ }
6
12
  /** The census (skim) has not yet named every session: the LISTS themselves
7
13
  * are still incomplete, so the list sections stay veiled. */
8
14
  export function isCensusRunning(ingest) {
@@ -10,6 +16,10 @@ export function isCensusRunning(ingest) {
10
16
  return false;
11
17
  if (ingest === null)
12
18
  return true;
19
+ // A completed first pass stays complete while queued runtimes start
20
+ // streaming or later work changes phase. Older daemons omit the stamp.
21
+ if (ingest.metadata_ready_at !== undefined)
22
+ return ingest.metadata_ready_at === null;
13
23
  // The explicit audit seam proves every bounded skim has committed. Runtime
14
24
  // rows remain queued because complete-record streaming has not started.
15
25
  if (ingest.phase === "metadata_ready")
@@ -1,5 +1,5 @@
1
1
  import { useEffect, useRef, useState } from "react";
2
- import { activityPhase, progressBytes, progressDuration, sampleActivity } from "./ingest-activity.js";
2
+ import { activityIdentity, activityPhase, progressBytes, progressDuration, sampleActivity } from "./ingest-activity.js";
3
3
  export function useIngestActivity(feed) {
4
4
  const phase = activityPhase(feed);
5
5
  const [samples, setSamples] = useState(null);
@@ -33,7 +33,7 @@ export function useIngestActivity(feed) {
33
33
  const sinceAnswer = Math.max(0, now - (feed.receivedAt ?? now));
34
34
  const elapsed = `${progressDuration(feed.uptimeMs + (phase.active ? sinceAnswer : 0))} elapsed`;
35
35
  const sinceChange = Math.max(0, now - (samples?.changedAt ?? now));
36
- const rate = sinceChange < 4_000 && samples?.identity === `${feed.startedAt}:${phase.key}:${phase.unit}`
36
+ const rate = sinceChange < 4_000 && samples?.identity === activityIdentity(feed, phase)
37
37
  ? samples.rate : null;
38
38
  let footer = "Connected";
39
39
  if (feed.connectionError)
@@ -44,6 +44,6 @@ export function useIngestActivity(feed) {
44
44
  else if (phase.unit && sinceChange >= 10_000)
45
45
  footer = `Connected · No new counts for ${progressDuration(sinceChange)}`;
46
46
  else if (!phase.active)
47
- footer = key === "ready" ? "Complete" : key === "paused" ? "Paused" : "Ready to browse";
47
+ footer = key === "ready" ? "Complete" : key === "paused" ? "Paused" : key === "failed" ? "Stopped" : "Ready to browse";
48
48
  return { phase, feed, footer, elapsed };
49
49
  }
package/dist/ports.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { AssetRef, CollaborationSubscriptionHandlers, CommandResponse, DayUsageResponse, ImageVariant, IngestProgress, MetaSubscriptionHandlers, SearchBuildProgress, SearchReadiness, SearchResponse, SessionSubscriptionHandlers, TimelineEntry, TimelineMetaPlanSource, TimelinePage, UsageResponse } from "@vibefield/rosetta-protocol";
1
+ import type { AssetRef, CollaborationSubscriptionHandlers, CommandResponse, DayUsageResponse, ImageVariant, IngestProgress, Ingestion, IngestionCommand, MetaSubscriptionHandlers, SearchBuildProgress, SearchReadiness, SearchResponse, SessionSubscriptionHandlers, TimelineEntry, TimelineMetaPlanSource, TimelinePage, UsageResponse } from "@vibefield/rosetta-protocol";
2
2
  import type { ProjectStats } from "./lib/model.js";
3
3
  /** A live subscription the room can end. */
4
4
  export interface Closable {
@@ -213,10 +213,16 @@ export interface AssetSource {
213
213
  attachmentAssetUrl(asset: AssetRef): string | null;
214
214
  }
215
215
  /** Everything a host hands the room. */
216
+ export interface IngestionPort {
217
+ pause(id: string, command: IngestionCommand, signal?: AbortSignal): Promise<Ingestion>;
218
+ resume(id: string, command: IngestionCommand, signal?: AbortSignal): Promise<Ingestion>;
219
+ }
216
220
  export interface RoomPorts {
217
221
  readonly catalog: CatalogPort;
218
222
  readonly sessions: SessionPort;
219
223
  readonly assets: AssetSource;
224
+ /** Optional daemon-level authority. Absence leaves history observation-only. */
225
+ readonly ingestion?: IngestionPort | null;
220
226
  /** Absent: a browser tab, which the room states rather than papers over. */
221
227
  readonly host?: HostPort | null;
222
228
  }
@@ -27,6 +27,7 @@ export interface OverlayCount {
27
27
  count(): number;
28
28
  }
29
29
  export interface Room {
30
+ readonly ingestion: RoomPorts["ingestion"];
30
31
  readonly catalog: CatalogPort;
31
32
  readonly sessions: SessionLeases;
32
33
  readonly assets: AssetSource;
@@ -39,6 +39,7 @@ function createOverlayCount() {
39
39
  const RoomContext = createContext(null);
40
40
  export function RoomProvider({ ports, options = {}, children, }) {
41
41
  const { catalog, sessions: sessionPort, assets } = ports;
42
+ const ingestion = ports.ingestion;
42
43
  const host = ports.host ?? null;
43
44
  const initialSummaryTab = options.initialSummaryTab ?? "activity";
44
45
  const portalContainer = options.portalContainer ?? null;
@@ -51,9 +52,9 @@ export function RoomProvider({ ports, options = {}, children, }) {
51
52
  const tints = useMemo(() => createTintDealer(storage), [storage]);
52
53
  const fold = useMemo(() => createFoldPreference(storage), [storage]);
53
54
  const room = useMemo(() => ({
54
- catalog, sessions, assets, host, initialSummaryTab, favicons, portalContainer, overlays,
55
+ catalog, sessions, assets, ingestion, host, initialSummaryTab, favicons, portalContainer, overlays,
55
56
  storage, tints, fold,
56
- }), [catalog, sessions, assets, host, initialSummaryTab, favicons, portalContainer, overlays, storage, tints, fold]);
57
+ }), [catalog, sessions, assets, ingestion, host, initialSummaryTab, favicons, portalContainer, overlays, storage, tints, fold]);
57
58
  return _jsx(RoomContext.Provider, { value: room, children: children });
58
59
  }
59
60
  export function useRoom() {
@@ -49,3 +49,9 @@
49
49
  }
50
50
 
51
51
  .ah-ingest-note { flex: 0 0 auto; margin: 0; padding: 9px 14px; color: var(--vf-text-secondary); font-size: 11px; line-height: 1.5; }
52
+
53
+ .ah-ingest-controls { margin: 10px 0 4px; }
54
+ .ah-ingest-controls button { padding: 5px 10px; border: 1px solid var(--vf-hairline-strong); border-radius: 5px; background: var(--vf-chrome-surface); color: var(--vf-text-primary); font: inherit; cursor: pointer; }
55
+ .ah-ingest-controls button:disabled { opacity: .55; cursor: default; }
56
+ .ah-ingest-controls button:focus-visible { outline: 2px solid var(--vf-select); outline-offset: 2px; }
57
+ .ah-ingest-controls [role="alert"] { color: var(--vf-text-secondary); line-height: 1.5; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibefield/rosetta-room",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "description": "rosetta's History room as a package: the projects, sessions and worktree columns, the panels and charts, and the transcript, behind injected ports. It dials nothing.",
6
6
  "main": "./dist/index.js",
@@ -35,7 +35,7 @@
35
35
  "@radix-ui/react-context-menu": "^2.3.7",
36
36
  "@radix-ui/react-dialog": "^1.1.23",
37
37
  "@radix-ui/react-tooltip": "^1.2.16",
38
- "@vibefield/rosetta-protocol": "^0.1.1",
38
+ "@vibefield/rosetta-protocol": "^0.1.2",
39
39
  "@tanstack/react-virtual": "^3.14.10",
40
40
  "@vibecook/mille": "^0.3.4",
41
41
  "@vibecook/mille-ui": "^0.3.4"