/** * Browser half of @local/dsh-rewind-retry. * * Adds three actions to finished turns in the Chat view: * - Retry: fork the session right before this turn's prompt and re-send * the same prompt in the fork. * - Edit: fork at the same point and put the prompt into the fork's * composer so it can be changed before sending. * - Continue: (latest turn only, when it failed or produced no reply) send a * short "continue" prompt in the same session, keeping all work. * * The session log is append-only, so "rewind" is always a fork at an exact * event boundary (Host `session.fork` with `atSeq`). The original session is * kept intact as a branch. */ window.__ModuleLoader__.load({ id: '@local/dsh-rewind-retry', factory(require) { const React = require('react'); const h = React.createElement; const NS = 'rewind-retry'; const STYLE_ID = 'dsh-rewind-retry-style'; const DICTS = { en: { retry: 'Retry', retryHint: 'Retry: fork the session before this prompt and send it again', edit: 'Edit', editHint: 'Edit prompt: fork the session before this prompt and edit it in the composer', continue: 'Continue', continueHint: 'Continue this session from where the turn stopped', continuePrompt: 'Continue from where you left off.', working: 'Working…', failed: 'Rewind failed: {message}', attachments: 'The original prompt had {count} attachment(s). They were not carried over; attach them again before sending.', dismiss: 'Dismiss', }, zh: { retry: '重试', retryHint: '重试:在此提示词之前分叉会话,并重新发送', edit: '编辑', editHint: '编辑提示词:在此提示词之前分叉会话,并在输入框中修改', continue: '继续', continueHint: '在当前会话中从中断处继续', continuePrompt: '请从中断的地方继续。', working: '处理中…', failed: '回退失败:{message}', attachments: '原提示词包含 {count} 个附件,未能自动带入,请在发送前重新添加。', dismiss: '关闭', }, }; const CSS = ` .rwr-group{display:inline-flex;align-items:center;gap:8px} .rwr-icon{width:calc(28px + var(--dsh-content-font-delta,0px));height:calc(28px + var(--dsh-content-font-delta,0px));color:var(--dsw-alias-label-tertiary,var(--dsw-alias-label-secondary));cursor:pointer;background:0 0;border:none;border-radius:28px;justify-content:center;align-items:center;padding:6px;display:inline-flex} .rwr-icon svg{width:calc(17px + var(--dsh-content-font-delta,0px));height:calc(17px + var(--dsh-content-font-delta,0px))} .rwr-icon:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary)} .rwr-icon:focus-visible,.rwr-pill:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px} .rwr-icon[aria-disabled=true],.rwr-pill[aria-disabled=true]{cursor:default;opacity:.4} .rwr-icon[aria-disabled=true]:hover{background:0 0;color:var(--dsw-alias-label-tertiary,var(--dsw-alias-label-secondary))} .rwr-row{min-height:calc(28px + var(--dsh-content-font-delta,0px));display:flex;flex-wrap:wrap;align-items:center;gap:8px} .rwr-pill{height:calc(28px + var(--dsh-content-font-delta,0px));display:inline-flex;align-items:center;gap:6px;padding:0 12px 0 10px;border:.5px solid var(--dsw-alias-border-l2);border-radius:14px;background:0 0;color:var(--dsw-alias-label-secondary);font:inherit;font-size:var(--dsh-content-font-size-secondary,13px);cursor:pointer} .rwr-pill svg{width:14px;height:14px} .rwr-pill:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)} .rwr-pill[aria-disabled=true]:hover{background:0 0;color:var(--dsw-alias-label-secondary)} .rwr-msg{font-size:var(--dsh-content-font-size-secondary,13px);color:var(--dsw-alias-label-secondary)} .rwr-msg[data-kind=error]{color:var(--dsw-alias-state-error-primary)} .rwr-notice{display:flex;align-items:flex-start;gap:12px;margin:0 0 8px;padding:8px 12px;border:.5px solid var(--dsw-alias-border-l2);border-radius:12px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-secondary);font-size:13px;line-height:20px} .rwr-notice span{flex:1;min-width:0} .rwr-notice button{flex:none;background:0 0;border:none;padding:0;color:var(--dsw-alias-label-primary);font:inherit;cursor:pointer;text-decoration:underline} `; // --------------------------------------------------------------------- // Icons (inline SVG, currentColor) // --------------------------------------------------------------------- function svg(children) { return h('svg', { viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true, }, ...children); } const ICONS = { retry: () => svg([ h('path', { key: 'a', d: 'M3 12a9 9 0 1 0 2.64-6.36L3 8.25' }), h('path', { key: 'b', d: 'M3 3.5v4.75h4.75' }), ]), edit: () => svg([ h('path', { key: 'a', d: 'M12 20h8.5' }), h('path', { key: 'b', d: 'M16.4 3.6a2.05 2.05 0 0 1 2.9 2.9L7.5 18.3 3.5 19.5l1.2-4z' }), ]), continue: () => svg([ h('path', { key: 'a', d: 'M6 4.5v15l11-7.5z' }), h('path', { key: 'b', d: 'M20 5v14' }), ]), }; // --------------------------------------------------------------------- // Pure planning over the loaded durable event window // --------------------------------------------------------------------- /** Durable events of one Session window, ascending by seq. */ function durableEvents(snapshot) { const out = []; const entries = snapshot && snapshot.entries ? snapshot.entries : []; for (const entry of entries) { if (!entry || entry.type === 'transient') continue; const event = entry.event; if (event && typeof event.seq === 'number' && typeof event.type === 'string') out.push(event); } out.sort((a, b) => a.seq - b.seq); return out; } function isNextTurnSplice(event) { return event.type === 'agent/inbox/spliced' && event.data && event.data.target === 'next-turn'; } function insertedIds(event) { const inserted = event.data && Array.isArray(event.data.inserted) ? event.data.inserted : []; return inserted.map((item) => item && item.id).filter((id) => id !== undefined); } function isAppendedUserMessage(event) { return event.type === 'user/message' && event.surfaceOp === 'append'; } /** * Choose the fork boundary that reproduces the session state right before * the prompt that opened `turn` was submitted. * * @param events - durable events of the loaded window, ascending. * @param turn - turn number to rewind. * @param hasMore - whether older history exists before the window. * @returns a plan, or a failure with `needOlder` when more history is needed. */ function planRewind(events, turn, hasMore) { const startIndex = events.findIndex((e) => e.type === 'turn/start' && e.data && e.data.turn === turn); if (startIndex < 0) return { ok: false, needOlder: hasMore, code: 'turn-not-loaded', message: `turn ${turn} is not loaded` }; const start = events[startIndex]; // The prompt: the first user-authored message appended in the turn's first step. let prompt; for (let i = startIndex + 1; i < events.length; i++) { const e = events[i]; if (e.type === 'step/end' || e.type === 'turn/end' || e.type === 'turn/start') break; if (isAppendedUserMessage(e) && e.data && e.data.source && e.data.source.kind === 'user') { prompt = e; break; } } if (prompt === undefined) return { ok: false, needOlder: false, code: 'no-user-prompt', message: `turn ${turn} was not started by a user prompt` }; const promptId = prompt.data.id; // Earliest inbox insertion of that prompt (a later splice may be a queue edit). let insertion; for (let i = startIndex - 1; i >= 0; i--) { const e = events[i]; if (isNextTurnSplice(e) && insertedIds(e).includes(promptId)) insertion = e; } if (insertion === undefined && hasMore) { return { ok: false, needOlder: true, code: 'insertion-not-loaded', message: 'prompt submission is not loaded' }; } const insertionSeq = insertion === undefined ? start.seq : insertion.seq; // The previous turn's end, if loaded. let previousEndIndex = -1; for (let i = startIndex - 1; i >= 0; i--) { if (events[i].type === 'turn/end') { previousEndIndex = i; break; } } let boundary; if (previousEndIndex >= 0 && insertionSeq <= events[previousEndIndex].seq) { // Queued while the previous turn was still running: cut at the previous // turn's end plus its standalone tail (the Host's default fork rule), // and drop the queued prompt(s) from the fork's inbox afterwards. boundary = events[previousEndIndex].seq; for (let i = previousEndIndex + 1; i < events.length; i++) { const e = events[i]; if (e.type === 'turn/start' || isAppendedUserMessage(e) || e.type === 'agent/inbox/spliced') break; boundary = e.seq; } } else { boundary = insertionSeq - 1; } if (!(boundary >= 0)) return { ok: false, needOlder: false, code: 'no-boundary', message: 'there is nothing before this prompt to fork from' }; // Next-turn items still pending at the boundary: inserted at or before it // and never delivered as a user message at or before it. Removing them in // the fork keeps an old queued prompt from running ahead of the new one. const delivered = new Set(); for (const e of events) { if (e.seq > boundary) break; if (isAppendedUserMessage(e) && e.data && e.data.id !== undefined) delivered.add(e.data.id); } const pending = []; for (const e of events) { if (e.seq > boundary) break; if (!isNextTurnSplice(e)) continue; for (const id of insertedIds(e)) if (!delivered.has(id) && !pending.includes(id)) pending.push(id); } const content = Array.isArray(prompt.data.content) ? prompt.data.content : []; const text = content.filter((b) => b && b.type === 'text' && typeof b.text === 'string').map((b) => b.text).join(''); const attachments = content.filter((b) => b && b.type !== 'text').length; return { ok: true, boundary, promptSeq: prompt.seq, turnStartSeq: start.seq, text, attachments, removeIds: pending }; } // --------------------------------------------------------------------- // Chat snapshot index (cached per snapshot object) // --------------------------------------------------------------------- const indexCache = new WeakMap(); const EMPTY_INDEX = { byMessage: new Map(), turns: new Map(), latest: undefined }; function chatIndex(snapshot) { if (!snapshot || typeof snapshot !== 'object') return EMPTY_INDEX; const cached = indexCache.get(snapshot); if (cached) return cached; const index = { byMessage: new Map(), turns: new Map(), latest: undefined }; try { const order = snapshot.timeline && Array.isArray(snapshot.timeline.turnOrder) ? snapshot.timeline.turnOrder : []; index.latest = order.length > 0 ? order[order.length - 1] : undefined; for (const turn of order) { const keys = snapshot.locations && typeof snapshot.locations.getTurn === 'function' ? snapshot.locations.getTurn(turn) || [] : []; const flags = { user: false, error: false, ended: false, closing: false }; for (const key of keys) { const node = snapshot.nodes && typeof snapshot.nodes.get === 'function' ? snapshot.nodes.get(key) : undefined; if (!node) continue; if (node.kind === 'user') flags.user = true; else if (node.kind === 'turn-error') flags.error = true; else if (node.kind === 'turn-tail' && node.data) { flags.ended = true; const closing = node.data.closing; if (closing) { flags.closing = true; const messageId = closing.finalNode && closing.finalNode.messageId; if (messageId !== undefined) index.byMessage.set(messageId, turn); } } } index.turns.set(turn, flags); } } catch (error) { console.error('[rewind-retry] could not index chat snapshot', error); } indexCache.set(snapshot, index); return index; } /** Stable primitive describing which actions one turn offers. */ function turnKey(snapshot, turn) { if (typeof turn !== 'number') return ''; const index = chatIndex(snapshot); const flags = index.turns.get(turn); if (!flags) return ''; return [turn, flags.user ? 1 : 0, flags.error ? 1 : 0, flags.ended ? 1 : 0, flags.closing ? 1 : 0, index.latest === turn ? 1 : 0].join(':'); } function parseKey(key) { if (!key) return undefined; const [turn, user, error, ended, closing, latest] = key.split(':').map(Number); return { turn, user: !!user, error: !!error, ended: !!ended, closing: !!closing, latest: !!latest }; } // --------------------------------------------------------------------- // Small pending-draft store keyed by Session id // --------------------------------------------------------------------- function createPendingStore() { const map = new Map(); const listeners = new Set(); const notify = () => { for (const l of [...listeners]) l(); }; return { get: (id) => map.get(id), set: (id, value) => { map.set(id, value); notify(); }, delete: (id) => { if (map.delete(id)) notify(); }, subscribe: (l) => { listeners.add(l); return () => { listeners.delete(l); }; }, clear: () => { map.clear(); notify(); }, }; } function requestId() { try { if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID(); } catch (_) { /* fall through */ } return 'rwr-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10); } function clientTimeZone() { try { return Intl.DateTimeFormat().resolvedOptions().timeZone || undefined; } catch (_) { return undefined; } } function format(template, values) { return String(template).replace(/\{(\w+)\}/g, (_, k) => (values && values[k] !== undefined ? String(values[k]) : '')); } function errorText(error) { if (!error) return 'unknown error'; if (typeof error === 'string') return error; return error.message || error.code || String(error); } return { inject: ['slots', 'sessions', 'uiWorkspace', 'locale', 'remote', 'remote.session'], // Exposed for offline tests only. __test: { planRewind, durableEvents }, apply(ctx) { const pendingDrafts = createPendingStore(); ctx.effect(() => () => pendingDrafts.clear(), 'rewind-retry: pending drafts'); // Dictionaries. ctx.effect(() => { const disposers = Object.entries(DICTS).map(([locale, dict]) => ctx.locale.register(NS, locale, dict)); return () => { for (const d of disposers) { try { d(); } catch (_) { /* already gone */ } } }; }, 'rewind-retry: dictionaries'); const fallbackT = (key, values) => { let locale = 'en'; try { const snap = ctx.locale.getSnapshot(); locale = String((snap && (snap.id || snap.locale)) || 'en'); } catch (_) { /* default */ } const dict = locale.startsWith('zh') ? DICTS.zh : DICTS.en; return format(dict[key] ?? DICTS.en[key] ?? key, values); }; const translator = (t) => (key, values) => { if (typeof t === 'function') { try { const out = t(key, values); if (typeof out === 'string' && out !== key && out !== `${NS}.${key}`) return values ? format(out, values) : out; } catch (_) { /* fall back */ } } return fallbackT(key, values); }; // Styles, removed with the plugin. ctx.effect(() => { if (typeof document === 'undefined') return () => {}; const tag = document.createElement('style'); tag.id = STYLE_ID; tag.dataset.plugin = '@local/dsh-rewind-retry'; tag.textContent = CSS; document.head.appendChild(tag); return () => { tag.remove(); }; }, 'rewind-retry: styles'); // ------------------------------------------------------------- actions async function loadPlan(sessionId, turn) { const binding = ctx.sessions.binding(sessionId); if (!binding || !binding.eventSource) throw new Error('session history is not open'); let plan; for (let attempt = 0; attempt < 6; attempt++) { const snapshot = binding.eventSource.getSnapshot(); const events = durableEvents(snapshot); plan = planRewind(events, turn, !!snapshot.hasMore); if (plan.ok || !plan.needOlder || !snapshot.hasMore) break; const first = events.length > 0 ? events[0].seq : 0; if (!binding.session || typeof binding.session.loadThrough !== 'function') break; await binding.session.loadThrough(Math.max(0, first - 1)); } if (!plan.ok) throw new Error(plan.message); return plan; } async function sendPrompt(sessionId, text) { const zone = clientTimeZone(); const result = await ctx.remote.session.prompt({ requestId: requestId(), sessionId, mode: 'queue', content: [{ type: 'text', text }], ...(zone === undefined ? {} : { clientTimeZone: zone }), }); if (result && result.ok === false) throw new Error(errorText(result.error)); return result; } /** Fork before `turn`'s prompt, then resend it (retry) or stage it (edit). */ async function rewind(sessionId, turn, mode) { const plan = await loadPlan(sessionId, turn); const childId = await ctx.sessions.fork({ sessionId, atSeq: plan.boundary, increaseTitle: true }); if (!childId) throw new Error('fork returned no session'); for (const itemId of plan.removeIds) { try { await ctx.remote.session.updateQueue({ sessionId: childId, itemId, action: { kind: 'remove' } }); } catch (error) { console.warn('[rewind-retry] could not drop inherited queue item', itemId, error); } } const canResend = mode === 'retry' && plan.attachments === 0 && plan.text.trim() !== ''; if (canResend) { try { await sendPrompt(childId, plan.text); } catch (error) { console.error('[rewind-retry] resend failed; staging the prompt instead', error); pendingDrafts.set(childId, { text: plan.text, attachments: plan.attachments }); } } else { pendingDrafts.set(childId, { text: plan.text, attachments: plan.attachments }); } ctx.uiWorkspace.openSession(childId); return childId; } async function continueSession(sessionId, text) { await sendPrompt(sessionId, text); } // ---------------------------------------------------------- components function useTurnFlags(useChat, turn) { const key = typeof useChat === 'function' ? useChat((snapshot) => turnKey(snapshot, turn)) : ''; return React.useMemo(() => parseKey(key), [key]); } function useRunner(sessionId, t) { const [busy, setBusy] = React.useState(null); const [message, setMessage] = React.useState(null); const alive = React.useRef(true); const timer = React.useRef(null); React.useEffect(() => () => { alive.current = false; if (timer.current) clearTimeout(timer.current); }, []); const run = React.useCallback((kind, operation) => { if (busy) return; setBusy(kind); setMessage(null); Promise.resolve().then(operation).catch((error) => { console.error('[rewind-retry]', kind, 'failed', error); if (!alive.current) return; setMessage({ kind: 'error', text: t('failed', { message: errorText(error) }) }); if (timer.current) clearTimeout(timer.current); timer.current = setTimeout(() => { if (alive.current) setMessage(null); }, 8000); }).finally(() => { if (alive.current) setBusy(null); }); }, [busy, t]); return { busy, message, run }; } function ActionButton({ kind, label, hint, onClick, disabled, pill }) { return h('button', { type: 'button', className: pill ? 'rwr-pill' : 'rwr-icon', title: hint, 'aria-label': hint, 'aria-disabled': disabled || undefined, 'data-rwr-action': kind, onClick: disabled ? undefined : onClick, }, ICONS[kind](), pill ? h('span', null, label) : null); } function TurnActions({ sessionId, flags, t, pill }) { const { busy, message, run } = useRunner(sessionId, t); if (!flags || !flags.ended) return null; const canRewind = flags.user; const canContinue = flags.latest && (flags.error || !flags.closing); if (!canRewind && !canContinue) return null; const buttons = []; if (canRewind) { buttons.push(h(ActionButton, { key: 'retry', kind: 'retry', pill, label: t('retry'), hint: t('retryHint'), disabled: busy !== null, onClick: () => run('retry', () => rewind(sessionId, flags.turn, 'retry')), })); buttons.push(h(ActionButton, { key: 'edit', kind: 'edit', pill, label: t('edit'), hint: t('editHint'), disabled: busy !== null, onClick: () => run('edit', () => rewind(sessionId, flags.turn, 'edit')), })); } if (canContinue) { buttons.push(h(ActionButton, { key: 'continue', kind: 'continue', pill, label: t('continue'), hint: t('continueHint'), disabled: busy !== null, onClick: () => run('continue', () => continueSession(sessionId, t('continuePrompt'))), })); } const status = busy !== null ? h('span', { key: 'status', className: 'rwr-msg', role: 'status' }, t('working')) : message !== null ? h('span', { key: 'status', className: 'rwr-msg', 'data-kind': message.kind, role: 'alert' }, message.text) : null; return h('span', { className: pill ? 'rwr-row' : 'rwr-group', 'data-rewind-retry': flags.turn }, ...buttons, status); } /** Inline icons inside the host's finished-turn action strip. */ function AssistantActionsEntry(props) { const t = React.useMemo(() => translator(props.t), [props.t]); const messageId = props.messageId; const turn = typeof props.useChat === 'function' ? props.useChat((snapshot) => { const found = chatIndex(snapshot).byMessage.get(messageId); return found === undefined ? -1 : found; }) : -1; const flags = useTurnFlags(props.useChat, turn); if (turn < 0 || !props.sessionId) return null; return h(TurnActions, { sessionId: props.sessionId, flags, t, pill: false }); } /** Labeled row for finished turns that have no host action strip (failed or reply-less turns). */ function TurnTailEntry(props) { const t = React.useMemo(() => translator(props.t), [props.t]); const turnObject = props.turn; const turn = turnObject && typeof turnObject === 'object' ? turnObject.turn : turnObject; const flags = useTurnFlags(props.useChat, turn); if (!flags || flags.closing || !props.sessionId) return null; return h(TurnActions, { sessionId: props.sessionId, flags, t, pill: true }); } /** Applies a staged prompt to a freshly forked Session's composer. */ function PendingDraftEntry(props) { const t = React.useMemo(() => translator(props.t), [props.t]); const { sessionId, inputActions, useInput } = props; const pending = React.useSyncExternalStore(pendingDrafts.subscribe, () => pendingDrafts.get(sessionId)); const draft = typeof useInput === 'function' ? useInput((state) => (state && typeof state.draft === 'string' ? state.draft : '')) : undefined; const draftRef = React.useRef(draft); draftRef.current = draft; const [job, setJob] = React.useState(null); const [notice, setNotice] = React.useState(null); React.useEffect(() => { if (pending === undefined) return; pendingDrafts.delete(sessionId); setJob({ ...pending, nonce: Math.random() }); if (pending.attachments > 0) setNotice(pending.attachments); }, [pending, sessionId]); React.useEffect(() => { if (!job || !inputActions || typeof inputActions.setDraft !== 'function') return undefined; let tries = 0; let timer; const tick = () => { const current = draftRef.current; if (typeof current === 'string' && current.trim() === job.text.trim()) { try { if (typeof inputActions.focus === 'function') inputActions.focus(); } catch (_) { /* optional */ } return; } if (tries++ >= 8) return; if (tries === 1 && typeof current === 'string' && current.trim() !== '') return; // never clobber a user draft try { inputActions.setDraft(job.text); } catch (error) { console.error('[rewind-retry] setDraft failed', error); } timer = setTimeout(tick, 120); }; timer = setTimeout(tick, 0); return () => clearTimeout(timer); }, [job, inputActions]); if (notice === null) return null; return h('div', { className: 'rwr-notice', role: 'status' }, h('span', null, t('attachments', { count: notice })), h('button', { type: 'button', onClick: () => setNotice(null) }, t('dismiss'))); } // ------------------------------------------------------ registrations ctx.slots.inject('conversation.chat.assistant-actions', () => ctx.slots.register({ name: 'conversation.chat.assistant-actions', id: '@local/dsh-rewind-retry', order: 50, locale: NS, }, AssistantActionsEntry)); ctx.slots.inject('conversation.chat.turnTail', () => ctx.slots.register({ name: 'conversation.chat.turnTail', id: '@local/dsh-rewind-retry', order: 50, locale: NS, }, TurnTailEntry)); ctx.slots.inject('conversation.input.dock', () => ctx.slots.register({ name: 'conversation.input.dock', id: '@local/dsh-rewind-retry', order: 90, locale: NS, }, PendingDraftEntry)); }, }; }, });