dsh-rewind-plugin 0.20.0 → 0.21.1

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.
@@ -1,301 +0,0 @@
1
- /**
2
- * dsh-rewind portal half: the per-user-message ↶ rewind button, rendered as a
3
- * React portal inside the message's `MessageIconActions` row.
4
- *
5
- * Why portals (aligned with the copy button's own rendering): the copy button
6
- * is a React child of the actions row, painted in the same commit as the
7
- * bubble. A pure-DOM `appendChild` (the earlier approach) lands one microtask
8
- * later and re-runs a full-transcript scan on EVERY mutation, which can push
9
- * the paint of a newly sent bubble — the "occasional hiccup before the bubble
10
- * shows". Portals let React own the button lifecycle (mount/unmount with the
11
- * row, no orphaned buttons, no manual re-attach after harness re-renders),
12
- * and the target collection is coalesced (one refresh per mutation batch) and
13
- * diffed (no setState churn when nothing changed).
14
- *
15
- * Mount point: the plugin registers a session-scoped bridge into the harness's
16
- * `conversation.session.header.actions` list slot. The bridge renders NO
17
- * header UI — it only portals buttons into the user rows of the session the
18
- * harness mounts it for. That slot is the harness-native way to get a
19
- * per-session React mount without touching any source; the registration is
20
- * typed structurally (see `SlotsLike`) so the plugin never imports the
21
- * conversation UI package's types and survives its version drift.
22
- *
23
- * COUPLING NOTE — HIGH. There is no harness interface for a per-user-message
24
- * action (see docs/compat/audit.md): `MessageIconActions.extraActions` and the
25
- * `conversation.chat.assistant-actions` slot are wired for assistant messages
26
- * only, and no per-user-message action slot exists. So this portal targets
27
- * undocumented internal structure — the `data-chat-flow-kind`,
28
- * `data-chat-anchor-key`, `data-composer-input`, `data-composer-card` and
29
- * `data-pending-steering` attributes plus the `anchorSeq` field read in
30
- * `client/hidden.ts`. Those are harness-internal and may change with the UI;
31
- * this module (and `hidden.ts`) must be re-adapted to follow, and is the
32
- * migration target when a first-class user-action slot or an official renderer
33
- * hook surface appears. The coupling is accepted deliberately because a
34
- * standards-conformant alternative does not exist today; it is not a defect to
35
- * be removed while the DOM-portal approach stands.
36
- *
37
- * Re-verified against 0.1.6-alpha.2: the five remaining anchors still exist (the
38
- * anchor key and flow kind still come from `ChatNodeSeat`, values `user` /
39
- * `steering`); only `data-time-hover-root` is gone, and nothing read it.
40
- *
41
- * @module dsh-rewind/client/portals
42
- */
43
- import { type ReactNode } from 'react';
44
- import type { SessionFace } from '@deepseek-ai/dsh-api-session-controller/client';
45
- import { type ChatOf, type ChatWatch, type HiddenChat, type MessageImageRef } from './hidden.ts';
46
- import type { RewindKey } from './locales.ts';
47
- type Translate = (key: RewindKey, params?: Record<string, unknown>) => string;
48
- /** One portal target: the actions row of a user/steering seat + its durable node. */
49
- export type PortalTarget = {
50
- readonly kind: 'durable';
51
- /** The seat's chat node key (React reconciliation + diff identity). */
52
- readonly key: string;
53
- /** The row's actions container (React portal target). */
54
- readonly container: HTMLElement;
55
- readonly seq: number;
56
- readonly time: number;
57
- readonly preview: string;
58
- } | {
59
- readonly kind: 'pending';
60
- /** `pending:${itemId}` — stable per inbox occurrence. */
61
- readonly key: string;
62
- /** The row's actions container (React portal target). */
63
- readonly container: HTMLElement;
64
- /** The host inbox occurrence the retract button addresses. */
65
- readonly itemId: string;
66
- /** Complete editable text; null when the message contains no text block. */
67
- readonly text: string | null;
68
- readonly preview: string;
69
- };
70
- /** Capabilities the session-scoped bridge receives from the plugin apply(). */
71
- export interface RewindBridgeDeps {
72
- readonly sessionOf: (sessionId: string) => SessionFace | undefined;
73
- /**
74
- * Chat reader on the `uiConversation` "chat" view: every chat
75
- * snapshot read goes through it. See `chatSnapshotOf` in hidden.ts.
76
- */
77
- readonly chatOf: ChatOf;
78
- /**
79
- * Subscribe to one session's live chat-update signal, so the composer refill
80
- * waiting on an executed rewind's chat node can be woken when the chat
81
- * snapshot changes. Passed through to `waitForCommand`.
82
- */
83
- readonly watchChat: ChatWatch;
84
- /**
85
- * Whether a session is the one the MAIN VIEW currently retains — the alpha.2
86
- * replacement for the removed `SessionListState.current` (see
87
- * `isMainViewSession` in index.ts). A predicate rather than "the current
88
- * session id", so a multi-window client can never refill the wrong composer.
89
- */
90
- readonly isMainViewSession: (sessionId: string) => boolean;
91
- readonly t: Translate;
92
- readonly subscribeLocale: (cb: () => void) => () => void;
93
- /**
94
- * Session-aware composer writer (see `writeComposer`): the
95
- * `conversation.input` facade `setDraft` when reachable, else the DOM fill.
96
- * Session-scoped so the refill only lands in the session that just rewound.
97
- */
98
- readonly setComposerText: (sessionId: string, text: string) => boolean;
99
- /**
100
- * Session-aware image restorer (see `restoreImages` in index.ts): re-adds
101
- * a withdrawn message's durable image references to that session's
102
- * composer draft so a rewind of an image-bearing message does not lose
103
- * the pictures. Optional — absent degrades to the text-only refill.
104
- */
105
- readonly restoreImages?: (sessionId: string, images: readonly MessageImageRef[]) => Promise<void>;
106
- }
107
- /** Structural face of the runtime slot service (see the module doc). */
108
- export interface SlotsLike {
109
- inject(key: string, install: () => () => void): () => void;
110
- /**
111
- * One slot registration. `Face` is the injected business face the renderer
112
- * decomposes (its `hooks` compartment is rebound as a `use<Name>` selector
113
- * hook); `Props` is the COMPOSED props a component receives — the slot's
114
- * runtime owner share, the locale seat, and that face — which is what the
115
- * component is typed against, never the face alone.
116
- */
117
- register<P, Face = P>(entry: {
118
- readonly name: string;
119
- readonly id?: string;
120
- readonly order?: number;
121
- readonly key?: string;
122
- readonly locale?: string;
123
- readonly inject?: () => Face;
124
- }, component: (props: P) => ReactNode): () => void;
125
- }
126
- /**
127
- * The session input facade's write face (structural, so the plugin
128
- * never imports the conversation UI package). `setDraft` replaces the whole
129
- * composer draft through the harness's own Lexical editor — the correct way
130
- * to restore the withdrawn text.
131
- */
132
- interface ComposerDraftWriter {
133
- setDraft(text: string): void;
134
- /**
135
- * Return the keyboard to the composer with the caret it last held (the
136
- * facade's own `focus`). Optional so the DOM fallback and older faces still
137
- * restore the text.
138
- */
139
- focus?(): void;
140
- }
141
- /**
142
- * Fill the dsh composer with `text` through the `contenteditable`
143
- * DOM path. Used by `setComposerText` (the harness-facade-aware writer) as the
144
- * last-resort and by `runRewindAndFill` to put the withdrawn target message
145
- * back into the composer after a rewind. Best-effort — no composer match means
146
- * false, never a throw.
147
- */
148
- export declare function fillComposer(text: string): boolean;
149
- /**
150
- * Composer write: prefer the harness facade's `setDraft` (correct whole-draft
151
- * replace), then degrade to the DOM `fillComposer` (contenteditable). A facade
152
- * that throws (session teardown) is treated as absent so the DOM path still
153
- * restores the text. Never throws.
154
- * @param text - the withdrawn target message text.
155
- * @param facade - the session input draft writer, when reachable.
156
- * @returns whether a channel applied the text.
157
- */
158
- export declare function writeComposer(text: string, facade: ComposerDraftWriter | undefined): boolean;
159
- /**
160
- * Execute one rewind from the popover and, when it settles successfully,
161
- * put the withdrawn target message's text back into the composer so the
162
- * user can edit and re-send.
163
- *
164
- * THE COMPOSER FILL IS EVENT-DRIVEN: it runs only when THIS page performed
165
- * the rewind (the user clicked confirm moments ago). It must NEVER scan
166
- * loaded history for rewind commands: a session window opens with only
167
- * the tail page and grows via loadOlder, so a "command already in the
168
- * snapshot" cannot be told apart from "command executed in this page" —
169
- * the old baseline heuristic refilled withdrawn text into the composer
170
- * after switching sessions or restarting dsh.
171
- */
172
- export declare function runRewindAndFill(session: SessionFace, seq: number, mode: 'chat' | 'both', isMainViewSession: (sessionId: string) => boolean, chatOf: ChatOf, watchChat: ChatWatch, setComposerText: (sessionId: string, text: string) => boolean, restoreImages?: (sessionId: string, images: readonly MessageImageRef[]) => Promise<void>): Promise<void>;
173
- /**
174
- * Locate the actions container of a user/steering seat row — the element the
175
- * ↶ button portals into (the copy/branch IconActions row).
176
- *
177
- * The finder is STRUCTURAL on purpose: `MessageIconActions` marks this container
178
- * with `data-clock`, which names clock placement rather than the action row (the
179
- * old `data-time-hover-root` marker lived on the per-turn tail footer and is
180
- * gone in 0.1.6-alpha.2), so the container is located as the direct holder of
181
- * the copy `<button>` (the `MessageIconActions` container, which mounts that
182
- * button as a direct child).
183
- *
184
- * Returns undefined when no qualifying container is found; the caller refuses
185
- * to portal (never a crash, never a wrong attachment). Exported as a test seam
186
- * (see `collectTargets`) so the row-shape finder is exercised directly for
187
- * both durable and pending row shapes without a full React portal render.
188
- */
189
- export declare function actionsContainerOf(row: HTMLElement | undefined): HTMLElement | undefined;
190
- /**
191
- * Collect the portal targets of one session: user rows × snapshot nodes.
192
- * Exported as a test seam — the DOM→targets pairing that drives the ↶ button
193
- * is otherwise only reachable through a full React portal render.
194
- */
195
- export declare function collectTargets(chat: HiddenChat): readonly PortalTarget[];
196
- /**
197
- * The session-kind slice the durable-target gate reads — the session
198
- * snapshot's `subagent` cell, the Harness's own runtime signal for a
199
- * direct-subagent (child) session. Typed structurally so the plugin never
200
- * imports the session-controller snapshot contract.
201
- */
202
- export interface SessionKindLike {
203
- /** Non-null exactly while this Session is addressed as a subagent child. */
204
- readonly subagent: unknown;
205
- }
206
- /**
207
- * Whether a session is rewind-inert: a direct-subagent (child) session.
208
- *
209
- * The Harness refuses every generic Session RPC for a subagent-owned identity
210
- * (`session/agent-busy`, "use subagent delivery for this child session"), so
211
- * `/rewind` can never execute there — the command's own admission is the
212
- * refusal, before any handler runs. A wired-up ↶ button in such a session is
213
- * therefore dead UI that closes its popover and does nothing
214
- * (SiriLee/dsh-rewind#26). This mirrors the Harness's own slash-command
215
- * directory (which returns no commands for an addressed child), the pending
216
- * path's `collectPendingTargets` gate, and the Host's `isSubagentSession`
217
- * skips: a child session gets no rewind surface and records no snapshot.
218
- */
219
- export declare function isRewindInertSession(snapshot: SessionKindLike): boolean;
220
- /**
221
- * Collect the durable (sent-message) rewind targets of one session: none at all
222
- * for a rewind-inert subagent session, otherwise `collectTargets`' DOM→target
223
- * pairing. Kept separate from `collectTargets` so the session-kind gate is a
224
- * pure, directly testable decision.
225
- * @param snapshot - the session snapshot carrying the `subagent` cell.
226
- * @param chat - the session's chat snapshot, or undefined while unavailable.
227
- */
228
- export declare function collectDurableTargets(snapshot: SessionKindLike, chat: HiddenChat | undefined): readonly PortalTarget[];
229
- /**
230
- * The pending bubble row's compared text and its message-attachment block
231
- * count, read from a CLONE (the live row is never touched).
232
- *
233
- * Both the actions container and the message-attachment block are excluded from
234
- * the text: the copy button's Tooltip mounts its label inside that button, and
235
- * the attachment block renders text of its own (a file name/size, a lone
236
- * image's loading label) that no inbox row reproduces. The block is counted
237
- * instead, as the attachment half of the match key (`matchPendingRows`).
238
- *
239
- * The clone's counterpart is taken by query position, not by position among the
240
- * row's children: the harness renders the attachment block BEFORE the message
241
- * bubble, so "drop the first child" would drop the message text.
242
- *
243
- * @param row - one rendered `[data-pending-steering]` row.
244
- * @returns the compared text and the attachment block count.
245
- */
246
- export declare function bubbleTextOf(row: HTMLElement): {
247
- readonly text: string;
248
- readonly attachments: number;
249
- };
250
- interface RewindPortalsProps extends RewindBridgeDeps {
251
- readonly sessionId: string;
252
- }
253
- /**
254
- * Session-scoped portal bridge: renders the ↶ button of every user message
255
- * row of the session the harness mounts it for. The refresh is coalesced
256
- * (one pass per microtask window via queueMicrotask) and diffed (setState is
257
- * skipped when the target set is unchanged), so the plugin never runs a
258
- * synchronous full-transcript scan inside a commit microtask.
259
- *
260
- * The refresh is driven by the three inputs its targets are made of: the chat
261
- * snapshot's visible order, the session's `inbox` projection (the pending
262
- * occurrences — see `syncInboxSubscription`), and the DOM (row/actions mounts,
263
- * via the scoped observer).
264
- */
265
- export declare function RewindPortals({ sessionId, sessionOf, chatOf, isMainViewSession, watchChat, t, subscribeLocale, setComposerText, restoreImages }: RewindPortalsProps): ReactNode;
266
- /** The current composer draft: the contenteditable `textContent`.
267
- * Empty when the composer is absent. Exported as a test seam (the
268
- * empty-composer guard in `retractPending`). */
269
- export declare function composerText(): string;
270
- /**
271
- * Withdraw one pre-sent (pending steering) message and every steering message
272
- * after it (the rollback point's "future"), WITHOUT interrupting the run.
273
- *
274
- * The message is still in the agent's next-step inbox, so the loop has not
275
- * claimed it into any request: removing it cannot change what the model is
276
- * generating. Stopping the run first (the durable-rewind rule) would throw
277
- * away in-flight work — a long tool call, a partial answer — for an edit the
278
- * model can never observe (SiriLee/dsh-rewind#27). Only the durable rewind
279
- * needs a stop, because it cuts the model-visible surface.
280
- *
281
- * 1. Remove the target and its future (steering only; queued stays) through
282
- * the session's own `updateQueue` channel, oldest first.
283
- * 2. Put the target's own images back, then its text when the composer is
284
- * empty (the empty-composer guard, so a draft is never clobbered).
285
- *
286
- * The realistic failure is `queue-item-not-found`: a step boundary claimed
287
- * the whole next-step batch a moment ago, so the message is now durable and
288
- * the durable row's regular rewind button takes over with no gap. Nothing is
289
- * removed then, and the composer is NOT refilled — refilling would invite a
290
- * duplicate send of a message the model has already received.
291
- */
292
- export declare function retractPending(session: SessionFace, itemId: string, text: string | null, setComposerText: (sessionId: string, text: string) => boolean, restoreImages?: (sessionId: string, images: readonly MessageImageRef[]) => Promise<void>): Promise<void>;
293
- /**
294
- * Build the slot-entry component for the plugin apply(): a tiny bridge that
295
- * injects the apply-time capabilities (session resolution, locale, rewind
296
- * runner) into the module-level `RewindPortals`.
297
- */
298
- export declare function createRewindBridge(deps: RewindBridgeDeps): (props: {
299
- readonly sessionId: string;
300
- }) => ReactNode;
301
- export {};