@shenora/react 0.1.2 → 0.4.0

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.
package/README.md CHANGED
@@ -66,6 +66,80 @@ single component**. A failed `post` has no promise to reject, so it is reported
66
66
  configureBridge({ onPostError: (failure) => log.error(failure.module, failure.type, failure.error) });
67
67
  ```
68
68
 
69
+ ### Tracked operations: a ready-made progress store
70
+
71
+ For work the host tracks with `Shenora.Ipc`'s operation registry (an `IModuleContext.Run`/`Start`
72
+ route on the C# side), `useShenoraOperations()` is a `createShenoraStore` instance built the same
73
+ way as the example above — no per-feature event wiring needed:
74
+
75
+ ```ts
76
+ import { useShenoraOperations } from '@shenora/react';
77
+
78
+ const running = useShenoraOperations((s) => s.running); // every in-flight operation
79
+ const waiting = useShenoraOperations((s) => s.waiting); // stopped, awaiting a decision — "needs you", one bucket
80
+ const importJob = useShenoraOperations((s) => s.byId[jobId]); // one, by id
81
+
82
+ useShenoraOperations.actions.cancel(jobId); // only does anything if the op opted into Cancellable
83
+ useShenoraOperations.actions.dismiss(jobId); // decline a waiting offer — refuses a running one
84
+ useShenoraOperations.actions.wait(jobId); // ASK the host to wait running work — refuses anything not running
85
+ useShenoraOperations.actions.clearFinished();
86
+ ```
87
+
88
+ `operation.progress` is an exported `OperationProgress` (`{ value: number; total?: number; unit?:
89
+ string }`) — import the type rather than re-declaring the shape — the APP's own unit
90
+ (bytes-of-a-known-total, items-of-a-known-total, an absolute count with no known total, or a genuine
91
+ percent), never a kit-assumed percentage: `total` is the denominator when one is known and `undefined`
92
+ when there isn't one, and `unit` is app-defined and uninterpreted, exactly like `kind`. The kit ships
93
+ no percent helper — render a ratio only when you have a `total`:
94
+
95
+ ```ts
96
+ import type { OperationProgress } from '@shenora/react';
97
+
98
+ function format(progress?: OperationProgress): string {
99
+ if (!progress) return 'starting…';
100
+ return progress.total
101
+ ? `${Math.round((progress.value / progress.total) * 100)}%`
102
+ : `${progress.value}${progress.unit ? ` ${progress.unit}` : ''}`; // no known total
103
+ }
104
+ ```
105
+
106
+ That division is your own policy, not the kit's, which is why it lives here instead of in `src/`.
107
+
108
+ For the two events the store deliberately does NOT subscribe to — `OPERATION_RESUME_REQUESTED` and
109
+ `OPERATION_WAIT_REQUESTED`, which target the OWNING module's service rather than this generic store —
110
+ the module name and the event vocabulary are exported too, so you match by symbol and stay inside the
111
+ host↔client mirror the wire tests pin:
112
+
113
+ ```ts
114
+ import { OperationEventTypes, OperationModuleName, useShenoraEvent } from '@shenora/react';
115
+
116
+ useShenoraEvent(OperationModuleName, OperationEventTypes.ResumeRequested, (payload) => …);
117
+ ```
118
+
119
+ It snapshots via `LIST` on first subscribe (so a progress strip that mounts mid-run isn't empty), then
120
+ folds `OPERATION_UPDATED` by id — one subscription however many components read it. The client
121
+ mirrors the host's three bands (design §5A.2): `running` (Active), `waiting` (Waiting), and `finished`
122
+ (Terminal). All three are derived from `byId` on every read, never a second copy to keep in sync —
123
+ `waiting` is a single-status filter, exactly like `running`. `OperationStatus` carries ONE waiting
124
+ value reached ONE way — the host's `IOperation.Wait` on a live operation — so there is no sub-case to
125
+ tell apart. Filtering by your own `module`/`kind` is a plain `Array.filter` over any of them. A
126
+ `waiting` operation carries `waitReason` — an app-defined string, like `kind`, OPTIONAL on the host
127
+ side — for your UI to branch on. The host never prunes a waiting entry on its own: it stays until
128
+ someone resumes, dismisses, or finishes it. `resume`/`dismiss`/`wait` are all fire-and-forget client
129
+ requests — the host's own `IOperation.Wait`/`Resume` (called by whoever owns the operation, hearing
130
+ `OPERATION_WAIT_REQUESTED`/`OPERATION_RESUME_REQUESTED`) is what actually changes the state; asking
131
+ is not acting. `clearFinished`/`resume`/`wait` do not touch local state themselves at all: the host's
132
+ `OPERATION_REMOVED { operationIds }` is the ONE authoritative removal signal the store folds, deleting
133
+ exactly the named ids — `MaxHistory` eviction and `clearFinished`
134
+ publish it, so a long-lived store's mirror of bounded host history cannot drift from what the host
135
+ actually did (this replaced two hand-written optimistic local prunes that a past release carried —
136
+ one of which was this project's only Critical, a `resume` prune that dropped a still-waiting row).
137
+ `dismiss` never needed one, since the host's `Dismiss` publishes an ordinary terminal snapshot over
138
+ the wire, the same as a real cancel. Use `createOperationsStore({ module, scope })` instead of the
139
+ default export if your host renamed `OperationRegistryOptions.ModuleName` or
140
+ you need a scope-filtered instance (a secondary window, an auxiliary session) — `clearFinished`
141
+ forwards that scope so clearing history in one window cannot wipe another's.
142
+
69
143
  ### Observing the whole stream
70
144
 
71
145
  `useShenoraEvent` and `createShenoraStore` listen for an exact `(module, type)`. When the vocabulary
package/dist/bridge.d.ts CHANGED
@@ -96,7 +96,7 @@ export declare class ShenoraBridge {
96
96
  * Send WITHOUT awaiting a reply, and return the request id.
97
97
  *
98
98
  * This is the default shape for a desktop shell, and {@link invoke} is the special case — see
99
- * `docs/2026-07-31-shenora-oneway-ipc-design.md`. Two reasons: a correlated call carries a deadline
99
+ * `docs/DECISIONS.md` D23. Two reasons: a correlated call carries a deadline
100
100
  * (30 s by default) and real work does not; and request/response is UI-THREAD-COUPLED here by
101
101
  * design, because the dispatch pipeline preserves the caller's synchronization context so facades
102
102
  * can touch the window. Reserve `invoke` for calls that are quick AND safe on the UI thread — the
@@ -111,7 +111,10 @@ export declare class ShenoraBridge {
111
111
  * otherwise is. Nothing is queued and no timer is set, so there is nothing to leak and no deadline.
112
112
  *
113
113
  * No transport (a plain browser tab) is a silent no-op, matching the fire-and-forget contract —
114
- * unlike `invoke`, there is no caller waiting to be told.
114
+ * unlike `invoke`, there is no caller waiting to be told. **A DISPOSED bridge is the same silent
115
+ * no-op**, and that one can surprise: `invoke` on a disposed bridge rejects with `NO_TRANSPORT`
116
+ * while `post` just returns the id, so a stale reference kept across a `configureBridge` swap looks
117
+ * like it is still sending. `isAvailable` is the check.
115
118
  */
116
119
  post<TPayload = unknown>(module: string, type: string, options?: PostOptions<TPayload>): string;
117
120
  /**
package/dist/bridge.js CHANGED
@@ -124,7 +124,7 @@ export class ShenoraBridge {
124
124
  * Send WITHOUT awaiting a reply, and return the request id.
125
125
  *
126
126
  * This is the default shape for a desktop shell, and {@link invoke} is the special case — see
127
- * `docs/2026-07-31-shenora-oneway-ipc-design.md`. Two reasons: a correlated call carries a deadline
127
+ * `docs/DECISIONS.md` D23. Two reasons: a correlated call carries a deadline
128
128
  * (30 s by default) and real work does not; and request/response is UI-THREAD-COUPLED here by
129
129
  * design, because the dispatch pipeline preserves the caller's synchronization context so facades
130
130
  * can touch the window. Reserve `invoke` for calls that are quick AND safe on the UI thread — the
@@ -139,7 +139,10 @@ export class ShenoraBridge {
139
139
  * otherwise is. Nothing is queued and no timer is set, so there is nothing to leak and no deadline.
140
140
  *
141
141
  * No transport (a plain browser tab) is a silent no-op, matching the fire-and-forget contract —
142
- * unlike `invoke`, there is no caller waiting to be told.
142
+ * unlike `invoke`, there is no caller waiting to be told. **A DISPOSED bridge is the same silent
143
+ * no-op**, and that one can surprise: `invoke` on a disposed bridge rejects with `NO_TRANSPORT`
144
+ * while `post` just returns the id, so a stale reference kept across a `configureBridge` swap looks
145
+ * like it is still sending. `isAvailable` is the check.
143
146
  */
144
147
  post(module, type, options = {}) {
145
148
  const request = {
package/dist/index.d.ts CHANGED
@@ -5,6 +5,7 @@ export { ShenoraEventBus, eventBus } from './eventBus.js';
5
5
  export { ShenoraBridge, getBridge, configureBridge, type ShenoraBridgeOptions, type InvokeOptions, type PostOptions, type PostFailure, } from './bridge.js';
6
6
  export { createShenoraStore, type ShenoraStore, type ShenoraStoreOptions, type ShenoraStoreIo, type ShenoraStoreSnapshot, } from './store.js';
7
7
  export { BaseModuleService } from './moduleService.js';
8
+ export { OperationStatuses, OperationEventTypes, OperationModuleName, createOperationsStore, useShenoraOperations, type OperationStatus, type OperationLabel, type OperationProgress, type OperationInfo, type OperationsState, type OperationsActions, type OperationsStoreOptions, } from './operations.js';
8
9
  export { WindowCommands, useWindowMaximized, type WindowResizeEdge, type CaptionButtonKind, type CaptionButtonRect, } from './windowCommands.js';
9
10
  export { useDropZone, DROP_ZONE_MODULE, type DropZoneFileDrop, type UseDropZoneOptions, } from './useDropZone.js';
10
11
  export { useShenora, useShenoraEvent, useShenoraQuery, type ShenoraQueryResult } from './hooks.js';
package/dist/index.js CHANGED
@@ -9,6 +9,12 @@ export { ShenoraEventBus, eventBus } from './eventBus.js';
9
9
  export { ShenoraBridge, getBridge, configureBridge, } from './bridge.js';
10
10
  export { createShenoraStore, } from './store.js';
11
11
  export { BaseModuleService } from './moduleService.js';
12
+ export { OperationStatuses,
13
+ // The event vocabulary + the default module name. `createOperationsStore` deliberately does NOT
14
+ // subscribe to RESUME_REQUESTED / WAIT_REQUESTED — those target the OWNING module's own service,
15
+ // not the generic store — so the app writing that handler needs both symbols, and until now had
16
+ // neither: it had to hard-code the literals the wire-mirror tests exist to keep it from doing.
17
+ OperationEventTypes, OperationModuleName, createOperationsStore, useShenoraOperations, } from './operations.js';
12
18
  export { WindowCommands, useWindowMaximized, } from './windowCommands.js';
13
19
  export { useDropZone, DROP_ZONE_MODULE, } from './useDropZone.js';
14
20
  export { useShenora, useShenoraEvent, useShenoraQuery } from './hooks.js';
@@ -0,0 +1,256 @@
1
+ import type { ShenoraBridge } from './bridge.js';
2
+ import type { ShenoraEventBus } from './eventBus.js';
3
+ import { type ShenoraStore } from './store.js';
4
+ import type { IpcError } from './types.js';
5
+ /**
6
+ * Mirrors `Shenora.Ipc.OperationStatus` (design §4.2) — crosses the wire as its camelCase name for
7
+ * free: `IpcJson` already installs a camelCase `JsonStringEnumConverter`, so no per-type wiring is
8
+ * needed on either side. Pinned against the host by
9
+ * `WireMirrorTests.Every_operation_status_exists_on_both_sides` — a status added on one side and
10
+ * not the other fails that test by name, not by a green suite that never looked.
11
+ */
12
+ export declare const OperationStatuses: {
13
+ readonly Running: "running";
14
+ readonly Completed: "completed";
15
+ readonly Failed: "failed";
16
+ readonly Cancelled: "cancelled";
17
+ /**
18
+ * Not progressing, awaiting a decision — the APP names why via {@link OperationInfo.waitReason}
19
+ * (e.g. `'credentials'`/`'dns'`/`'queued'`/`'rate-limited'`), never a kit-owned reason enum. Not
20
+ * one of the terminal statuses in {@link OperationsState.finished}, and never pruned as history —
21
+ * a waiting entry is a pending offer, not something finished.
22
+ *
23
+ * Reached ONE way, and that is the point: the host's `IOperation.Wait` on a live operation. The
24
+ * body is parked, not gone, so there is always something to resume. Exits via the host's
25
+ * `IOperation.Resume` (back to `running`), the `DISMISS` route (to `cancelled`), or a direct
26
+ * complete/fail.
27
+ *
28
+ * Two simplifications got it here, both undoing the same mistake — encoding HOW an entry arrived
29
+ * rather than WHAT state it is in. It was once two values (`paused` and `interrupted`) that every
30
+ * host transition already treated as one band, and the host once also accepted crash-checkpoint
31
+ * entries with no live body at all (`RegisterWaiting` + `resumePayload`), which forced every caller
32
+ * to answer "does this one have a handle?". The checkpoint half was cut in 0.2.0: crash recovery is
33
+ * the APP's business — it owns the checkpoint, and a resumed run is a fresh `Start()` like any other.
34
+ */
35
+ readonly Waiting: "waiting";
36
+ };
37
+ /** One of {@link OperationStatuses}. */
38
+ export type OperationStatus = (typeof OperationStatuses)[keyof typeof OperationStatuses];
39
+ /**
40
+ * Mirrors `Shenora.Ipc.OperationEvents` — pinned against the host by
41
+ * `WireMirrorTests.Operation_event_names_match_the_host` (ALSO IN THIS BATCH, whole-branch review):
42
+ * these were bare string literals with nothing comparing them to the host's own constants, so a host
43
+ * rename left the suite green and the client permanently deaf to the renamed event.
44
+ */
45
+ export declare const OperationEventTypes: {
46
+ readonly Updated: "OPERATION_UPDATED";
47
+ readonly ResumeRequested: "OPERATION_RESUME_REQUESTED";
48
+ /**
49
+ * A client asked to wait a running operation (generic-library audit finding 3, renamed from
50
+ * `PAUSE_REQUESTED`) — the owning module should call the host's `IOperation.Wait` once it has
51
+ * actually stopped. Not subscribed by {@link createOperationsStore} itself, same as
52
+ * {@link ResumeRequested}: it targets the owning module's own service, not the generic operations
53
+ * store.
54
+ */
55
+ readonly WaitRequested: "OPERATION_WAIT_REQUESTED";
56
+ /**
57
+ * One or more operation ids left the host registry with no corresponding `Updated` snapshot —
58
+ * `MaxHistory` eviction, `CLEAR_FINISHED`, and the interrupted-entry drop on `RESUME` (Finding 4,
59
+ * generic-library audit). Payload is `{ operationIds: string[] }`; {@link createOperationsStore}
60
+ * folds it by deleting those ids, which is what let the two hand-written optimistic prunes
61
+ * (`clearFinished`/`resume` used to carry one each) be removed — one authoritative event that
62
+ * cannot diverge from what the host actually did, replacing two guesses that could.
63
+ */
64
+ readonly Removed: "OPERATION_REMOVED";
65
+ };
66
+ /**
67
+ * Mirrors the route names `Shenora.Ipc.OperationsFacade` switches on (its own
68
+ * `ListType`/`CancelType`/`ClearFinishedType`/`ResumeType` constants) — pinned by
69
+ * `WireMirrorTests.Operation_route_names_match_the_hosts_facade`, same rationale as
70
+ * {@link OperationEventTypes}.
71
+ */
72
+ export declare const OperationRoutes: {
73
+ readonly List: "LIST";
74
+ readonly Cancel: "CANCEL";
75
+ readonly ClearFinished: "CLEAR_FINISHED";
76
+ readonly Resume: "RESUME";
77
+ /** Decline a pending Waiting offer by id — mirrors {@link Cancel}'s shape. */
78
+ readonly Dismiss: "DISMISS";
79
+ /**
80
+ * Ask the owning module to wait a running operation by id (generic-library audit finding 3,
81
+ * renamed from `PAUSE`) — mirrors {@link Resume}'s shape (`{ operationId }` → `{ requested }`).
82
+ * Asking is not acting: the owning module's own `IOperation.Wait` is what actually stops the work
83
+ * and publishes the transition, same split as {@link Resume} vs the host's `IOperation.Resume`.
84
+ */
85
+ readonly Wait: "WAIT";
86
+ };
87
+ /**
88
+ * Default `Shenora.Ipc.OperationRegistryOptions.ModuleName` — pinned by
89
+ * `WireMirrorTests.The_default_operations_module_name_matches_the_host`.
90
+ */
91
+ export declare const OperationModuleName = "OPERATIONS";
92
+ /**
93
+ * Mirrors `Shenora.Ipc.OperationLabel` — human-facing text the HOST never renders itself: an
94
+ * untranslated fallback plus an app i18n key and interpolation parameters (headless, D13).
95
+ */
96
+ export interface OperationLabel {
97
+ text?: string;
98
+ key?: string;
99
+ parameters?: Record<string, string>;
100
+ }
101
+ /**
102
+ * Mirrors `Shenora.Ipc.OperationProgress` — how far a tracked operation has gotten, in the APP's own
103
+ * unit, never a kit-assumed percent (generic-library audit, before publish: percent is not the
104
+ * mechanism, it is one way an app happens to measure). `total` is the denominator when one is known;
105
+ * `undefined` means there is NO known total — an absolute count with nothing to divide by (bytes
106
+ * streamed so far off a chunked response, say), never zero. `unit` is app-defined, like `kind`
107
+ * (`'bytes'`, `'files'`, `'percent'`) — the kit never interprets it and ships no percent helper: render
108
+ * a ratio only when `total` is set, e.g. `total ? (value / total) * 100 : undefined` — that division
109
+ * is the consumer's own policy (see the README example).
110
+ */
111
+ export interface OperationProgress {
112
+ value: number;
113
+ total?: number;
114
+ unit?: string;
115
+ }
116
+ /**
117
+ * Mirrors `Shenora.Ipc.OperationInfo` — a full snapshot of one tracked operation. Every lifecycle
118
+ * transition (start, progress, terminal) publishes one of these under `OPERATION_UPDATED`, so the
119
+ * client folds by `id`: last write wins, with no cross-type ordering hazard.
120
+ */
121
+ export interface OperationInfo {
122
+ id: string;
123
+ module: string;
124
+ kind: string;
125
+ scope?: string;
126
+ status: OperationStatus;
127
+ progress?: OperationProgress;
128
+ title?: OperationLabel;
129
+ detail?: OperationLabel;
130
+ /** Why the operation is `'waiting'` — an app-defined string, like `kind`; the kit never interprets it. */
131
+ waitReason?: string;
132
+ error?: IpcError;
133
+ cancellable: boolean;
134
+ startedAt: string;
135
+ finishedAt?: string;
136
+ }
137
+ /**
138
+ * State behind {@link useShenoraOperations}. Mirrors the host's THREE bands (design §5A.2):
139
+ *
140
+ * | Band | Getter | Exits |
141
+ * |---|---|---|
142
+ * | Active | {@link running} | complete / fail / cancel / wait |
143
+ * | Waiting — stopped, resumable, awaiting a decision | {@link waiting} | resume / dismiss / complete / fail |
144
+ * | Terminal | {@link finished} | — (prunable via `clearFinished`) |
145
+ *
146
+ * `waiting` is now the WHOLE band, not a union of two getters — the host's `OperationStatus` carries
147
+ * only one waiting value (the former `paused`/`interrupted` pair collapsed into it, since every host
148
+ * transition already treated them as one band). A waiting entry is a pending offer, not finished
149
+ * history: the host never prunes it on its own, and only `Resume` or `Dismiss` removes it. Every
150
+ * getter here is DERIVED from `byId` on every read — never a second copy a fold has to remember to
151
+ * keep in sync. `byId` itself is the only thing any reducer here ever writes.
152
+ */
153
+ export interface OperationsState {
154
+ byId: Record<string, OperationInfo>;
155
+ /** Every currently-running operation, in `byId` order. */
156
+ readonly running: OperationInfo[];
157
+ /**
158
+ * The WAITING band (design §5A.2): stopped, resumable, awaiting a decision — exactly what
159
+ * `Dismiss`/`RequestResume` both accept, so a status bar can render "needs you" as one bucket.
160
+ * Every entry here reached the band the same way (a live `Wait()` on the host), so there is no
161
+ * sub-case to distinguish — see {@link OperationStatuses.Waiting}.
162
+ */
163
+ readonly waiting: OperationInfo[];
164
+ /** Every operation that reached a terminal status (completed/failed/cancelled). */
165
+ readonly finished: OperationInfo[];
166
+ }
167
+ /** Fire-and-forget actions exposed on {@link useShenoraOperations}, routed to `OperationsFacade`. */
168
+ export interface OperationsActions {
169
+ /** `CANCEL { operationId }` — the app-level cancel route `ipc-contracts` prescribes. */
170
+ cancel: (operationId: string) => string;
171
+ /**
172
+ * `DISMISS { operationId }` — decline a pending `waiting` offer (design §5A.3), mirroring
173
+ * {@link cancel}'s shape. No optimistic local prune: the host's `Dismiss` transitions the entry to
174
+ * `cancelled` and publishes an ordinary `OPERATION_UPDATED` snapshot for it (same as a real
175
+ * cancel), so the store already folds the result from the wire.
176
+ */
177
+ dismiss: (operationId: string) => string;
178
+ /**
179
+ * `CLEAR_FINISHED { scope? }` — drop retained finished history, forwarding this store's own
180
+ * configured scope (generic-library audit finding 1) so a scoped store's "clear completed" cannot
181
+ * wipe another scope's history host-side. No local mutation here: the host's
182
+ * `OPERATION_REMOVED` (finding 4) is the only thing that removes a row from this store now — see
183
+ * {@link OperationEventTypes.Removed}. It used to carry an optimistic local prune of every TERMINAL
184
+ * entry, added because removals had no wire event at all; that guess is retired now that one exists.
185
+ */
186
+ clearFinished: () => string;
187
+ /**
188
+ * `RESUME { operationId }` — ask the host to resume a waiting operation, mirroring {@link wait}.
189
+ * No local mutation: asking never changes state by itself — the owning module's own
190
+ * `IOperation.Resume` publishes the `running` transition, and the store folds it from the wire like
191
+ * any other `OPERATION_UPDATED`.
192
+ *
193
+ * This action carried an optimistic local prune through two designs and was the source of the
194
+ * release's only Critical — it deleted a row the host deliberately kept, making a still-waiting
195
+ * operation unreachable. The prune existed because the host's `RequestResume` was ASYMMETRIC
196
+ * (removing crash-checkpoint entries, keeping live ones). The 0.2.0 design pass cut the checkpoint
197
+ * half, so the asymmetry is gone at the source and there is nothing left for a client to mirror.
198
+ */
199
+ resume: (operationId: string) => string;
200
+ /**
201
+ * `WAIT { operationId }` — ask the owning module to wait a running operation (generic-library
202
+ * audit finding 3, renamed from `pause`), mirroring {@link dismiss}'s shape. No optimistic local
203
+ * prune: asking never changes the state by itself — the owning module's own `IOperation.Wait` is
204
+ * what publishes the `waiting` transition, and the store folds it from the wire like any other
205
+ * `OPERATION_UPDATED`.
206
+ */
207
+ wait: (operationId: string) => string;
208
+ }
209
+ /** Test/alternate-transport seams, a renamed host module, and an optional scope filter, for {@link createOperationsStore}. */
210
+ export interface OperationsStoreOptions {
211
+ /**
212
+ * The request/event module this store talks to. Must match the host's
213
+ * `OperationRegistryOptions.ModuleName` — default `'OPERATIONS'` on both sides — when an app
214
+ * renamed it to avoid a collision with one of its own module names (the duplicate-module guard
215
+ * `OperationsFacade`'s own docs describe). A store bound to the default name cannot reach a
216
+ * renamed host at all, which is exactly the gap this field closes.
217
+ */
218
+ module?: string;
219
+ /**
220
+ * Optional app-defined scope, applied to THREE places so the store stays internally consistent:
221
+ * the bus subscription (only deltas whose event scope matches are folded), the actions' request
222
+ * envelope, and the initial `LIST` snapshot's payload (`OperationsFacade` reads its scope filter
223
+ * from the payload, not the envelope — see `OperationsFacade.RouteMessageAsync`). Threading it
224
+ * into only the first two would load every scope on first subscribe and never remove the
225
+ * out-of-scope rows, since no delta for them ever arrives: a silent, permanent leak.
226
+ */
227
+ scope?: string;
228
+ /** Test/multi-transport seams. Default: the shared bridge and event bus. */
229
+ bridge?: ShenoraBridge;
230
+ bus?: ShenoraEventBus;
231
+ }
232
+ /**
233
+ * Build a store instance over the operations module — the factory {@link useShenoraOperations}
234
+ * itself is built from. Exposed (rather than only the ready-made hook) for the same reason
235
+ * `WindowCommands` takes an optional bridge: a test needs a fake bridge/bus
236
+ * (`operations.test.ts`), an app that renamed the host's `OperationRegistryOptions.ModuleName`
237
+ * needs a store bound to that name instead of the unreachable default, and an app running a
238
+ * secondary window or auxiliary session needs its own scope-filtered instance instead of being
239
+ * stuck with the shared, unscoped default.
240
+ */
241
+ export declare function createOperationsStore(options?: OperationsStoreOptions): ShenoraStore<OperationsState, OperationsActions>;
242
+ /**
243
+ * The client side of the operations primitive (design §4.6, `OperationsFacade` +
244
+ * `IOperationRegistry`): snapshots via `LIST` on first subscribe, then folds `OPERATION_UPDATED` by
245
+ * id — one subscription however many components read it, and a late mounter renders CURRENT state
246
+ * because the host is authoritative (the store primitive's own late-mounter case is now
247
+ * host-backed end to end). `running`/`waiting`/`finished` are selectors an activity panel or status
248
+ * bar reads directly: `useShenoraOperations((s) => s.waiting)` for the one "needs you" bucket
249
+ * (every entry in it reached the band the same way, so there is no sub-case to filter for; read
250
+ * `waitReason` for WHY it is waiting). Bound to the default module/no scope — use
251
+ * {@link createOperationsStore} directly for a renamed module or a scope-filtered instance.
252
+ *
253
+ * Headless, per D13: no component, no UI opinion, no `ProcessType`-style enum — what an operation
254
+ * IS stays the app's `kind` string; this only carries the uniform lifecycle around it.
255
+ */
256
+ export declare const useShenoraOperations: ShenoraStore<OperationsState, OperationsActions>;
@@ -0,0 +1,191 @@
1
+ import { createShenoraStore } from './store.js';
2
+ /**
3
+ * Mirrors `Shenora.Ipc.OperationStatus` (design §4.2) — crosses the wire as its camelCase name for
4
+ * free: `IpcJson` already installs a camelCase `JsonStringEnumConverter`, so no per-type wiring is
5
+ * needed on either side. Pinned against the host by
6
+ * `WireMirrorTests.Every_operation_status_exists_on_both_sides` — a status added on one side and
7
+ * not the other fails that test by name, not by a green suite that never looked.
8
+ */
9
+ export const OperationStatuses = {
10
+ Running: 'running',
11
+ Completed: 'completed',
12
+ Failed: 'failed',
13
+ Cancelled: 'cancelled',
14
+ /**
15
+ * Not progressing, awaiting a decision — the APP names why via {@link OperationInfo.waitReason}
16
+ * (e.g. `'credentials'`/`'dns'`/`'queued'`/`'rate-limited'`), never a kit-owned reason enum. Not
17
+ * one of the terminal statuses in {@link OperationsState.finished}, and never pruned as history —
18
+ * a waiting entry is a pending offer, not something finished.
19
+ *
20
+ * Reached ONE way, and that is the point: the host's `IOperation.Wait` on a live operation. The
21
+ * body is parked, not gone, so there is always something to resume. Exits via the host's
22
+ * `IOperation.Resume` (back to `running`), the `DISMISS` route (to `cancelled`), or a direct
23
+ * complete/fail.
24
+ *
25
+ * Two simplifications got it here, both undoing the same mistake — encoding HOW an entry arrived
26
+ * rather than WHAT state it is in. It was once two values (`paused` and `interrupted`) that every
27
+ * host transition already treated as one band, and the host once also accepted crash-checkpoint
28
+ * entries with no live body at all (`RegisterWaiting` + `resumePayload`), which forced every caller
29
+ * to answer "does this one have a handle?". The checkpoint half was cut in 0.2.0: crash recovery is
30
+ * the APP's business — it owns the checkpoint, and a resumed run is a fresh `Start()` like any other.
31
+ */
32
+ Waiting: 'waiting',
33
+ };
34
+ /**
35
+ * Mirrors `Shenora.Ipc.OperationEvents` — pinned against the host by
36
+ * `WireMirrorTests.Operation_event_names_match_the_host` (ALSO IN THIS BATCH, whole-branch review):
37
+ * these were bare string literals with nothing comparing them to the host's own constants, so a host
38
+ * rename left the suite green and the client permanently deaf to the renamed event.
39
+ */
40
+ export const OperationEventTypes = {
41
+ Updated: 'OPERATION_UPDATED',
42
+ ResumeRequested: 'OPERATION_RESUME_REQUESTED',
43
+ /**
44
+ * A client asked to wait a running operation (generic-library audit finding 3, renamed from
45
+ * `PAUSE_REQUESTED`) — the owning module should call the host's `IOperation.Wait` once it has
46
+ * actually stopped. Not subscribed by {@link createOperationsStore} itself, same as
47
+ * {@link ResumeRequested}: it targets the owning module's own service, not the generic operations
48
+ * store.
49
+ */
50
+ WaitRequested: 'OPERATION_WAIT_REQUESTED',
51
+ /**
52
+ * One or more operation ids left the host registry with no corresponding `Updated` snapshot —
53
+ * `MaxHistory` eviction, `CLEAR_FINISHED`, and the interrupted-entry drop on `RESUME` (Finding 4,
54
+ * generic-library audit). Payload is `{ operationIds: string[] }`; {@link createOperationsStore}
55
+ * folds it by deleting those ids, which is what let the two hand-written optimistic prunes
56
+ * (`clearFinished`/`resume` used to carry one each) be removed — one authoritative event that
57
+ * cannot diverge from what the host actually did, replacing two guesses that could.
58
+ */
59
+ Removed: 'OPERATION_REMOVED',
60
+ };
61
+ /**
62
+ * Mirrors the route names `Shenora.Ipc.OperationsFacade` switches on (its own
63
+ * `ListType`/`CancelType`/`ClearFinishedType`/`ResumeType` constants) — pinned by
64
+ * `WireMirrorTests.Operation_route_names_match_the_hosts_facade`, same rationale as
65
+ * {@link OperationEventTypes}.
66
+ */
67
+ export const OperationRoutes = {
68
+ List: 'LIST',
69
+ Cancel: 'CANCEL',
70
+ ClearFinished: 'CLEAR_FINISHED',
71
+ Resume: 'RESUME',
72
+ /** Decline a pending Waiting offer by id — mirrors {@link Cancel}'s shape. */
73
+ Dismiss: 'DISMISS',
74
+ /**
75
+ * Ask the owning module to wait a running operation by id (generic-library audit finding 3,
76
+ * renamed from `PAUSE`) — mirrors {@link Resume}'s shape (`{ operationId }` → `{ requested }`).
77
+ * Asking is not acting: the owning module's own `IOperation.Wait` is what actually stops the work
78
+ * and publishes the transition, same split as {@link Resume} vs the host's `IOperation.Resume`.
79
+ */
80
+ Wait: 'WAIT',
81
+ };
82
+ /**
83
+ * Default `Shenora.Ipc.OperationRegistryOptions.ModuleName` — pinned by
84
+ * `WireMirrorTests.The_default_operations_module_name_matches_the_host`.
85
+ */
86
+ export const OperationModuleName = 'OPERATIONS';
87
+ /**
88
+ * Statuses that count as finished history. Deliberately excludes `waiting` — a resumable operation
89
+ * (whether a live `Wait()` or a crash-announced checkpoint) is a pending offer, "distinct from
90
+ * finished history" (`Shenora.Ipc.OperationStatus.Waiting`), not a terminal outcome.
91
+ */
92
+ const TERMINAL_STATUSES = new Set([
93
+ OperationStatuses.Completed,
94
+ OperationStatuses.Failed,
95
+ OperationStatuses.Cancelled,
96
+ ]);
97
+ function index(list) {
98
+ const byId = {};
99
+ for (const operation of list)
100
+ byId[operation.id] = operation;
101
+ return byId;
102
+ }
103
+ /**
104
+ * The one place `running`/`waiting`/`finished` are computed — wrap `byId` here, nowhere else.
105
+ */
106
+ function makeState(byId) {
107
+ return {
108
+ byId,
109
+ get running() {
110
+ return Object.values(byId).filter((operation) => operation.status === OperationStatuses.Running);
111
+ },
112
+ get waiting() {
113
+ return Object.values(byId).filter((operation) => operation.status === OperationStatuses.Waiting);
114
+ },
115
+ get finished() {
116
+ return Object.values(byId).filter((operation) => TERMINAL_STATUSES.has(operation.status));
117
+ },
118
+ };
119
+ }
120
+ /**
121
+ * Build a store instance over the operations module — the factory {@link useShenoraOperations}
122
+ * itself is built from. Exposed (rather than only the ready-made hook) for the same reason
123
+ * `WindowCommands` takes an optional bridge: a test needs a fake bridge/bus
124
+ * (`operations.test.ts`), an app that renamed the host's `OperationRegistryOptions.ModuleName`
125
+ * needs a store bound to that name instead of the unreachable default, and an app running a
126
+ * secondary window or auxiliary session needs its own scope-filtered instance instead of being
127
+ * stuck with the shared, unscoped default.
128
+ */
129
+ export function createOperationsStore(options = {}) {
130
+ const module = options.module ?? OperationModuleName;
131
+ return createShenoraStore(module, {
132
+ initial: makeState({}),
133
+ // LIST is the snapshot source (design §4.6): a store cannot replay a stream, so a component
134
+ // that mounts while work is already running gets it from here before folding any deltas.
135
+ // The payload carries `scope` so the initial load is filtered the SAME way the deltas are
136
+ // (below, and via createShenoraStore's own `scope` option) — both halves must agree, or a
137
+ // scoped store loads every scope once and then never sheds the out-of-scope rows.
138
+ snapshot: {
139
+ type: OperationRoutes.List,
140
+ payload: options.scope !== undefined ? { scope: options.scope } : undefined,
141
+ apply: (_state, data) => makeState(index(data)),
142
+ },
143
+ on: {
144
+ // ONE event type for every transition (design §4.3) — last-write-wins by id, so folding needs
145
+ // no ordering logic and no cross-type races.
146
+ [OperationEventTypes.Updated]: (state, payload) => makeState({ ...state.byId, [payload.id]: payload }),
147
+ // The ONE removal delta (Finding 4, generic-library audit), replacing the two hand-written
148
+ // optimistic prunes `clearFinished`/`resume` used to carry (see their own docs below) — deletes
149
+ // exactly the ids the host named, regardless of status; an id this store never had is a no-op.
150
+ [OperationEventTypes.Removed]: (state, payload) => {
151
+ const byId = { ...state.byId };
152
+ for (const id of payload.operationIds)
153
+ delete byId[id];
154
+ return makeState(byId);
155
+ },
156
+ },
157
+ actions: ({ post }) => ({
158
+ cancel: (operationId) => post(OperationRoutes.Cancel, { payload: { operationId } }),
159
+ dismiss: (operationId) => post(OperationRoutes.Dismiss, { payload: { operationId } }),
160
+ wait: (operationId) => post(OperationRoutes.Wait, { payload: { operationId } }),
161
+ clearFinished: () =>
162
+ // Forward THIS store's own configured scope (Finding 1, generic-library audit) — the same
163
+ // key the LIST snapshot payload already carries above. No local mutation here any more
164
+ // (Finding 4): the host's OPERATION_REMOVED is the ONLY thing that removes a row now, which
165
+ // is also what makes the scope threading safe to add — nothing here can diverge from what
166
+ // the host actually cleared.
167
+ post(OperationRoutes.ClearFinished, {
168
+ payload: options.scope !== undefined ? { scope: options.scope } : undefined,
169
+ }),
170
+ resume: (operationId) => post(OperationRoutes.Resume, { payload: { operationId } }),
171
+ }),
172
+ scope: options.scope,
173
+ bridge: options.bridge,
174
+ bus: options.bus,
175
+ });
176
+ }
177
+ /**
178
+ * The client side of the operations primitive (design §4.6, `OperationsFacade` +
179
+ * `IOperationRegistry`): snapshots via `LIST` on first subscribe, then folds `OPERATION_UPDATED` by
180
+ * id — one subscription however many components read it, and a late mounter renders CURRENT state
181
+ * because the host is authoritative (the store primitive's own late-mounter case is now
182
+ * host-backed end to end). `running`/`waiting`/`finished` are selectors an activity panel or status
183
+ * bar reads directly: `useShenoraOperations((s) => s.waiting)` for the one "needs you" bucket
184
+ * (every entry in it reached the band the same way, so there is no sub-case to filter for; read
185
+ * `waitReason` for WHY it is waiting). Bound to the default module/no scope — use
186
+ * {@link createOperationsStore} directly for a renamed module or a scope-filtered instance.
187
+ *
188
+ * Headless, per D13: no component, no UI opinion, no `ProcessType`-style enum — what an operation
189
+ * IS stays the app's `kind` string; this only carries the uniform lifecycle around it.
190
+ */
191
+ export const useShenoraOperations = createOperationsStore();
package/dist/store.d.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import { type ShenoraBridge, type PostOptions } from './bridge.js';
2
2
  import { type ShenoraEventBus } from './eventBus.js';
3
3
  import type { EventMessage } from './types.js';
4
- /** The IPC handles a store's `actions` are built over. */
5
- export interface ShenoraStoreIo {
4
+ /** The IPC handles + local-state seam a store's `actions` are built over. */
5
+ export interface ShenoraStoreIo<TState = unknown> {
6
6
  /** Fire-and-forget send to this store's module. Returns the request id. */
7
7
  post: <TPayload = unknown>(type: string, options?: PostOptions<TPayload>) => string;
8
8
  /** Correlated request to this store's module — for calls that are quick AND UI-thread-safe. */
@@ -10,6 +10,17 @@ export interface ShenoraStoreIo {
10
10
  payload?: TPayload;
11
11
  timeoutMs?: number;
12
12
  }) => Promise<TData>;
13
+ /** Current state — for an action that needs to compute an OPTIMISTIC update from it. */
14
+ getState: () => TState;
15
+ /**
16
+ * Apply a local state change with NO host round trip and NO wire event — the seam for an
17
+ * optimistic update an action can fully decide by itself (e.g. dropping the rows a
18
+ * `CLEAR_FINISHED`-style action just told the host to drop). The host stays authoritative for
19
+ * everything a `snapshot`/`on` reducer already covers; this exists for the narrow case where the
20
+ * action already knows the answer and a host round trip would only be a delivery delay, not new
21
+ * information.
22
+ */
23
+ setState: (reduce: (state: TState) => TState) => void;
13
24
  }
14
25
  /** How a store loads the state that already exists before anyone was watching. */
15
26
  export interface ShenoraStoreSnapshot<TState> {
@@ -34,7 +45,7 @@ export interface ShenoraStoreOptions<TState, TActions> {
34
45
  /** Event type (within this module) → PURE reducer over state. */
35
46
  on?: Record<string, (state: TState, payload: never, event: EventMessage) => TState>;
36
47
  /** Fire-and-forget senders / requests, exposed on the returned hook. */
37
- actions?: (io: ShenoraStoreIo) => TActions;
48
+ actions?: (io: ShenoraStoreIo<TState>) => TActions;
38
49
  /** Optional app-defined routing scope, applied to both the subscriptions and the sends. */
39
50
  scope?: string;
40
51
  /** Test/multi-transport seams. Default: the shared bridge and event bus. */
@@ -64,7 +75,7 @@ export interface ShenoraStore<TState, TActions> {
64
75
  * A store fed by one module's host event stream, shared by every component that reads it.
65
76
  *
66
77
  * This is the shape a desktop app needs and the one three sibling apps each hand-built before it
67
- * existed here — see `docs/2026-07-31-shenora-oneway-ipc-design.md` §5 for the survey. It exists
78
+ * existed here — see `.claude/knowledge/generic-library.md`'s worked example for that survey. It exists
68
79
  * because status- and progress-driven UI is inherently MANY-WATCHERS: a full panel and a compact
69
80
  * progress strip want the same live state, and without a shared store each re-implements the wiring,
70
81
  * each opens its own subscription, and each starts empty.
package/dist/store.js CHANGED
@@ -5,7 +5,7 @@ import { eventBus as defaultEventBus } from './eventBus.js';
5
5
  * A store fed by one module's host event stream, shared by every component that reads it.
6
6
  *
7
7
  * This is the shape a desktop app needs and the one three sibling apps each hand-built before it
8
- * existed here — see `docs/2026-07-31-shenora-oneway-ipc-design.md` §5 for the survey. It exists
8
+ * existed here — see `.claude/knowledge/generic-library.md`'s worked example for that survey. It exists
9
9
  * because status- and progress-driven UI is inherently MANY-WATCHERS: a full panel and a compact
10
10
  * progress strip want the same live state, and without a shared store each re-implements the wiring,
11
11
  * each opens its own subscription, and each starts empty.
@@ -116,6 +116,8 @@ export function createShenoraStore(module, options) {
116
116
  const io = {
117
117
  post: (type, postOptions) => bridge().post(module, type, { scope, ...postOptions }),
118
118
  invoke: (type, invokeOptions) => bridge().invoke(module, type, { scope, ...invokeOptions }),
119
+ getState,
120
+ setState: (reduce) => setState(reduce(getState())),
119
121
  };
120
122
  function useStore(selector) {
121
123
  // getSnapshot must return a STABLE value for an unchanged store, or React throws
@@ -42,6 +42,17 @@ export interface UseDropZoneOptions {
42
42
  bus?: ShenoraEventBus;
43
43
  }
44
44
  /**
45
+ * **The file-drop API for a Shenora page. Do not use the DOM's own drop event for files —
46
+ * it is not an alternative here, it is the thing this exists to replace.**
47
+ *
48
+ * A page-side `onDrop` gets a `File` handle whose only accessor is its CONTENT. In a shell
49
+ * architecture the page is UI and the host does the file work, so those bytes have to be read into
50
+ * the renderer and then pushed across the IPC boundary: a full copy of every dropped file, EAGERLY,
51
+ * at drop time, before the app knows whether it wants any of them. Drop 200 files to filter by
52
+ * extension and you pay for all 200; drop a multi-GB asset and you pay that, to reach a file the
53
+ * host could have opened off the same disk. This hook gives you `string[]` paths instead — open
54
+ * lazily, stream, hash incrementally, move or link without copying, watch for changes.
55
+ *
45
56
  * Sync a native drop-zone overlay to a page element, ported from the primary desktop sibling
46
57
  * (its fix-history comments kept below): the host positions a transparent WinForms overlay
47
58
  * over the element to capture REAL OS file paths — including drags started while the app is in
@@ -6,6 +6,17 @@ import { debounce, randomId } from './internal.js';
6
6
  export const DROP_ZONE_MODULE = 'DROP_ZONE';
7
7
  const newZoneId = () => randomId('drop-zone-');
8
8
  /**
9
+ * **The file-drop API for a Shenora page. Do not use the DOM's own drop event for files —
10
+ * it is not an alternative here, it is the thing this exists to replace.**
11
+ *
12
+ * A page-side `onDrop` gets a `File` handle whose only accessor is its CONTENT. In a shell
13
+ * architecture the page is UI and the host does the file work, so those bytes have to be read into
14
+ * the renderer and then pushed across the IPC boundary: a full copy of every dropped file, EAGERLY,
15
+ * at drop time, before the app knows whether it wants any of them. Drop 200 files to filter by
16
+ * extension and you pay for all 200; drop a multi-GB asset and you pay that, to reach a file the
17
+ * host could have opened off the same disk. This hook gives you `string[]` paths instead — open
18
+ * lazily, stream, hash incrementally, move or link without copying, watch for changes.
19
+ *
9
20
  * Sync a native drop-zone overlay to a page element, ported from the primary desktop sibling
10
21
  * (its fix-history comments kept below): the host positions a transparent WinForms overlay
11
22
  * over the element to capture REAL OS file paths — including drags started while the app is in
@@ -72,9 +72,13 @@ export declare class WindowCommands extends BaseModuleService<WindowRequests> {
72
72
  }
73
73
  /**
74
74
  * The max/restore-glyph resync pattern from the source app: the authoritative maximize state,
75
- * re-queried on every window resize (a maximize/restore always resizes the window, and the DOM
76
- * has no other signal for the manual work-area maximize). Failures (plain browser, no host)
77
- * leave it false.
75
+ * re-queried when a resize SETTLES (a maximize/restore always resizes the window, and the DOM has no
76
+ * other signal for the manual work-area maximize). Failures (plain browser, no host) leave it false.
77
+ *
78
+ * Read once immediately, then on the TRAILING edge of a 100 ms debounce — not once per `resize`
79
+ * event, which a window drag fires ~180 times in three seconds. Coalescing is the correct semantics
80
+ * here, not just the cheap one: maximize/restore is a single step, so only the end state matters. Do
81
+ * not build on intermediate values during a drag; there are none.
78
82
  */
79
83
  export declare function useWindowMaximized(commands?: WindowCommands): boolean;
80
84
  export {};
@@ -60,9 +60,13 @@ export class WindowCommands extends BaseModuleService {
60
60
  }
61
61
  /**
62
62
  * The max/restore-glyph resync pattern from the source app: the authoritative maximize state,
63
- * re-queried on every window resize (a maximize/restore always resizes the window, and the DOM
64
- * has no other signal for the manual work-area maximize). Failures (plain browser, no host)
65
- * leave it false.
63
+ * re-queried when a resize SETTLES (a maximize/restore always resizes the window, and the DOM has no
64
+ * other signal for the manual work-area maximize). Failures (plain browser, no host) leave it false.
65
+ *
66
+ * Read once immediately, then on the TRAILING edge of a 100 ms debounce — not once per `resize`
67
+ * event, which a window drag fires ~180 times in three seconds. Coalescing is the correct semantics
68
+ * here, not just the cheap one: maximize/restore is a single step, so only the end state matters. Do
69
+ * not build on intermediate values during a drag; there are none.
66
70
  */
67
71
  export function useWindowMaximized(commands) {
68
72
  const [maximized, setMaximized] = useState(false);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shenora/react",
3
- "version": "0.1.2",
3
+ "version": "0.4.0",
4
4
  "description": "React client for Shenora desktop hosts: correlated invoke/send/subscribe over the WebView2 bridge, typed module services, hooks, and a browser fallback for pure-UI development.",
5
5
  "license": "MIT",
6
6
  "author": "Jiarong Gu",