dsh-prompt-rewriter 1.0.3 → 1.1.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
@@ -36,6 +36,14 @@ The third column is not something you wrote. It read your sentence and filled it
36
36
 
37
37
  Changed your mind mid-flight? Click again to cancel. The draft stays exactly as it was — no half-finished text left behind.
38
38
 
39
+ ### You can navigate away mid-rewrite
40
+
41
+ Once you've clicked, you don't have to sit and wait. **Switch to another session and the task keeps running.**
42
+
43
+ Come back and the result is already in the composer; if it hasn't finished, the button is still the spinning tail. Two sessions can each run their own rewrite without interfering.
44
+
45
+ That wasn't true in 1.0.x: switching sessions aborted the task, and coming back showed nothing.
46
+
39
47
  ### The four button states
40
48
 
41
49
  ![Button states: idle, rewriting, done and undoable, failed and retryable](https://raw.githubusercontent.com/huaguoxingkong/dsh-prompt-rewriter/main/docs/images/05-button-states-en.gif)
package/README.zh.md CHANGED
@@ -37,6 +37,14 @@
37
37
 
38
38
  中途想放弃就再点一下,草稿保持原样,不会给你留下半截文字。
39
39
 
40
+ ### 增强过程中可以切走
41
+
42
+ 点了按钮之后不必守着它。**切到别的会话,任务会继续跑完。**
43
+
44
+ 回来的时候,结果已经写进输入框了;如果还没跑完,按钮仍然是那个旋转的鱼尾。两个会话可以同时各跑一个,互不影响。
45
+
46
+ 这一点在 1.0.x 里不是这样:那时切换会话会直接中断任务,回来什么也没有。
47
+
40
48
  ### 按钮的四种状态
41
49
 
42
50
  ![按钮状态:空闲、改写中、完成可回退、失败可重试](https://raw.githubusercontent.com/huaguoxingkong/dsh-prompt-rewriter/main/docs/images/05-button-states.gif)
package/client.js CHANGED
@@ -248,6 +248,103 @@ window.__ModuleLoader__.load({
248
248
  );
249
249
  }
250
250
 
251
+ /**
252
+ * One enhancement task per session, owned OUTSIDE the component tree.
253
+ *
254
+ * The composer control is mounted per session and unmounted the moment the
255
+ * user switches away. Keeping the in-flight request (and its abort
256
+ * controller) inside the component therefore tied the task's lifetime to
257
+ * whichever session happened to be on screen: unmounting aborted the fetch
258
+ * and the cleanup flagged the component dead, so the reply was discarded
259
+ * even if it arrived. Switching sessions looked like "the enhancement was
260
+ * cancelled", when in fact the UI had simply outlived it.
261
+ *
262
+ * This registry is the decoupling point. A task belongs to a sessionId, not
263
+ * to a component instance, so it survives unmount, keeps running, and is
264
+ * delivered to whichever composer mounts that session later. Two sessions
265
+ * get independent entries and cannot interfere.
266
+ *
267
+ * @returns a small imperative store with subscribe/getSnapshot per session.
268
+ */
269
+ function createTaskStore() {
270
+ /** sessionId -> task record. Entries are removed once settled and consumed. */
271
+ const tasks = new Map();
272
+ /** sessionId -> listener set, so a remount can re-render on progress. */
273
+ const listeners = new Map();
274
+ /** Bumped whenever any task changes, so snapshots are stable to compare. */
275
+ let version = 0;
276
+ /** Cache of published snapshots, keyed by sessionId, invalidated by version. */
277
+ const snapshots = new Map();
278
+
279
+ const notify = (sessionId) => {
280
+ version += 1;
281
+ snapshots.clear();
282
+ const set = listeners.get(sessionId);
283
+ if (set === undefined) return;
284
+ for (const listener of set) listener();
285
+ };
286
+
287
+ /**
288
+ * The public view of a session's task. `null` means "nothing known", which
289
+ * is what the control renders when the session has no work.
290
+ */
291
+ const snapshot = (sessionId) => {
292
+ const cached = snapshots.get(sessionId);
293
+ if (cached !== undefined) return cached;
294
+ const task = tasks.get(sessionId);
295
+ const next = task === undefined
296
+ ? null
297
+ : Object.freeze({ phase: task.phase, failure: task.failure, announce: task.announce, contextAware: task.contextAware, restore: task.restore, result: task.result });
298
+ snapshots.set(sessionId, next);
299
+ return next;
300
+ };
301
+
302
+ const subscribe = (sessionId, listener) => {
303
+ let set = listeners.get(sessionId);
304
+ if (set === undefined) {
305
+ set = new Set();
306
+ listeners.set(sessionId, set);
307
+ }
308
+ set.add(listener);
309
+ return () => {
310
+ set.delete(listener);
311
+ if (set.size === 0) listeners.delete(sessionId);
312
+ };
313
+ };
314
+
315
+ /** Read-or-create the record for a session. */
316
+ const ensure = (sessionId) => {
317
+ let task = tasks.get(sessionId);
318
+ if (task === undefined) {
319
+ task = { phase: 'idle', failure: null, announce: null, contextAware: false, restore: null, controller: null };
320
+ tasks.set(sessionId, task);
321
+ }
322
+ return task;
323
+ };
324
+
325
+ /** Apply a patch to a session's record and publish it. */
326
+ const patch = (sessionId, changes) => {
327
+ const task = ensure(sessionId);
328
+ Object.assign(task, changes);
329
+ notify(sessionId);
330
+ };
331
+
332
+ /** Drop a session's record entirely (task finished and was consumed). */
333
+ const clear = (sessionId) => {
334
+ if (tasks.delete(sessionId)) notify(sessionId);
335
+ };
336
+
337
+ return { snapshot, subscribe, ensure, patch, clear, get tasks() { return tasks; } };
338
+ }
339
+
340
+ /**
341
+ * Sessions whose enhancement finished while their composer was unmounted and
342
+ * nobody has looked at them yet. On remount the control picks these up and
343
+ * adopts the result, so the user sees the completed task on return instead of
344
+ * a silent loss.
345
+ */
346
+ const taskStore = createTaskStore();
347
+
251
348
  /**
252
349
  * Composer-side enhancement control: sends the current draft to this
253
350
  * package's Host half, then replaces the draft in place — never submits.
@@ -263,34 +360,33 @@ window.__ModuleLoader__.load({
263
360
  // The composer's own attachment list. Only these images may be referenced;
264
361
  // images from earlier turns live in the session log and are never read here.
265
362
  const attachmentIds = useInput((state) => state.attachmentIds);
266
- const [busy, setBusy] = useState(false);
267
- // Failure text only; success is represented by the undo offer itself.
268
- const [failure, setFailure] = useState(null);
269
- // Status text for the live region only. A user-initiated pause lands here
270
- // instead of `failure`, so it is announced without changing the icon.
271
- const [announce, setAnnounce] = useState(null);
272
- // True when the last successful run consulted the conversation history.
273
- const [contextAware, setContextAware] = useState(false);
274
- // `restore` is the undo offer: `text` is the pre-enhancement prompt and
275
- // `produced` is the exact draft the swap left behind. The offer dies as
276
- // soon as the live draft stops matching `produced`.
277
- const [restore, setRestore] = useState(null);
278
- const flight = useRef(null);
279
- const alive = useRef(false);
280
- const ticket = useRef(0);
363
+ // Task state lives in the module-level registry, keyed by session, so it
364
+ // outlives this component instance. Switching sessions only changes which
365
+ // entry is read; it never cancels work.
366
+ //
367
+ // A version counter forces a re-render when the task changes while this
368
+ // component is mounted (progress, failure, completion). The counter is
369
+ // local state only — it is not the task, so losing it on unmount costs
370
+ // nothing and cannot cancel work.
371
+ const [, bumpTaskVersion] = useState(0);
372
+ useEffect(() => taskStore.subscribe(sessionId, () => bumpTaskVersion((v) => v + 1)), [sessionId]);
373
+ // The snapshot is read straight from the registry on every render. The version
374
+ // counter above is what triggers those renders; the value itself never lives
375
+ // in component state, so unmounting cannot lose or cancel a task.
376
+ const task = taskStore.snapshot(sessionId);
377
+ const busy = task?.phase === 'running';
378
+ const failure = task?.failure ?? null;
379
+ const announce = task?.announce ?? null;
380
+ const contextAware = task?.contextAware === true;
381
+ const restore = task?.restore ?? null;
281
382
  const latestRev = useRef(draftRev);
282
383
  latestRev.current = draftRev;
283
384
 
284
- // Owns the lifetime of in-flight work: the composer unmounts far more
285
- // often than the page reloads, so the abort must follow the component.
286
- useEffect(() => {
287
- alive.current = true;
288
- return () => {
289
- alive.current = false;
290
- flight.current?.abort();
291
- flight.current = null;
292
- };
293
- }, []);
385
+ // NO abort on unmount. The component unmounts whenever the user switches
386
+ // away from this session, and aborting there is exactly what made the
387
+ // enhancement stop; the request now belongs to the registry and keeps
388
+ // running until it settles on its own.
389
+ useEffect(() => () => {}, []);
294
390
 
295
391
  const replaceDraft = useCallback(
296
392
  (text, before, rev) => inputActions?.insertText(text, { start: 0, end: before.length, draftRev: rev }) === true,
@@ -301,16 +397,15 @@ window.__ModuleLoader__.load({
301
397
  const undo = useCallback(() => {
302
398
  if (restore === null) return;
303
399
  if (draft !== restore.produced) {
304
- setRestore(null);
400
+ taskStore.patch(sessionId, { restore: null });
305
401
  return;
306
402
  }
307
403
  // Select over the draft the editor actually holds, so any normalization
308
404
  // the composer applied to the model output cannot leave a residue.
309
405
  if (replaceDraft(restore.text, draft, draftRev)) {
310
- setRestore(null);
311
- setFailure(null);
406
+ taskStore.patch(sessionId, { restore: null, failure: null });
312
407
  }
313
- }, [draft, draftRev, replaceDraft, restore]);
408
+ }, [draft, draftRev, replaceDraft, restore, sessionId]);
314
409
 
315
410
  /** Encode bytes as canonical base64 for the JSON request body. */
316
411
  function bytesToBase64(bytes) {
@@ -365,25 +460,42 @@ window.__ModuleLoader__.load({
365
460
  return { images, skipped };
366
461
  }
367
462
 
368
- const run = useCallback(() => {
369
- if (flight.current !== null) {
370
- flight.current.abort();
463
+ /**
464
+ * Start or pause this session's enhancement.
465
+ *
466
+ * The request runs against the REGISTRY entry, not this closure, so neither
467
+ * unmounting (session switch) nor a re-render can cancel it. `phase` in the
468
+ * record is the single source of truth for busy/idle, which also makes the
469
+ * button state correct when the user returns mid-flight.
470
+ */
471
+ const run = useCallback(() => {
472
+ const task = taskStore.ensure(sessionId);
473
+ // Second click means "pause": abort the request this task owns. Because the
474
+ // controller is stored on the record, it is still reachable no matter which
475
+ // component instance asked.
476
+ if (task.phase === 'running' && task.controller !== null) {
477
+ task.controller.abort();
371
478
  return;
372
479
  }
373
480
  const before = draft;
374
481
  if (before.trim().length === 0) {
375
- setFailure(null);
376
- setRestore(null);
377
- setAnnounce(null);
482
+ taskStore.patch(sessionId, { failure: null, restore: null, announce: null });
378
483
  return;
379
484
  }
380
485
  const rev = draftRev;
381
486
  const controller = new AbortController();
382
- flight.current = controller;
383
- const mine = ++ticket.current;
384
- setBusy(true);
385
- setFailure(null);
386
- setAnnounce(null);
487
+ taskStore.patch(sessionId, {
488
+ phase: 'running',
489
+ failure: null,
490
+ announce: null,
491
+ controller,
492
+ // The draft this run rewrites; captured so the reply is validated
493
+ // against the text that was actually sent.
494
+ origin: before,
495
+ originRev: rev,
496
+ });
497
+ // Deliberately a floating async task owned by the store: nothing awaits it,
498
+ // and no component cleanup can reach its abort controller.
387
499
  (async () => {
388
500
  try {
389
501
  // Read the composer's images whenever the draft has any. The Host owns
@@ -407,57 +519,114 @@ window.__ModuleLoader__.load({
407
519
  });
408
520
  if (!response.ok) throw new Error(t('state.transport', { status: response.status }));
409
521
  const payload = await response.json();
410
- if (!alive.current || mine !== ticket.current) return;
411
522
  if (payload?.ok !== true) throw new Error(payload?.message || t('state.failed'));
412
523
  const text = typeof payload.text === 'string' ? payload.text : '';
413
- if (latestRev.current !== rev || text.trim().length === 0) {
414
- setRestore(null);
415
- return;
416
- }
417
- if (!replaceDraft(text, before, rev)) {
418
- setFailure(t('state.refused'));
524
+ if (text.trim().length === 0) {
525
+ taskStore.patch(sessionId, { restore: null });
419
526
  return;
420
527
  }
421
- // Record whether this run was context-aware so the seat can show it.
422
- setContextAware(payload?.mode === 'context');
423
- // Say so when a picture could not be read, rather than silently
424
- // rewriting without it.
425
528
  const skippedImages = Number(payload?.imagesSkipped) || 0;
426
- setAnnounce(skippedImages > 0 ? t('state.imagesSkipped', { count: skippedImages }) : null);
427
- // Success: the same seat now offers the way back. The produced text
428
- // is read from the next published draft, so the offer compares
429
- // against what the composer really stored.
430
- setRestore({ text: before, produced: null, pending: true });
529
+ // `applyResult` is the live delivery path, supplied by whichever composer
530
+ // is currently mounted for this session. When nobody is mounted it is
531
+ // undefined and the parked result waits for the remount.
532
+ const apply = taskStore.ensure(sessionId).applyResult;
533
+ const outcome = typeof apply === 'function' ? apply(text, before, rev) : 'parked';
534
+ taskStore.patch(sessionId, {
535
+ contextAware: payload?.mode === 'context',
536
+ announce: skippedImages > 0 ? t('state.imagesSkipped', { count: skippedImages }) : null,
537
+ });
538
+ if (outcome === 'applied') {
539
+ // The undo offer is only meaningful once the text actually landed in the
540
+ // editor; offering it after a refused or superseded write would claim a
541
+ // swap that never happened.
542
+ taskStore.patch(sessionId, { restore: { text: before, produced: null, pending: true } });
543
+ } else if (outcome === 'refused') {
544
+ taskStore.patch(sessionId, { restore: null, failure: t('state.refused') });
545
+ } else if (outcome === 'parked') {
546
+ // Nobody is mounted for this session: keep the result for the next mount.
547
+ taskStore.patch(sessionId, { result: { text, originRev: rev, origin: before }, restore: null });
548
+ } else {
549
+ // The draft moved on while the user was away; report it instead of
550
+ // overwriting text they have since edited.
551
+ taskStore.patch(sessionId, { restore: null, announce: t('state.stale') });
552
+ }
431
553
  } catch (error) {
432
- if (!alive.current || mine !== ticket.current) return;
433
554
  if (controller.signal.aborted) {
434
- // The user paused the run themselves. That is not a failure, so
435
- // it must not raise the retry mark: fall back to the resting
436
- // fish and keep the draft untouched. Screen readers still hear
437
- // the cancellation through the live region.
438
- setFailure(null);
439
- setRestore(null);
440
- setAnnounce(t('state.cancelled'));
555
+ // The user paused the run themselves. That is not a failure, so it
556
+ // must not raise the retry mark: fall back to the resting fish and
557
+ // keep the draft untouched. Screen readers still hear the
558
+ // cancellation through the live region.
559
+ taskStore.patch(sessionId, { failure: null, restore: null, announce: t('state.cancelled') });
441
560
  } else {
442
- setFailure(error instanceof Error ? error.message : t('state.failed'));
561
+ taskStore.patch(sessionId, { failure: error instanceof Error ? error.message : t('state.failed') });
443
562
  }
444
563
  } finally {
445
- if (flight.current === controller) flight.current = null;
446
- if (alive.current && mine === ticket.current) setBusy(false);
564
+ const current = taskStore.ensure(sessionId);
565
+ if (current.controller === controller) taskStore.patch(sessionId, { phase: 'idle', controller: null });
447
566
  }
448
567
  })();
449
- }, [draft, draftRev, replaceDraft, sessionId, t]);
568
+ }, [attachmentIds, draft, draftRev, sessionId, sessions, t]);
569
+
570
+ /**
571
+ * Publish this composer as the live delivery target for its session.
572
+ *
573
+ * The task runs outside the component tree, so when it finishes it needs a
574
+ * way to reach whatever editor is on screen RIGHT NOW. Registering the
575
+ * applier here is that bridge: mounted → applied immediately; unmounted →
576
+ * the parked result waits for the next mount, which re-registers and takes it.
577
+ * This replaces the old "effect only runs on mount" delivery, which could not
578
+ * see a result that landed after the first render.
579
+ */
580
+ useEffect(() => {
581
+ const apply = (text, before, rev) => {
582
+ // Distinguish the two reasons a result may not land, because the user needs
583
+ // to know which happened: the editor refused the write, or the draft moved
584
+ // on while the request was in flight.
585
+ if (latestRev.current !== rev) return 'stale';
586
+ return replaceDraft(text, before, rev) === true ? 'applied' : 'refused';
587
+ };
588
+ taskStore.patch(sessionId, { applyResult: apply });
589
+ return () => {
590
+ // Only clear if we are still the registered applier; a remount may have
591
+ // taken over already.
592
+ const current = taskStore.ensure(sessionId);
593
+ if (current.applyResult === apply) taskStore.patch(sessionId, { applyResult: undefined });
594
+ };
595
+ }, [replaceDraft, sessionId]);
596
+
597
+ // Deliver a parked result: the run finished while this session had no mounted
598
+ // composer (the user was looking at another conversation). Applying it here
599
+ // is what makes "switch away and come back" show a completed rewrite instead
600
+ // of a lost one, and it runs once per result because the record is cleared
601
+ // after consumption.
602
+ useEffect(() => {
603
+ const pendingResult = taskStore.ensure(sessionId).result;
604
+ if (pendingResult === undefined) return;
605
+ // Only the composer that owns this session's live editor may write to it.
606
+ if (inputActions === undefined || typeof inputActions.insertText !== 'function') return;
607
+ const { text, originRev } = pendingResult;
608
+ const swapped = latestRev.current === originRev
609
+ ? replaceDraft(text, pendingResult.origin, originRev)
610
+ : false;
611
+ // Consume regardless: a stale result must not be re-applied on every mount.
612
+ taskStore.patch(sessionId, { result: undefined });
613
+ if (!swapped) {
614
+ // The draft moved on while the user was away; keep the outcome visible as
615
+ // a notice instead of overwriting text they have since edited.
616
+ taskStore.patch(sessionId, { restore: null, announce: t('state.stale') });
617
+ }
618
+ }, [inputActions, replaceDraft, sessionId, t]);
450
619
 
451
620
  // Adopt the produced draft on the first render after a successful swap,
452
621
  // then retire the offer once the user edits that text.
453
622
  useEffect(() => {
454
623
  if (restore === null) return;
455
624
  if (restore.pending) {
456
- setRestore({ text: restore.text, produced: draft, pending: false });
625
+ taskStore.patch(sessionId, { restore: { text: restore.text, produced: draft, pending: false } });
457
626
  return;
458
627
  }
459
- if (restore.produced !== draft) setRestore(null);
460
- }, [draft, restore]);
628
+ if (restore.produced !== draft) taskStore.patch(sessionId, { restore: null });
629
+ }, [draft, restore, sessionId]);
461
630
 
462
631
  if (inputActions === undefined || typeof inputActions.insertText !== 'function') return null;
463
632
 
@@ -845,6 +1014,7 @@ window.__ModuleLoader__.load({
845
1014
  'state.failed': '增强失败,草稿保持不变。',
846
1015
  'state.failedWith': '增强失败:{reason}。点击可重试。',
847
1016
  'state.cancelled': '已停止增强,草稿保持不变。',
1017
+ 'state.stale': '增强已完成,但草稿在你离开期间被改动,结果未写入。',
848
1018
  'state.transport': '增强接口不可用(HTTP {status}),请确认插件已启用。',
849
1019
  'state.imagesSkipped': '有 {count} 张图片未能读取,本次增强未参考它们。',
850
1020
  // Settings page
@@ -875,6 +1045,7 @@ window.__ModuleLoader__.load({
875
1045
  'state.failed': 'Enhancement failed; the draft is unchanged.',
876
1046
  'state.failedWith': 'Enhancement failed: {reason}. Click to retry.',
877
1047
  'state.cancelled': 'Enhancement stopped; the draft is unchanged.',
1048
+ 'state.stale': 'The rewrite finished, but the draft changed while you were away, so it was not applied.',
878
1049
  'state.transport': 'The enhancement endpoint is unavailable (HTTP {status}); check that the plugin is enabled.',
879
1050
  'state.imagesSkipped': '{count} image(s) could not be read; this enhancement did not use them.',
880
1051
  // Settings page
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-prompt-rewriter",
3
- "version": "1.0.3",
3
+ "version": "1.1.0",
4
4
  "description": "One-click prompt enhancement beside the DeepSeek Harness composer: rewrites a draft in place without sending it.",
5
5
  "type": "module",
6
6
  "icon": "icon.svg",