/** * dsh-rewind portal half: the per-user-message ↶ rewind button, rendered as a * React portal inside the message's `MessageIconActions` row. * * Why portals (aligned with the copy button's own rendering): the copy button * is a React child of the actions row, painted in the same commit as the * bubble. A pure-DOM `appendChild` (the earlier approach) lands one microtask * later and re-runs a full-transcript scan on EVERY mutation, which can push * the paint of a newly sent bubble — the "occasional hiccup before the bubble * shows". Portals let React own the button lifecycle (mount/unmount with the * row, no orphaned buttons, no manual re-attach after harness re-renders), * and the target collection is coalesced (one refresh per mutation batch) and * diffed (no setState churn when nothing changed). * * Mount point: the plugin registers a session-scoped bridge into the harness's * `conversation.session.header.actions` list slot. The bridge renders NO * header UI — it only portals buttons into the user rows of the session the * harness mounts it for. That slot is the harness-native way to get a * per-session React mount without touching any source; the registration is * typed structurally (see `SlotsLike`) so the plugin never imports the * conversation UI package's types and survives its version drift. * * COUPLING NOTE — HIGH. There is no harness interface for a per-user-message * action (see docs/compat/audit.md): `MessageIconActions.extraActions` and the * `conversation.chat.assistant-actions` slot are wired for assistant messages * only, and no per-user-message action slot exists. So this portal targets * undocumented internal structure — the `data-chat-flow-kind`, * `data-chat-anchor-key`, `data-composer-input`, `data-composer-card` and * `data-pending-steering` attributes plus the `anchorSeq` field read in * `client/hidden.ts`. Those are harness-internal and may change with the UI; * this module (and `hidden.ts`) must be re-adapted to follow, and is the * migration target when a first-class user-action slot or an official renderer * hook surface appears. The coupling is accepted deliberately because a * standards-conformant alternative does not exist today; it is not a defect to * be removed while the DOM-portal approach stands. * * Re-verified against 0.1.6-alpha.2: the five remaining anchors still exist (the * anchor key and flow kind still come from `ChatNodeSeat`, values `user` / * `steering`); only `data-time-hover-root` is gone, and nothing read it. * * @module dsh-rewind/client/portals */ import { type ReactNode } from 'react'; import type { SessionFace } from '@deepseek-ai/dsh-api-session-controller/client'; import { type ChatOf, type ChatWatch, type HiddenChat, type MessageImageRef } from './hidden.ts'; import type { RewindKey } from './locales.ts'; type Translate = (key: RewindKey, params?: Record) => string; /** One portal target: the actions row of a user/steering seat + its durable node. */ export type PortalTarget = { readonly kind: 'durable'; /** The seat's chat node key (React reconciliation + diff identity). */ readonly key: string; /** The row's actions container (React portal target). */ readonly container: HTMLElement; readonly seq: number; readonly time: number; readonly preview: string; } | { readonly kind: 'pending'; /** `pending:${itemId}` — stable per inbox occurrence. */ readonly key: string; /** The row's actions container (React portal target). */ readonly container: HTMLElement; /** The host inbox occurrence the retract button addresses. */ readonly itemId: string; /** Complete editable text; null when the message contains no text block. */ readonly text: string | null; readonly preview: string; }; /** Capabilities the session-scoped bridge receives from the plugin apply(). */ export interface RewindBridgeDeps { readonly sessionOf: (sessionId: string) => SessionFace | undefined; /** * Chat reader on the `uiConversation` "chat" view: every chat * snapshot read goes through it. See `chatSnapshotOf` in hidden.ts. */ readonly chatOf: ChatOf; /** * Subscribe to one session's live chat-update signal, so the composer refill * waiting on an executed rewind's chat node can be woken when the chat * snapshot changes. Passed through to `waitForCommand`. */ readonly watchChat: ChatWatch; /** * Whether a session is the one the MAIN VIEW currently retains — the alpha.2 * replacement for the removed `SessionListState.current` (see * `isMainViewSession` in index.ts). A predicate rather than "the current * session id", so a multi-window client can never refill the wrong composer. */ readonly isMainViewSession: (sessionId: string) => boolean; readonly t: Translate; readonly subscribeLocale: (cb: () => void) => () => void; /** * Session-aware composer writer (see `writeComposer`): the * `conversation.input` facade `setDraft` when reachable, else the DOM fill. * Session-scoped so the refill only lands in the session that just rewound. */ readonly setComposerText: (sessionId: string, text: string) => boolean; /** * Session-aware image restorer (see `restoreImages` in index.ts): re-adds * a withdrawn message's durable image references to that session's * composer draft so a rewind of an image-bearing message does not lose * the pictures. Optional — absent degrades to the text-only refill. */ readonly restoreImages?: (sessionId: string, images: readonly MessageImageRef[]) => Promise; } /** Structural face of the runtime slot service (see the module doc). */ export interface SlotsLike { inject(key: string, install: () => () => void): () => void; /** * One slot registration. `Face` is the injected business face the renderer * decomposes (its `hooks` compartment is rebound as a `use` selector * hook); `Props` is the COMPOSED props a component receives — the slot's * runtime owner share, the locale seat, and that face — which is what the * component is typed against, never the face alone. */ register(entry: { readonly name: string; readonly id?: string; readonly order?: number; readonly key?: string; readonly locale?: string; readonly inject?: () => Face; }, component: (props: P) => ReactNode): () => void; } /** * The session input facade's write face (structural, so the plugin * never imports the conversation UI package). `setDraft` replaces the whole * composer draft through the harness's own Lexical editor — the correct way * to restore the withdrawn text. */ interface ComposerDraftWriter { setDraft(text: string): void; /** * Return the keyboard to the composer with the caret it last held (the * facade's own `focus`). Optional so the DOM fallback and older faces still * restore the text. */ focus?(): void; } /** * Fill the dsh composer with `text` through the `contenteditable` * DOM path. Used by `setComposerText` (the harness-facade-aware writer) as the * last-resort and by `runRewindAndFill` to put the withdrawn target message * back into the composer after a rewind. Best-effort — no composer match means * false, never a throw. */ export declare function fillComposer(text: string): boolean; /** * Composer write: prefer the harness facade's `setDraft` (correct whole-draft * replace), then degrade to the DOM `fillComposer` (contenteditable). A facade * that throws (session teardown) is treated as absent so the DOM path still * restores the text. Never throws. * @param text - the withdrawn target message text. * @param facade - the session input draft writer, when reachable. * @returns whether a channel applied the text. */ export declare function writeComposer(text: string, facade: ComposerDraftWriter | undefined): boolean; /** * Execute one rewind from the popover and, when it settles successfully, * put the withdrawn target message's text back into the composer so the * user can edit and re-send. * * THE COMPOSER FILL IS EVENT-DRIVEN: it runs only when THIS page performed * the rewind (the user clicked confirm moments ago). It must NEVER scan * loaded history for rewind commands: a session window opens with only * the tail page and grows via loadOlder, so a "command already in the * snapshot" cannot be told apart from "command executed in this page" — * the old baseline heuristic refilled withdrawn text into the composer * after switching sessions or restarting dsh. */ 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): Promise; /** * Locate the actions container of a user/steering seat row — the element the * ↶ button portals into (the copy/branch IconActions row). * * The finder is STRUCTURAL on purpose: `MessageIconActions` marks this container * with `data-clock`, which names clock placement rather than the action row (the * old `data-time-hover-root` marker lived on the per-turn tail footer and is * gone in 0.1.6-alpha.2), so the container is located as the direct holder of * the copy `