@spzxg/cli-ui 0.6.2 → 0.6.5

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.
Files changed (131) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +137 -11
  3. package/dist/cli-ui-embed-BmLN2Jjg.js +43290 -0
  4. package/dist/{cli-ui-index-BY5i3wId.js → cli-ui-index-D4qfPHMd.js} +2 -2
  5. package/dist/{cli-ui-jszip.min-BZsUwI-s.js → cli-ui-jszip.min-DC9898pC.js} +1 -1
  6. package/dist/{cli-ui-jszip.min-m01fexHW.js → cli-ui-jszip.min-DpEl0G1z.js} +2 -2
  7. package/dist/{cli-ui-xlsx-CpWicJLT.js → cli-ui-xlsx-zs7oEtPj.js} +1 -1
  8. package/dist/cli-ui.css +32 -1
  9. package/dist/cli-ui.js +1 -1
  10. package/package.json +14 -8
  11. package/src/board/BrowserPane.tsx +160 -74
  12. package/src/board/ChangesPane.tsx +2 -2
  13. package/src/board/CliBoard.tsx +79 -50
  14. package/src/board/FileViewer.tsx +32 -22
  15. package/src/board/FilesPane.tsx +32 -15
  16. package/src/board/PanelControls.tsx +28 -0
  17. package/src/board/blocks.tsx +5 -5
  18. package/src/board/context.ts +65 -0
  19. package/src/board/fileOps.ts +26 -3
  20. package/src/board/fileWatch.ts +88 -0
  21. package/src/board/folderDownload.ts +30 -14
  22. package/src/board/panels.ts +33 -0
  23. package/src/board/textLoad.ts +2 -2
  24. package/src/board/workspace.ts +38 -9
  25. package/src/chat/CliChat.tsx +73 -30
  26. package/src/chat/Composer.tsx +41 -41
  27. package/src/chat/RunControls.tsx +99 -0
  28. package/src/chat/configuration.ts +15 -0
  29. package/src/chat/draft.ts +41 -0
  30. package/src/chat/export.ts +2 -2
  31. package/src/components.tsx +13 -0
  32. package/src/core/blocks.ts +2 -2
  33. package/src/core/callbacks.ts +8 -0
  34. package/src/core/client.ts +8 -6
  35. package/src/core/conversation.ts +3 -3
  36. package/src/core/id.ts +15 -0
  37. package/src/core/identity.ts +33 -0
  38. package/src/core/provider.tsx +24 -14
  39. package/src/core/store.ts +83 -26
  40. package/src/core/types.ts +34 -0
  41. package/src/elements.tsx +135 -29
  42. package/src/embed.ts +6 -8
  43. package/src/index.ts +24 -17
  44. package/src/layout/CliSplit.tsx +1 -1
  45. package/src/msglist/CliMsgList.tsx +10 -10
  46. package/src/operator/branchFlow.ts +35 -0
  47. package/src/operator/index.ts +9 -0
  48. package/src/operator/liveStream.ts +1 -0
  49. package/src/operator/node/AgentConnect.tsx +624 -0
  50. package/src/operator/node/AppearancePanel.tsx +35 -0
  51. package/src/operator/node/BranchHistory.tsx +67 -0
  52. package/src/operator/node/BranchNavigator.tsx +36 -0
  53. package/src/operator/node/BrowserPane.tsx +128 -0
  54. package/src/operator/node/BrowserPopup.tsx +22 -0
  55. package/src/operator/node/Chat.tsx +517 -0
  56. package/src/operator/node/CommandPalette.tsx +213 -0
  57. package/src/operator/node/Composer.tsx +287 -0
  58. package/src/operator/node/InsightsView.tsx +831 -0
  59. package/src/operator/node/Layout.tsx +190 -0
  60. package/src/operator/node/Markdown.tsx +61 -0
  61. package/src/operator/node/Modal.tsx +179 -0
  62. package/src/operator/node/NeedsYouMenu.tsx +54 -0
  63. package/src/operator/node/OnboardingWizard.tsx +250 -0
  64. package/src/operator/node/Orchestrator.tsx +634 -0
  65. package/src/operator/node/ProjectLanding.tsx +86 -0
  66. package/src/operator/node/ProjectsColumn.tsx +137 -0
  67. package/src/operator/node/ProviderAuth.tsx +561 -0
  68. package/src/operator/node/RunControls.tsx +2 -0
  69. package/src/operator/node/RunSwitch.tsx +113 -0
  70. package/src/operator/node/Services.tsx +245 -0
  71. package/src/operator/node/SessionRail.tsx +260 -0
  72. package/src/operator/node/SessionView.tsx +288 -0
  73. package/src/operator/node/SettingsView.tsx +690 -0
  74. package/src/operator/node/TaskBoard.tsx +352 -0
  75. package/src/operator/node/TaskChanges.tsx +530 -0
  76. package/src/operator/node/TaskHero.tsx +50 -0
  77. package/src/operator/node/TasksColumn.tsx +200 -0
  78. package/src/operator/node/Terminal.tsx +143 -0
  79. package/src/operator/node/Transcript.tsx +463 -0
  80. package/src/operator/node/ViewFile.tsx +79 -0
  81. package/src/operator/node/Welcome.tsx +101 -0
  82. package/src/operator/node/WorktreePrune.tsx +333 -0
  83. package/src/operator/node/agents.ts +58 -0
  84. package/src/operator/node/api.ts +40 -0
  85. package/src/operator/node/boardBackend.ts +80 -0
  86. package/src/operator/node/boardEvents.ts +22 -0
  87. package/src/operator/node/boardInteraction.ts +15 -0
  88. package/src/operator/node/boardState.ts +57 -0
  89. package/src/operator/node/boardWorkspace.ts +12 -0
  90. package/src/operator/node/branchFlow.ts +1 -0
  91. package/src/operator/node/composerDraft.ts +52 -0
  92. package/src/operator/node/fileLinks.ts +81 -0
  93. package/src/operator/node/format.ts +290 -0
  94. package/src/operator/node/github.tsx +234 -0
  95. package/src/operator/node/icons.tsx +88 -0
  96. package/src/operator/node/liveStream.ts +270 -0
  97. package/src/operator/node/modals.tsx +720 -0
  98. package/src/operator/node/navHistory.ts +70 -0
  99. package/src/operator/node/persist.ts +26 -0
  100. package/src/operator/node/protocol/agents/optIn.ts +9 -0
  101. package/src/operator/node/protocol/agents/types.ts +394 -0
  102. package/src/operator/node/protocol/approvalFailure.ts +77 -0
  103. package/src/operator/node/protocol/authFailure.ts +66 -0
  104. package/src/operator/node/protocol/customModels.ts +23 -0
  105. package/src/operator/node/protocol/events.ts +13 -0
  106. package/src/operator/node/protocol/features.ts +70 -0
  107. package/src/operator/node/protocol/internalUsage.ts +1 -0
  108. package/src/operator/node/protocol/promptLimits.ts +52 -0
  109. package/src/operator/node/protocol/storageRetention.ts +13 -0
  110. package/src/operator/node/protocol/types.ts +332 -0
  111. package/src/operator/node/protocol/usageLimit.ts +72 -0
  112. package/src/operator/node/shared.tsx +89 -0
  113. package/src/operator/node/transcriptModel.ts +129 -0
  114. package/src/operator/node/types.ts +296 -0
  115. package/src/operator/node/useGlobalEvents.ts +97 -0
  116. package/src/operator/node/useOrchestrator.ts +696 -0
  117. package/src/operator/node/usePrefs.ts +114 -0
  118. package/src/operator/node/useRecaps.ts +91 -0
  119. package/src/operator/node/useTaskStream.ts +279 -0
  120. package/src/operator.base.css +1798 -0
  121. package/src/operator.css +186 -16
  122. package/src/styles.css +20 -0
  123. package/src/ui/Markdown.tsx +7 -2
  124. package/src/ui/Popover.tsx +77 -0
  125. package/src/ui/TextBlock.tsx +3 -3
  126. package/src/ui/Transcript.tsx +10 -8
  127. package/src/ui/fileLinks.ts +6 -0
  128. package/src/ui/format.ts +1 -1
  129. package/src/ui/shared.tsx +1 -1
  130. package/src/ui/transcriptModel.ts +2 -2
  131. package/dist/cli-ui-embed-6IJ_jRVh.js +0 -41223
@@ -0,0 +1,88 @@
1
+ /* Orchestrator — icon set (ported from the design's icons.js) */
2
+ import type { SVGProps } from "react";
3
+
4
+ type P = SVGProps<SVGSVGElement>;
5
+
6
+ function S(children: React.ReactNode, props?: P) {
7
+ return (
8
+ <svg
9
+ viewBox="0 0 24 24"
10
+ fill="none"
11
+ stroke="currentColor"
12
+ strokeWidth={1.7}
13
+ strokeLinecap="round"
14
+ strokeLinejoin="round"
15
+ width="1em"
16
+ height="1em"
17
+ style={{ flex: "none" }}
18
+ {...props}
19
+ >
20
+ {children}
21
+ </svg>
22
+ );
23
+ }
24
+
25
+ export const Icon = {
26
+ plus: (p?: P) => S(<><line x1={12} y1={5} x2={12} y2={19} /><line x1={5} y1={12} x2={19} y2={12} /></>, p),
27
+ list: (p?: P) => S(<><line x1={9} y1={6} x2={20} y2={6} /><line x1={9} y1={12} x2={20} y2={12} /><line x1={9} y1={18} x2={20} y2={18} /><circle cx={5} cy={6} r={0.5} /><circle cx={5} cy={12} r={0.5} /><circle cx={5} cy={18} r={0.5} /></>, p),
28
+ board: (p?: P) => S(<><rect x={3} y={4} width={5} height={16} rx={1.5} /><rect x={10} y={4} width={5} height={11} rx={1.5} /><rect x={17} y={4} width={4} height={8} rx={1.5} /></>, p),
29
+ chart: (p?: P) => S(<><line x1={5} y1={20} x2={5} y2={13} /><line x1={12} y1={20} x2={12} y2={5} /><line x1={19} y1={20} x2={19} y2={9} /></>, p),
30
+ folder: (p?: P) => S(<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />, p),
31
+ gear: (p?: P) => S(<><circle cx={12} cy={12} r={3} /><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" /></>, p),
32
+ send: (p?: P) => S(<><path d="M7 11l5-5 5 5" /><line x1={12} y1={6} x2={12} y2={18} /></>, p),
33
+ spark: (p?: P) => S(<path d="M12 3l1.6 5.4L19 10l-5.4 1.6L12 17l-1.6-5.4L5 10l5.4-1.6z" />, p),
34
+ clock: (p?: P) => S(<><circle cx={12} cy={12} r={9} /><path d="M12 7v5l3 2" /></>, p),
35
+ clear: (p?: P) => S(<><path d="M3 12a9 9 0 1 0 3-6.7" /><path d="M3 4v4h4" /></>, p),
36
+ dots: (p?: P) => S(<><circle cx={5} cy={12} r={1} /><circle cx={12} cy={12} r={1} /><circle cx={19} cy={12} r={1} /></>, p),
37
+ chevDown: (p?: P) => S(<path d="M6 9l6 6 6-6" />, p),
38
+ chevUp: (p?: P) => S(<path d="M6 15l6-6 6 6" />, p),
39
+ chevRight: (p?: P) => S(<path d="M9 6l6 6-6 6" />, p),
40
+ toBottom: (p?: P) => S(<><path d="M7 7l5 5 5-5" /><line x1={5} y1={17} x2={19} y2={17} /></>, p),
41
+ x: (p?: P) => S(<><line x1={6} y1={6} x2={18} y2={18} /><line x1={6} y1={18} x2={18} y2={6} /></>, p),
42
+ clip: (p?: P) => S(<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" />, p),
43
+ check: (p?: P) => S(<path d="M5 12l5 5 9-11" />, p),
44
+ lock: (p?: P) => S(<><rect x={4} y={11} width={16} height={9} rx={2} /><path d="M8 11V7a4 4 0 0 1 8 0v4" /></>, p),
45
+ edit: (p?: P) => S(<><path d="M12 20h9" /><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z" /></>, p),
46
+ doc: (p?: P) => S(<><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><path d="M14 2v6h6" /></>, p),
47
+ github: (p?: P) => S(<path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22" />, p),
48
+ git: (p?: P) => S(<><circle cx={6} cy={6} r={2} /><circle cx={6} cy={18} r={2} /><circle cx={18} cy={9} r={2} /><path d="M18 11a8 8 0 0 1-8 8" /><line x1={6} y1={8} x2={6} y2={16} /></>, p),
49
+ flag: (p?: P) => S(<path d="M5 21V4h12l-2 4 2 4H5" />, p),
50
+ bolt: (p?: P) => S(<path d="M13 2L4 14h7l-1 8 9-12h-7z" />, p),
51
+ moon: (p?: P) => S(<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" />, p),
52
+ sun: (p?: P) => S(<><circle cx={12} cy={12} r={4} /><line x1={12} y1={2} x2={12} y2={5} /><line x1={12} y1={19} x2={12} y2={22} /><line x1={2} y1={12} x2={5} y2={12} /><line x1={19} y1={12} x2={22} y2={12} /><line x1={4.9} y1={4.9} x2={6.8} y2={6.8} /><line x1={17.2} y1={17.2} x2={19.1} y2={19.1} /><line x1={4.9} y1={19.1} x2={6.8} y2={17.2} /><line x1={17.2} y1={6.8} x2={19.1} y2={4.9} /></>, p),
53
+ external: (p?: P) => S(<><path d="M14 4h6v6" /><path d="M20 4l-9 9" /><path d="M19 13v5a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h5" /></>, p),
54
+ copy: (p?: P) => S(<><rect x={9} y={9} width={12} height={12} rx={2} /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" /></>, p),
55
+ play: (p?: P) => S(<path d="M7 5l11 7-11 7z" />, p),
56
+ stop: (p?: P) => S(<rect x={6} y={6} width={12} height={12} rx={2} />, p),
57
+ sliders: (p?: P) => S(<><line x1={4} y1={21} x2={4} y2={14} /><line x1={4} y1={10} x2={4} y2={3} /><line x1={12} y1={21} x2={12} y2={12} /><line x1={12} y1={8} x2={12} y2={3} /><line x1={20} y1={21} x2={20} y2={16} /><line x1={20} y1={12} x2={20} y2={3} /><line x1={1} y1={14} x2={7} y2={14} /><line x1={9} y1={8} x2={15} y2={8} /><line x1={17} y1={16} x2={23} y2={16} /></>, p),
58
+ search: (p?: P) => S(<><circle cx={11} cy={11} r={7} /><line x1={21} y1={21} x2={16.65} y2={16.65} /></>, p),
59
+ terminal: (p?: P) => S(<><rect x={3} y={4} width={18} height={16} rx={2} /><path d="M7 9l3 3-3 3" /><line x1={13} y1={15} x2={17} y2={15} /></>, p),
60
+ archive: (p?: P) => S(<><rect x={3} y={4} width={18} height={4} rx={1} /><path d="M5 8v11a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V8" /><line x1={10} y1={12} x2={14} y2={12} /></>, p),
61
+ restore: (p?: P) => S(<><path d="M3 12a9 9 0 1 0 3-6.7" /><path d="M3 4v4h4" /><path d="M12 8v4l3 2" /></>, p),
62
+ download: (p?: P) => S(<><path d="M12 4v11" /><path d="M7 11l5 5 5-5" /><line x1={5} y1={20} x2={19} y2={20} /></>, p),
63
+ };
64
+
65
+ // ---- agent brand marks ----
66
+ // Logos, not line icons: filled paths in `currentColor` (the stroked `S()` set
67
+ // above would smear these at avatar size). Keyed by agent driver id so any UI
68
+ // that knows a task's `agent` can stamp the right mark; unknown/legacy ids get
69
+ // no entry and callers fall back to a generic glyph.
70
+
71
+ function Brand(children: React.ReactNode, props?: P) {
72
+ return (
73
+ <svg viewBox="0 0 24 24" fill="currentColor" stroke="none" width="1em" height="1em" style={{ flex: "none" }} aria-hidden {...props}>
74
+ {children}
75
+ </svg>
76
+ );
77
+ }
78
+
79
+ export const AgentMark: Record<string, (p?: P) => React.ReactElement> = {
80
+ claude: (p?: P) => Brand(
81
+ <path d="M4.709 15.955l4.72-2.647.079-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 0 1-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.984-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.003 1.81 2.508 2.33.127.578-.322.455-.34-.049-2.204-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z" />,
82
+ p,
83
+ ),
84
+ codex: (p?: P) => Brand(
85
+ <path d="M22.282 9.821a5.985 5.985 0 0 0-.516-4.911 6.046 6.046 0 0 0-6.51-2.9A6.065 6.065 0 0 0 4.981 4.182a5.985 5.985 0 0 0-3.998 2.9 6.046 6.046 0 0 0 .743 7.097 5.98 5.98 0 0 0 .51 4.911 6.051 6.051 0 0 0 6.515 2.9A5.985 5.985 0 0 0 13.26 24a6.056 6.056 0 0 0 5.772-4.206 5.99 5.99 0 0 0 3.998-2.9 6.056 6.056 0 0 0-.748-7.073zm-9.022 12.608a4.476 4.476 0 0 1-2.876-1.04l.142-.081 4.778-2.758a.795.795 0 0 0 .393-.681v-6.737l2.02 1.168a.071.071 0 0 1 .038.052v5.583a4.504 4.504 0 0 1-4.495 4.494zM3.6 18.304a4.471 4.471 0 0 1-.535-3.014l.142.085 4.783 2.758a.771.771 0 0 0 .78 0l5.843-3.368v2.332a.08.08 0 0 1-.033.062L9.74 19.95a4.499 4.499 0 0 1-6.14-1.646zM2.34 7.896a4.485 4.485 0 0 1 2.366-1.973V11.6a.766.766 0 0 0 .388.677l5.814 3.354-2.02 1.169a.076.076 0 0 1-.071 0l-4.83-2.787A4.504 4.504 0 0 1 2.34 7.872zm16.597 3.856L13.104 8.364 15.119 7.2a.076.076 0 0 1 .071 0l4.83 2.791a4.494 4.494 0 0 1-.676 8.105v-5.678a.79.79 0 0 0-.407-.667zm2.01-3.024l-.141-.085-4.774-2.782a.776.776 0 0 0-.785 0L9.409 9.23V6.897a.066.066 0 0 1 .028-.061l4.83-2.787a4.499 4.499 0 0 1 6.681 4.66zM8.307 12.863l-2.02-1.164a.08.08 0 0 1-.038-.057V6.074a4.499 4.499 0 0 1 7.376-3.453l-.142.08-4.778 2.758a.795.795 0 0 0-.393.682zm1.097-2.365l2.602-1.5 2.607 1.5v2.999l-2.597 1.5-2.607-1.5z" />,
86
+ p,
87
+ ),
88
+ };
@@ -0,0 +1,270 @@
1
+ "use client";
2
+
3
+ import { notifyCallback } from "../../core/callbacks";
4
+
5
+ // Every live stream the UI watches (task transcript, lifecycle events, browser
6
+ // status, services) rides ONE WebSocket per tab — server.js /live, bridged to the
7
+ // same SSE routes as before (lib/liveBridge.js). Plain EventSources each pinned
8
+ // one of the browser's 6 HTTP/1.1 connections per node, so a few open tabs
9
+ // starved every ordinary request: sends, the "needs you" list and the browser
10
+ // panel spun forever. WebSockets use their own pool.
11
+ //
12
+ // openLive() keeps EventSource's contract for its callers: onOpen fires on every
13
+ // (re)connect — each one re-snapshots — and onMessage gets each event's data.
14
+ // If the socket can't be opened at all (a proxy without WebSocket support, the
15
+ // node restarting while the tab loaded), the tab falls back to one EventSource
16
+ // per stream — the old behaviour — and keeps retrying the socket to move back.
17
+
18
+ export interface LiveHandlers {
19
+ /** Upstream HTTP status, or 0 for a normal/network stream end. */
20
+ onEnd?: (code: number) => void;
21
+ /** Transport disruption; built-in reconnect/fallback remains active. */
22
+ onError?: (error: Error) => void;
23
+ onMessage: (data: string) => void;
24
+ onOpen?: () => void;
25
+ }
26
+ export interface LiveSub {
27
+ close(): void;
28
+ }
29
+
30
+ interface Sub {
31
+ id: number;
32
+ url: string;
33
+ h: LiveHandlers;
34
+ es?: EventSource;
35
+ retry?: ReturnType<typeof setTimeout>;
36
+ fails: number;
37
+ }
38
+
39
+ // The server sends a keepalive every 20 s; a socket silent for longer than this
40
+ // is dead (laptop sleep, dropped tunnel) even if the browser hasn't noticed.
41
+ const SILENCE_MS = 65_000;
42
+ const CONNECT_TIMEOUT_MS = 10_000;
43
+ const BACKOFF_MS = [500, 1000, 2000, 4000, 8000];
44
+ /** While on the EventSource fallback, how often to try the socket again. */
45
+ const PROBE_MS = 30_000;
46
+
47
+ const subs = new Map<number, Sub>();
48
+ let nextId = 1;
49
+ let mode: "ws" | "sse" | null = null;
50
+ let ws: WebSocket | null = null;
51
+ let wsOpen = false;
52
+ let everOpened = false;
53
+ let failsBeforeOpen = 0;
54
+ let attempt = 0;
55
+ let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
56
+ let watchdog: ReturnType<typeof setInterval> | undefined;
57
+ let lastFrame = 0;
58
+ let listening = false;
59
+ let probeTimer: ReturnType<typeof setTimeout> | undefined;
60
+ let cancelProbe: (() => void) | undefined;
61
+ let unlisten: (() => void) | undefined;
62
+
63
+ function currentMode(): "ws" | "sse" {
64
+ if (mode) return mode;
65
+ mode = typeof WebSocket === "undefined" ? "sse" : "ws";
66
+ return mode;
67
+ }
68
+
69
+ function listen() {
70
+ if (listening || typeof window === "undefined") return;
71
+ listening = true;
72
+ // Back from the background / back online: don't wait out the backoff.
73
+ const wake = () => {
74
+ if (mode !== "ws" || subs.size === 0) return;
75
+ if (!ws) connect();
76
+ else if (wsOpen && Date.now() - lastFrame > SILENCE_MS) drop(ws);
77
+ };
78
+ const visible = () => { if (document.visibilityState === "visible") wake(); };
79
+ document.addEventListener("visibilitychange", visible);
80
+ window.addEventListener("online", wake);
81
+ unlisten = () => { document.removeEventListener("visibilitychange", visible); window.removeEventListener("online", wake); listening = false; };
82
+ }
83
+
84
+ function sendSub(s: Sub) {
85
+ if (ws && wsOpen) ws.send(JSON.stringify({ t: "sub", s: s.id, u: s.url }));
86
+ }
87
+
88
+ function openEventSource(s: Sub) {
89
+ if (subs.get(s.id) !== s) return;
90
+ s.es?.close();
91
+ const es = new EventSource(s.url);
92
+ const active = () => subs.get(s.id) === s && s.es === es;
93
+ es.onopen = () => { if (!active()) return; if (s.retry) { clearTimeout(s.retry); s.retry = undefined; } s.fails = 0; s.h.onOpen?.(); };
94
+ es.onmessage = (event) => { if (active()) s.h.onMessage(event.data); };
95
+ es.onerror = () => {
96
+ if (!active()) return;
97
+ notifyCallback(s.h.onError, new Error("实时事件连接中断,正在等待恢复"));
98
+ // EventSource hides HTTP status. CONNECTING retries itself; CLOSED needs
99
+ // a bounded manual retry so renewed auth can recover without a reload.
100
+ if (!active() || es.readyState !== 2) return;
101
+ es.close();
102
+ if (s.retry) clearTimeout(s.retry);
103
+ s.retry = setTimeout(() => { s.retry = undefined; if (active()) openEventSource(s); }, 30_000);
104
+ };
105
+ s.es = es;
106
+ }
107
+
108
+ function liveUrl(): string {
109
+ return `${location.protocol === "https:" ? "wss:" : "ws:"}//${location.host}/live`;
110
+ }
111
+
112
+ function fallBackToEventSource() {
113
+ console.warn("[live] WebSocket unavailable — falling back to EventSource");
114
+ mode = "sse";
115
+ for (const s of subs.values()) openEventSource(s);
116
+ scheduleProbe();
117
+ }
118
+
119
+ // The fallback is a stopgap, not a verdict: its EventSources pin the very HTTP
120
+ // connections /live exists to keep free, and the usual cause (the node was
121
+ // restarting while this tab loaded) is gone a minute later. Keep trying the
122
+ // socket and move the streams back onto it as soon as it opens.
123
+ function scheduleProbe() {
124
+ if (probeTimer || subs.size === 0 || mode !== "sse") return;
125
+ probeTimer = setTimeout(probe, PROBE_MS);
126
+ }
127
+
128
+ function probe() {
129
+ probeTimer = undefined;
130
+ if (mode !== "sse") return;
131
+ if (subs.size === 0) {
132
+ // Nothing to move: let the next openLive try the socket first.
133
+ mode = "ws";
134
+ failsBeforeOpen = 0;
135
+ return;
136
+ }
137
+ let sock: WebSocket;
138
+ try { sock = new WebSocket(liveUrl()); } catch { scheduleProbe(); return; }
139
+ const settle = () => { clearTimeout(timer); sock.onopen = sock.onclose = sock.onerror = null; cancelProbe = undefined; };
140
+ cancelProbe = () => { settle(); try { sock.close(); } catch { /* gone */ } };
141
+ const timer = setTimeout(() => { settle(); try { sock.close(); } catch { /* gone */ } scheduleProbe(); }, CONNECT_TIMEOUT_MS);
142
+ sock.onopen = () => {
143
+ settle();
144
+ sock.close();
145
+ if (mode !== "sse") return;
146
+ mode = "ws";
147
+ failsBeforeOpen = 0;
148
+ for (const s of subs.values()) { s.es?.close(); s.es = undefined; }
149
+ // Each stream re-snapshots on the socket, so the hand-over loses nothing.
150
+ connect();
151
+ };
152
+ sock.onclose = () => { settle(); scheduleProbe(); };
153
+ sock.onerror = () => { /* onclose follows */ };
154
+ }
155
+
156
+ function scheduleReconnect() {
157
+ if (reconnectTimer || subs.size === 0) return;
158
+ const delay = BACKOFF_MS[Math.min(attempt, BACKOFF_MS.length - 1)];
159
+ attempt++;
160
+ reconnectTimer = setTimeout(() => { reconnectTimer = undefined; connect(); }, delay);
161
+ }
162
+
163
+ // Treat a socket as closed right away (its onclose can take minutes on a dead
164
+ // TCP connection), then reconnect.
165
+ function drop(sock: WebSocket) {
166
+ if (ws !== sock) return;
167
+ ws = null;
168
+ wsOpen = false;
169
+ if (watchdog) { clearInterval(watchdog); watchdog = undefined; }
170
+ sock.onopen = sock.onmessage = sock.onclose = sock.onerror = null;
171
+ try { sock.close(); } catch { /* already closed */ }
172
+ for (const s of subs.values()) { if (s.retry) { clearTimeout(s.retry); s.retry = undefined; } }
173
+ for (const s of [...subs.values()]) notifyCallback(s.h.onError, new Error("实时事件连接已断开,正在重连"));
174
+ if (subs.size === 0) return;
175
+ if (!everOpened && ++failsBeforeOpen >= 3) { fallBackToEventSource(); return; }
176
+ scheduleReconnect();
177
+ }
178
+
179
+ function resubscribeLater(s: Sub, code: number) {
180
+ // A renewed login must recover on the existing socket. Retry expired auth
181
+ // slowly; permanent missing/invalid resources wait for a new subscription.
182
+ const auth = code === 401 || code === 403;
183
+ if (!auth && code !== 0 && code !== 408 && code !== 429 && code < 500) return;
184
+ const delay = auth || code === 429 ? 30_000 : BACKOFF_MS[Math.min(s.fails, BACKOFF_MS.length - 1)] * 2;
185
+ s.fails++;
186
+ if (s.retry) clearTimeout(s.retry);
187
+ s.retry = setTimeout(() => { s.retry = undefined; if (subs.get(s.id) === s) sendSub(s); }, delay);
188
+ }
189
+
190
+ function onFrame(text: string) {
191
+ lastFrame = Date.now();
192
+ if (text.charCodeAt(0) === 123 /* { */) {
193
+ let msg: { t?: string; s?: number; code?: number };
194
+ try { msg = JSON.parse(text); } catch { return; }
195
+ const s = msg.s !== undefined ? subs.get(msg.s) : undefined;
196
+ if (msg.t === "open" && s) { s.fails = 0; s.h.onOpen?.(); }
197
+ else if (msg.t === "end" && s) {
198
+ notifyCallback(s.h.onEnd, msg.code ?? 0);
199
+ if (subs.get(s.id) === s) resubscribeLater(s, msg.code ?? 0);
200
+ }
201
+ return;
202
+ }
203
+ const nl = text.indexOf("\n");
204
+ if (nl <= 0) return;
205
+ const s = subs.get(Number(text.slice(0, nl)));
206
+ if (s) s.h.onMessage(text.slice(nl + 1));
207
+ }
208
+
209
+ function connect() {
210
+ if (ws || currentMode() !== "ws" || subs.size === 0) return;
211
+ if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = undefined; }
212
+ listen();
213
+ let sock: WebSocket;
214
+ try {
215
+ sock = new WebSocket(liveUrl());
216
+ } catch {
217
+ fallBackToEventSource();
218
+ return;
219
+ }
220
+ ws = sock;
221
+ // A proxy that takes the connection but never answers the upgrade would
222
+ // otherwise leave the tab without live updates indefinitely.
223
+ const handshake = setTimeout(() => { if (ws === sock && !wsOpen) drop(sock); }, CONNECT_TIMEOUT_MS);
224
+ sock.onopen = () => {
225
+ clearTimeout(handshake);
226
+ wsOpen = true;
227
+ everOpened = true;
228
+ attempt = 0;
229
+ lastFrame = Date.now();
230
+ for (const s of subs.values()) { s.fails = 0; sendSub(s); }
231
+ watchdog = setInterval(() => { if (Date.now() - lastFrame > SILENCE_MS) drop(sock); }, 10_000);
232
+ };
233
+ sock.onmessage = (e) => onFrame(String(e.data));
234
+ sock.onclose = () => drop(sock);
235
+ sock.onerror = () => { /* onclose follows */ };
236
+ }
237
+
238
+ /**
239
+ * Watch one live stream (an SSE route URL). Mirrors EventSource: onOpen on each
240
+ * (re)connect, onMessage per event. close() stops it.
241
+ */
242
+ export function openLive(url: string, h: LiveHandlers): LiveSub {
243
+ const s: Sub = { id: nextId++, url, h, fails: 0 };
244
+ subs.set(s.id, s);
245
+ if (currentMode() === "sse") openEventSource(s);
246
+ else if (wsOpen) sendSub(s);
247
+ else connect();
248
+ return {
249
+ close() {
250
+ if (subs.get(s.id) !== s) return;
251
+ subs.delete(s.id);
252
+ if (s.retry) clearTimeout(s.retry);
253
+ s.es?.close();
254
+ if (ws && wsOpen) ws.send(JSON.stringify({ t: "unsub", s: s.id }));
255
+ if (subs.size === 0) {
256
+ // Standalone embeds may mount/unmount repeatedly. Release the shared
257
+ // transport when its last consumer leaves, including fallback probes.
258
+ if (reconnectTimer) clearTimeout(reconnectTimer);
259
+ if (probeTimer) clearTimeout(probeTimer);
260
+ if (watchdog) clearInterval(watchdog);
261
+ reconnectTimer = probeTimer = watchdog = undefined;
262
+ cancelProbe?.();
263
+ const socket = ws; ws = null; wsOpen = false;
264
+ if (socket) { socket.onopen = socket.onmessage = socket.onclose = socket.onerror = null; try { socket.close(); } catch { /* already closed */ } }
265
+ unlisten?.(); unlisten = undefined;
266
+ mode = null; everOpened = false; failsBeforeOpen = attempt = 0;
267
+ }
268
+ },
269
+ };
270
+ }