@hienlh/ppm 0.8.59 → 0.8.61

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 (168) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/web/assets/{_basePickBy-CZovQgWd.js → _basePickBy-COwDPZl_.js} +1 -1
  3. package/dist/web/assets/{_baseUniq-ClnvscgW.js → _baseUniq-DCb0mkTp.js} +1 -1
  4. package/dist/web/assets/{api-settings--eVrUeZM.js → api-settings-CuUkz5gb.js} +1 -1
  5. package/dist/web/assets/{arc-C2Qaz-ch.js → arc-D0bJaFyD.js} +1 -1
  6. package/dist/web/assets/architecture-PBZL5I3N-281eTKQ3.js +1 -0
  7. package/dist/web/assets/{architectureDiagram-2XIMDMQ5-Jq91S_rs.js → architectureDiagram-2XIMDMQ5-BVEUkQYB.js} +1 -1
  8. package/dist/web/assets/arrow-left-C_j9Ki73.js +1 -0
  9. package/dist/web/assets/{blockDiagram-WCTKOSBZ-CKGufRTy.js → blockDiagram-WCTKOSBZ-CU2t4NHJ.js} +1 -1
  10. package/dist/web/assets/browser-tab-CsZFFI1C.js +1 -0
  11. package/dist/web/assets/{c4Diagram-IC4MRINW-BNP2L9r_.js → c4Diagram-IC4MRINW-DzjR91sM.js} +1 -1
  12. package/dist/web/assets/channel-CKNZAqoN.js +1 -0
  13. package/dist/web/assets/chat-tab-WYQKXiDW.js +7 -0
  14. package/dist/web/assets/{chunk-4BX2VUAB-BptTlTyl.js → chunk-4BX2VUAB-0YMkpW2S.js} +1 -1
  15. package/dist/web/assets/{chunk-55IACEB6-C4mUdyio.js → chunk-55IACEB6-Dp0pTM5r.js} +1 -1
  16. package/dist/web/assets/{chunk-7E7YKBS2-6xAQfBwa.js → chunk-7E7YKBS2-CuYKSUgJ.js} +1 -1
  17. package/dist/web/assets/{chunk-7R4GIKGN-DXaGAn_K.js → chunk-7R4GIKGN-DvbvLUIN.js} +2 -2
  18. package/dist/web/assets/{chunk-C72U2L5F-DOtEiN5f.js → chunk-C72U2L5F-CcEW1AMZ.js} +1 -1
  19. package/dist/web/assets/{chunk-EGIJ26TM-D0KJTa_T.js → chunk-EGIJ26TM-Cgt-qg75.js} +1 -1
  20. package/dist/web/assets/{chunk-FMBD7UC4-C_1aG0eb.js → chunk-FMBD7UC4-JCLgVcaC.js} +1 -1
  21. package/dist/web/assets/{chunk-GEFDOKGD-DwVPiYfW.js → chunk-GEFDOKGD-B82RP9ow.js} +1 -1
  22. package/dist/web/assets/chunk-GLR3WWYH-Bx2UL5jF.js +2 -0
  23. package/dist/web/assets/chunk-HHEYEP7N-BnRVfNc5.js +1 -0
  24. package/dist/web/assets/{chunk-JSJVCQXG-BSrqCL_3.js → chunk-JSJVCQXG-Pb-JMOgO.js} +1 -1
  25. package/dist/web/assets/{chunk-KX2RTZJC-BCxGmbzy.js → chunk-KX2RTZJC-BRj-ZEvL.js} +1 -1
  26. package/dist/web/assets/{chunk-KYZI473N-BKO5gMeU.js → chunk-KYZI473N-CBRPKraG.js} +1 -1
  27. package/dist/web/assets/{chunk-L3YUKLVL-3wBgkSvL.js → chunk-L3YUKLVL-DNFj84V6.js} +1 -1
  28. package/dist/web/assets/{chunk-MX3YWQON-BgjSEzus.js → chunk-MX3YWQON-BnPzQK-O.js} +1 -1
  29. package/dist/web/assets/{chunk-NQ4KR5QH-DLrZwBEm.js → chunk-NQ4KR5QH-BRj25yO7.js} +1 -1
  30. package/dist/web/assets/{chunk-O4XLMI2P-BurQy8tt.js → chunk-O4XLMI2P-BdXwVXjJ.js} +1 -1
  31. package/dist/web/assets/{chunk-OZEHJAEY-YTn24bGg.js → chunk-OZEHJAEY-LfXT4p8B.js} +1 -1
  32. package/dist/web/assets/{chunk-PQ6SQG4A-BxtUGYhW.js → chunk-PQ6SQG4A-EdgQyTqa.js} +1 -1
  33. package/dist/web/assets/{chunk-PU5JKC2W-B66ELkQm.js → chunk-PU5JKC2W-D3thuSok.js} +1 -1
  34. package/dist/web/assets/chunk-QZHKN3VN-gaBt0Rbd.js +1 -0
  35. package/dist/web/assets/{chunk-R5LLSJPH-euR2RxLN.js → chunk-R5LLSJPH-LdG7RqsM.js} +1 -1
  36. package/dist/web/assets/{chunk-WL4C6EOR-_2CBOJdI.js → chunk-WL4C6EOR-BHFnnXOt.js} +1 -1
  37. package/dist/web/assets/{chunk-XIRO2GV7-kqQ0g6wW.js → chunk-XIRO2GV7-DUmQrLsF.js} +1 -1
  38. package/dist/web/assets/{chunk-XPW4576I-CtcaMb09.js → chunk-XPW4576I-CsGTseUr.js} +1 -1
  39. package/dist/web/assets/{chunk-XZSTWKYB-BYxFzZwS.js → chunk-XZSTWKYB-5W2emiq4.js} +1 -1
  40. package/dist/web/assets/{chunk-YBOYWFTD-Dx_fX35n.js → chunk-YBOYWFTD-COdZIaX4.js} +1 -1
  41. package/dist/web/assets/classDiagram-VBA2DB6C-CqaIqYPn.js +1 -0
  42. package/dist/web/assets/classDiagram-v2-RAHNMMFH-Bo5WN2ok.js +1 -0
  43. package/dist/web/assets/clone-DNDy9Sms.js +1 -0
  44. package/dist/web/assets/{code-editor-DgTfBijB.js → code-editor-C6umJOvn.js} +1 -1
  45. package/dist/web/assets/{cose-bilkent-S5V4N54A-CHHjH2dV.js → cose-bilkent-S5V4N54A-C1QJ6GPW.js} +1 -1
  46. package/dist/web/assets/{dagre-CNtSxiE_.js → dagre-CWo8w9wK.js} +1 -1
  47. package/dist/web/assets/{dagre-KLK3FWXG-ChenfPp1.js → dagre-KLK3FWXG-Br4t5TRV.js} +1 -1
  48. package/dist/web/assets/database-viewer-TYwvlW4u.js +1 -0
  49. package/dist/web/assets/{diagram-E7M64L7V-CzKYZM0Y.js → diagram-E7M64L7V-CkDC2uAj.js} +1 -1
  50. package/dist/web/assets/{diagram-IFDJBPK2-ChB_paPo.js → diagram-IFDJBPK2-NvhckwcA.js} +1 -1
  51. package/dist/web/assets/{diagram-P4PSJMXO-D1eW1dkL.js → diagram-P4PSJMXO--nUaNiyB.js} +1 -1
  52. package/dist/web/assets/{diff-viewer-C5A-ZnrC.js → diff-viewer-DApETeeX.js} +1 -1
  53. package/dist/web/assets/{erDiagram-INFDFZHY-mCvUFSn6.js → erDiagram-INFDFZHY-DK4QEZYh.js} +1 -1
  54. package/dist/web/assets/{flowDiagram-PKNHOUZH-14ohZ1M1.js → flowDiagram-PKNHOUZH-B9h_Ba-v.js} +1 -1
  55. package/dist/web/assets/{ganttDiagram-A5KZAMGK-DIX0pLbk.js → ganttDiagram-A5KZAMGK-BVlftqyZ.js} +1 -1
  56. package/dist/web/assets/git-graph-mtdNxBZs.js +1 -0
  57. package/dist/web/assets/gitGraph-HDMCJU4V-D5qEPjgs.js +1 -0
  58. package/dist/web/assets/{gitGraphDiagram-K3NZZRJ6-yEWZbdf_.js → gitGraphDiagram-K3NZZRJ6-L7sj3Bs-.js} +1 -1
  59. package/dist/web/assets/{graphlib-DhOZxqsh.js → graphlib-BbbiUImY.js} +1 -1
  60. package/dist/web/assets/index-CYhfwlmi.js +37 -0
  61. package/dist/web/assets/index-n0Ww6i6b.css +2 -0
  62. package/dist/web/assets/info-3K5VOQVL-CbpovIYU.js +1 -0
  63. package/dist/web/assets/infoDiagram-LFFYTUFH-DFh9c-S2.js +2 -0
  64. package/dist/web/assets/input-DGlv6gt_.js +41 -0
  65. package/dist/web/assets/{isEmpty-C0YYdhYj.js → isEmpty-DXomfd7J.js} +1 -1
  66. package/dist/web/assets/{ishikawaDiagram-PHBUUO56-olazD6dZ.js → ishikawaDiagram-PHBUUO56-cW7SMLa_.js} +1 -1
  67. package/dist/web/assets/{journeyDiagram-4ABVD52K-CttDH9bb.js → journeyDiagram-4ABVD52K-DFQXUZsc.js} +1 -1
  68. package/dist/web/assets/{kanban-definition-K7BYSVSG-BBXbI37U.js → kanban-definition-K7BYSVSG-BMUhjxqj.js} +1 -1
  69. package/dist/web/assets/keybindings-store-DA8at4_B.js +1 -0
  70. package/dist/web/assets/{line-DBLLF7lH.js → line--xyfYP3x.js} +1 -1
  71. package/dist/web/assets/{linear-BLFWatDe.js → linear-BdqW7iQu.js} +1 -1
  72. package/dist/web/assets/{markdown-renderer-DK-YZN0m.js → markdown-renderer-oHkpw_nC.js} +5 -5
  73. package/dist/web/assets/{mermaid-parser.core-BKiGOTjR.js → mermaid-parser.core-BY8JfkE_.js} +2 -2
  74. package/dist/web/assets/{mindmap-definition-YRQLILUH-DoT7m4Sz.js → mindmap-definition-YRQLILUH-DIv-LMXG.js} +1 -1
  75. package/dist/web/assets/{ordinal-CCj7PWgZ.js → ordinal-CIoJK3nc.js} +1 -1
  76. package/dist/web/assets/packet-RMMSAZCW-BbzPU9BK.js +1 -0
  77. package/dist/web/assets/pie-UPGHQEXC-B0h6hM1j.js +1 -0
  78. package/dist/web/assets/{pieDiagram-SKSYHLDU-Bkh2E4zE.js → pieDiagram-SKSYHLDU-seSK40d1.js} +1 -1
  79. package/dist/web/assets/postgres-viewer-XnXGFIcT.js +1 -0
  80. package/dist/web/assets/{quadrantDiagram-337W2JSQ-B7zgALOL.js → quadrantDiagram-337W2JSQ-BaRFqlsA.js} +1 -1
  81. package/dist/web/assets/radar-KQ55EAFF-CHptMqVT.js +1 -0
  82. package/dist/web/assets/{requirementDiagram-Z7DCOOCP-D_5GXNRo.js → requirementDiagram-Z7DCOOCP-1WWjMQB_.js} +1 -1
  83. package/dist/web/assets/{sankeyDiagram-WA2Y5GQK-BA9EFAAe.js → sankeyDiagram-WA2Y5GQK-DEGGYsk7.js} +1 -1
  84. package/dist/web/assets/{sequenceDiagram-2WXFIKYE-fyWIrHiG.js → sequenceDiagram-2WXFIKYE-BtRvoUTC.js} +1 -1
  85. package/dist/web/assets/{settings-store-Bbhg_ptG.js → settings-store-D3dJqGhB.js} +2 -2
  86. package/dist/web/assets/settings-tab-t--MmXOo.js +1 -0
  87. package/dist/web/assets/sqlite-viewer-Zm20Z3Ys.js +1 -0
  88. package/dist/web/assets/{stateDiagram-RAJIS63D-DfRBcaBu.js → stateDiagram-RAJIS63D-C16aO8tn.js} +1 -1
  89. package/dist/web/assets/stateDiagram-v2-FVOUBMTO-D7qSAjnK.js +1 -0
  90. package/dist/web/assets/switch-goUjvGec.js +1 -0
  91. package/dist/web/assets/{tab-store-dpsCvqhH.js → tab-store-DSz5PQI0.js} +1 -1
  92. package/dist/web/assets/{terminal-tab-lu-7WWOT.js → terminal-tab-CxJ3m9tD.js} +2 -2
  93. package/dist/web/assets/{timeline-definition-YZTLITO2-DYfwJ1jM.js → timeline-definition-YZTLITO2-DrjxCpEM.js} +1 -1
  94. package/dist/web/assets/treemap-KZPCXAKY-BL9OJq3X.js +1 -0
  95. package/dist/web/assets/{use-monaco-theme-DHbyUrzJ.js → use-monaco-theme-BQzvItNE.js} +1 -1
  96. package/dist/web/assets/{vennDiagram-LZ73GAT5-DqbKNRD9.js → vennDiagram-LZ73GAT5-DfYFnniI.js} +1 -1
  97. package/dist/web/assets/{xychartDiagram-JWTSCODW-DhUL86qT.js → xychartDiagram-JWTSCODW-BRvXOVlG.js} +1 -1
  98. package/dist/web/index.html +12 -10
  99. package/dist/web/sw.js +1 -1
  100. package/docs/code-standards.md +106 -7
  101. package/docs/project-changelog.md +80 -1
  102. package/docs/streaming-input-guide.md +267 -0
  103. package/docs/system-architecture.md +177 -12
  104. package/package.json +1 -1
  105. package/src/providers/claude-agent-sdk.ts +16 -14
  106. package/src/providers/mock-provider.ts +6 -1
  107. package/src/server/index.ts +4 -0
  108. package/src/server/routes/browser-preview.ts +89 -0
  109. package/src/server/ws/chat.ts +194 -139
  110. package/src/types/api.ts +9 -1
  111. package/src/web/components/browser/browser-tab.tsx +269 -0
  112. package/src/web/components/chat/chat-tab.tsx +14 -5
  113. package/src/web/components/chat/message-input.tsx +39 -12
  114. package/src/web/components/chat/message-list.tsx +15 -12
  115. package/src/web/components/layout/command-palette.tsx +2 -0
  116. package/src/web/components/layout/editor-panel.tsx +1 -0
  117. package/src/web/components/layout/mobile-nav.tsx +2 -2
  118. package/src/web/components/layout/panel-layout.tsx +17 -1
  119. package/src/web/components/layout/tab-bar.tsx +2 -0
  120. package/src/web/components/layout/tab-content.tsx +5 -0
  121. package/src/web/hooks/use-chat.ts +196 -203
  122. package/src/web/stores/panel-store.ts +10 -10
  123. package/src/web/stores/tab-store.ts +2 -1
  124. package/dist/web/assets/architecture-PBZL5I3N-ChOahOB7.js +0 -1
  125. package/dist/web/assets/channel-w7yboq56.js +0 -1
  126. package/dist/web/assets/chat-tab-CM6zFolq.js +0 -7
  127. package/dist/web/assets/chunk-GLR3WWYH-D9pZakqr.js +0 -2
  128. package/dist/web/assets/chunk-HHEYEP7N-Dld5BpGB.js +0 -1
  129. package/dist/web/assets/chunk-QZHKN3VN-DwSXwtjH.js +0 -1
  130. package/dist/web/assets/classDiagram-VBA2DB6C-BpJ6Oog2.js +0 -1
  131. package/dist/web/assets/classDiagram-v2-RAHNMMFH-Bj8gIhkP.js +0 -1
  132. package/dist/web/assets/clone-BSi6cgDh.js +0 -1
  133. package/dist/web/assets/database-viewer-DSlQhR7c.js +0 -1
  134. package/dist/web/assets/git-graph-B5QR_Cf-.js +0 -1
  135. package/dist/web/assets/gitGraph-HDMCJU4V-CEee2FCA.js +0 -1
  136. package/dist/web/assets/index-WKLuYsBY.css +0 -2
  137. package/dist/web/assets/index-frRaTxEm.js +0 -37
  138. package/dist/web/assets/info-3K5VOQVL-ce_pi3En.js +0 -1
  139. package/dist/web/assets/infoDiagram-LFFYTUFH-BzqyoqXw.js +0 -2
  140. package/dist/web/assets/input-Brjz2Vv-.js +0 -41
  141. package/dist/web/assets/keybindings-store-Bjy78BoD.js +0 -1
  142. package/dist/web/assets/packet-RMMSAZCW-CdYSLjRL.js +0 -1
  143. package/dist/web/assets/pie-UPGHQEXC-Bm5LiD-6.js +0 -1
  144. package/dist/web/assets/postgres-viewer-CV0kVl2C.js +0 -1
  145. package/dist/web/assets/radar-KQ55EAFF-C4PnyG7_.js +0 -1
  146. package/dist/web/assets/settings-tab-DofusrxH.js +0 -1
  147. package/dist/web/assets/sqlite-viewer-D5L6DIMB.js +0 -1
  148. package/dist/web/assets/stateDiagram-v2-FVOUBMTO-CMN4M2Em.js +0 -1
  149. package/dist/web/assets/treemap-KZPCXAKY-2_y-mhkz.js +0 -1
  150. /package/dist/web/assets/{api-client-DpGMOZNf.js → api-client-icCZ-07C.js} +0 -0
  151. /package/dist/web/assets/{array-BGFCBI0e.js → array-CLwNaqU1.js} +0 -0
  152. /package/dist/web/assets/{columns-2-ChOTgl3e.js → columns-2-Bcg3QJBg.js} +0 -0
  153. /package/dist/web/assets/{cytoscape.esm-Ccan6xou.js → cytoscape.esm-B-QQuWwK.js} +0 -0
  154. /package/dist/web/assets/{defaultLocale-CRZydyG6.js → defaultLocale-D_VMtRaY.js} +0 -0
  155. /package/dist/web/assets/{dist-T0Vhi0Mh.js → dist-CMmNEgEP.js} +0 -0
  156. /package/dist/web/assets/{dist-Cce3efmT.js → dist-Ckxnw5rl.js} +0 -0
  157. /package/dist/web/assets/{init-B8gtcn7T.js → init-vVpfz1D6.js} +0 -0
  158. /package/dist/web/assets/{isArrayLikeObject-B4pdpV8V.js → isArrayLikeObject-DvHDmeBe.js} +0 -0
  159. /package/dist/web/assets/{katex-Bbu770d9.js → katex-C3cZrCvP.js} +0 -0
  160. /package/dist/web/assets/{math-DwgHI-Cu.js → math-a44lmFDa.js} +0 -0
  161. /package/dist/web/assets/{path-DZF-JdEe.js → path-CuyvWNAH.js} +0 -0
  162. /package/dist/web/assets/{preload-helper-qlgyTAkD.js → preload-helper-CsoeaaUJ.js} +0 -0
  163. /package/dist/web/assets/{react-BGf7KNLk.js → react-BPIfZRKM.js} +0 -0
  164. /package/dist/web/assets/{rough.esm-VLpapkIG.js → rough.esm-c4PR5shF.js} +0 -0
  165. /package/dist/web/assets/{src-BoSBNdA_.js → src-CLWraeNW.js} +0 -0
  166. /package/dist/web/assets/{table-Yo02WRH-.js → table-C9jDaRl2.js} +0 -0
  167. /package/dist/web/assets/{tag-CaC1ng2E.js → tag-CENGyt_L.js} +0 -0
  168. /package/dist/web/assets/{utils-btZ8C8-R.js → utils-Bslrbb-G.js} +0 -0
@@ -0,0 +1,267 @@
1
+ # Streaming Input Migration Quick Reference (v0.8.55+)
2
+
3
+ ## What Changed?
4
+
5
+ **Before (v0.8.54):** Each message triggered a new SDK query
6
+ ```
7
+ Message 1 → SDK subprocess spawn → generate response → close
8
+ Message 2 → SDK subprocess spawn → generate response → close
9
+ (Slow, context resets between messages)
10
+ ```
11
+
12
+ **After (v0.8.55):** Single persistent streaming session
13
+ ```
14
+ Session created → AsyncGenerator streaming input opened
15
+ Message 1 → Push into generator → process events
16
+ Message 2 → Push into same generator → continue streaming
17
+ (Fast, continuous context, no SDK restarts)
18
+ ```
19
+
20
+ ## Key Concepts
21
+
22
+ ### Session State (BE-Owned)
23
+ The backend maintains a `SessionEntry` per chat session:
24
+ - Tracks connected clients (can be zero if FE disconnected)
25
+ - Maintains streaming phase (idle, connecting, thinking, streaming)
26
+ - Buffers events for reconnection sync
27
+ - Auto-cleans after 5 minutes of FE inactivity
28
+
29
+ ### Message Priority (v0.8.55+)
30
+ ```typescript
31
+ // Send message with priority
32
+ ws.send({
33
+ type: "message",
34
+ content: "Debug this code",
35
+ priority: "now" // "now" | "next" | "later"
36
+ })
37
+ ```
38
+ - **"now"** — Abort current query, restart with this message
39
+ - **"next"** — Queue after current, run next
40
+ - **"later"** — Append to queue, run last
41
+
42
+ ### Event Buffering on Reconnect
43
+ When FE WS reconnects after disconnect:
44
+ 1. BE sends `session_state` with current phase + pending approval
45
+ 2. BE sends `turn_events` with all buffered events since last connection
46
+ 3. FE rebuilds chat UI state from buffered events
47
+ 4. No message loss (unless session cleaned up after 5min)
48
+
49
+ ## Common Patterns
50
+
51
+ ### Frontend: Send Message
52
+ ```typescript
53
+ // In useChat hook or message input handler
54
+ ws.send(JSON.stringify({
55
+ type: "message",
56
+ content: userInput,
57
+ priority: "now", // Optional
58
+ images: [{ id: "img1", data: "base64..." }] // Optional
59
+ }));
60
+ ```
61
+
62
+ ### Frontend: Handle Reconnection
63
+ ```typescript
64
+ function handleReconnect() {
65
+ // 1. WS open fires
66
+ // 2. Server sends session_state
67
+ const sessionState = JSON.parse(msg);
68
+ // 3. Server sends turn_events
69
+ const turnEvents = JSON.parse(msg);
70
+
71
+ // 4. FE rebuilds state from buffered events
72
+ turnEvents.events.forEach(event => {
73
+ chatStore.addEvent(event);
74
+ });
75
+
76
+ // 5. FE is now synced with BE
77
+ }
78
+ ```
79
+
80
+ ### Backend: Session Lifecycle
81
+ ```typescript
82
+ // 1. FE connects
83
+ open(ws) {
84
+ const entry = activeSessions.get(sessionId);
85
+ if (!entry) {
86
+ // Create new session entry
87
+ activeSessions.set(sessionId, {
88
+ phase: "idle",
89
+ clients: new Set([ws]),
90
+ turnEvents: []
91
+ });
92
+ } else {
93
+ // Reconnect: clear cleanup timer, add client
94
+ entry.clients.add(ws);
95
+ }
96
+ }
97
+
98
+ // 2. FE sends message
99
+ message(ws, data) {
100
+ const parsed = JSON.parse(data);
101
+ if (parsed.type === "message") {
102
+ // Abort current if streaming, wait for cleanup
103
+ if (entry.phase !== "idle") {
104
+ entry.abort.abort();
105
+ await entry.streamPromise;
106
+ }
107
+ // Start new streaming loop (detached)
108
+ entry.streamPromise = runStreamLoop(...);
109
+ }
110
+ }
111
+
112
+ // 3. Streaming loop runs independently
113
+ async function runStreamLoop() {
114
+ for await (const event of chatService.sendMessage(...)) {
115
+ bufferAndBroadcast(sessionId, event); // To all connected clients
116
+ }
117
+ setPhase(sessionId, "idle"); // Back to idle when done
118
+ if (entry.clients.size === 0) {
119
+ startCleanupTimer(sessionId); // 5-min cleanup
120
+ }
121
+ }
122
+
123
+ // 4. FE disconnects
124
+ close(ws) {
125
+ entry.clients.delete(ws);
126
+ // Stream continues! (BE owns the connection)
127
+ // Timer started if no more clients
128
+ }
129
+ ```
130
+
131
+ ## Phase State Machine
132
+
133
+ ```
134
+ ┌─ initializing (setup, session resume)
135
+
136
+ idle ←→ connecting (waiting for first SDK event, heartbeat)
137
+ ↑ ↓
138
+ │ ┌──→ thinking (extended thinking)
139
+ │ ↓ ↓
140
+ └─── streaming (text/tool_use content)
141
+ ↑ ↓
142
+ └─────┘ (dynamic switch)
143
+ ```
144
+
145
+ **Transitions:**
146
+ - Heartbeat: `connecting` → (5s elapsed updates) → `thinking` (when content arrives)
147
+ - Content: `thinking` → `streaming` (first text event)
148
+ - Dynamic: `streaming` ↔ `thinking` (based on event types)
149
+ - Done: Any → `idle` (stream complete, ready for next message)
150
+
151
+ ## WebSocket Messages (v0.8.55+)
152
+
153
+ ### Client → Server
154
+ ```typescript
155
+ // Send message
156
+ { type: "message"; content: string; priority?: string; images?: {...}[] }
157
+
158
+ // Approve tool
159
+ { type: "approval_response"; requestId: string; approved: boolean }
160
+
161
+ // Cancel current
162
+ { type: "cancel" }
163
+
164
+ // Handshake after open
165
+ { type: "ready" }
166
+ ```
167
+
168
+ ### Server → Client
169
+ ```typescript
170
+ // Content
171
+ { type: "text"; content: string }
172
+ { type: "thinking"; content: string }
173
+
174
+ // Tool execution
175
+ { type: "tool_use"; tool: string; input: unknown }
176
+ { type: "tool_result"; output: string; isError?: boolean }
177
+
178
+ // User approval request
179
+ { type: "approval_request"; requestId: string; tool: string; input: unknown }
180
+
181
+ // Session state (sent on open/ready)
182
+ { type: "session_state"; sessionId: string; phase: SessionPhase; pendingApproval: {...} | null }
183
+
184
+ // Buffered events (on reconnect)
185
+ { type: "turn_events"; events: unknown[] }
186
+
187
+ // Metadata
188
+ { type: "account_info"; accountId: string; accountLabel: string }
189
+ { type: "phase_changed"; phase: SessionPhase; elapsed?: number }
190
+ { type: "title_updated"; title: string }
191
+
192
+ // Completion
193
+ { type: "done"; sessionId: string; contextWindowPct?: number }
194
+
195
+ // Error
196
+ { type: "error"; message: string }
197
+
198
+ // Keepalive
199
+ { type: "ping" }
200
+ ```
201
+
202
+ ## Benefits
203
+
204
+ | Aspect | Before (v0.8.54) | After (v0.8.55) |
205
+ |--------|------------------|-----------------|
206
+ | **SDK Restarts** | Per message | Once per session |
207
+ | **Context** | Resets between messages | Persistent |
208
+ | **Startup Time** | 2-5s per message | Instant follow-ups |
209
+ | **Reconnection** | Message loss | Event buffering ensures sync |
210
+ | **Concurrency** | N/A | Multiple clients per session |
211
+ | **Tool Approvals** | Restarts query | Integrated in stream |
212
+
213
+ ## Troubleshooting
214
+
215
+ ### Session Cleaned Up (No Longer Exists)
216
+ **Cause:** FE disconnected for >5 minutes
217
+ **Solution:** Create new session, FE reconnects with new sessionId
218
+
219
+ ### Events Missing After Reconnect
220
+ **Cause:** Server-side event buffer (10k event limit) overflowed
221
+ **Solution:** Flush buffer periodically or increase limit if needed
222
+
223
+ ### Phase Stuck in "Connecting"
224
+ **Cause:** SDK subprocess not responding (120s timeout)
225
+ **Solution:** Check environment (ANTHROPIC_API_KEY, network), see error message for hints
226
+
227
+ ### Multiple Clients Out of Sync
228
+ **Cause:** Broadcast failed for one client, others ahead
229
+ **Solution:** Evicted client will reconnect and re-sync from buffered events
230
+
231
+ ## Debugging
232
+
233
+ ### Enable Logging
234
+ ```bash
235
+ # Check server logs for session lifecycle
236
+ [chat] session=abc123 phase → connecting
237
+ [chat] session=abc123 first SDK event after 1250ms: type=text
238
+ [chat] session=abc123 stream completed (45 events)
239
+ [chat] session=abc123 phase → idle
240
+ ```
241
+
242
+ ### Check Session State
243
+ ```typescript
244
+ // On WS message handler
245
+ console.log(`Session entry:`, activeSessions.get(sessionId));
246
+ // Outputs: { phase, clients.size, pendingApprovalEvent, turnEvents.length }
247
+ ```
248
+
249
+ ### Monitor Reconnections
250
+ ```typescript
251
+ // In WS open handler
252
+ console.log(`FE reconnected (phase=${existing.phase}, clients=${existing.clients.size})`);
253
+ // Tells you: active streaming, how many clients connected
254
+ ```
255
+
256
+ ## Performance Notes
257
+
258
+ - **No SDK overhead:** Persistent streaming eliminates subprocess spawn overhead
259
+ - **Event buffering:** Clients see all events after reconnect (max 10k events per turn)
260
+ - **Memory:** Session entries cleaned after 5min (bounded memory usage)
261
+ - **Latency:** Follow-up messages start immediately (no SDK init)
262
+
263
+ ---
264
+
265
+ **For detailed architecture:** See `docs/system-architecture.md` → "Chat Streaming Flow" section
266
+ **For API types:** See `src/types/api.ts` and `src/types/chat.ts`
267
+ **For implementation:** See `src/server/ws/chat.ts` and `src/providers/claude-agent-sdk.ts`
@@ -205,7 +205,47 @@ interface AIProvider {
205
205
  **Implementations:**
206
206
  - **claude-agent-sdk** (Primary) — @anthropic-ai/claude-agent-sdk, streaming, tool use. Reads model/effort/maxTurns/budget/thinking from config. Settings refreshed per query. Windows CLI fallback for Bun subprocess pipe issues. .env poisoning mitigation. **Multi-account support:** Injects account API token from AccountService instead of relying on ANTHROPIC_API_KEY env var when accounts configured.
207
207
  - **mock-provider** (Testing) — Returns canned responses
208
- - **Note:** CLI provider removed (v2); agent SDK is sole AI provider with Windows CLI fallback
208
+ - **cursor-cli** (CLI-based) Spawns `cursor-agent` CLI binary with NDJSON streaming. Extends `CliProvider` base class.
209
+ - **codex/gemini** (Planned) — Pluggable via `CliProvider` extension (~100-150 lines each)
210
+
211
+ #### Multi-Provider Architecture (v0.8.61+)
212
+
213
+ PPM supports multiple AI providers through a generic `AIProvider` interface and extensible base classes:
214
+
215
+ **Provider Types:**
216
+ 1. **SDK-based** (claude-agent-sdk) — Uses Anthropic SDK for rich features (approvals, thinking blocks)
217
+ 2. **CLI-based** (cursor-cli, codex, gemini) — Spawns external binary with NDJSON streaming
218
+
219
+ **Base Classes:**
220
+ - `AIProvider` interface — Defines required methods (createSession, sendMessage) + optional capabilities (abortQuery, getMessages, listSessionsByDir, ensureProjectPath)
221
+ - `CliProvider` abstract class — Shared spawn/parse/abort logic for all CLI-spawning providers
222
+ - Provider-specific subclasses implement: `buildArgs()`, `mapEvent()`, `extractSessionId()`, `isAvailable()`
223
+
224
+ **Streaming Infrastructure:**
225
+ - `parseNdjsonLines()` utility — Async generator that buffers partial TCP packets, yields complete JSON lines
226
+ - `ChatEvent` union type — Normalized event format across all providers (text, tool_use, thinking, approval_request, system, done, error)
227
+ - Event mappers translate provider-specific JSON → ChatEvent (e.g., Cursor's `reasoning` type → `thinking` event)
228
+
229
+ **Provider Registration & Bootstrap:**
230
+ - `ProviderRegistry` maintains active provider instances
231
+ - `bootstrapProviders()` async function checks `isAvailable()` on CLI providers before registering
232
+ - Graceful fallback: if Cursor binary not found, provider skips registration (no crash, logged as info)
233
+ - Config type `AIProviderConfig.type` union: `"agent-sdk" | "cli" | "mock"`
234
+
235
+ **CLI-Provider Features:**
236
+ - **Session capture** — Extract session ID from provider's init event, re-key process tracking
237
+ - **Workspace trust auto-retry** — Detect trust prompts in stderr, retry once with `--trust` flag
238
+ - **Process lifecycle** — Track active processes per session, escalate SIGTERM → SIGKILL on abort
239
+ - **History loading** — Override `listSessions()` to read native provider history (e.g., Cursor SQLite DAG)
240
+ - **Graceful degradation** — Missing binary → provider skipped, not fatal
241
+
242
+ **New Files (v0.8.61):**
243
+ - `src/utils/ndjson-line-parser.ts` — NDJSON streaming parser
244
+ - `src/providers/cli-provider-base.ts` — Abstract base class for CLI providers
245
+ - `src/providers/cursor-cli/cursor-provider.ts` — CursorCliProvider implementation
246
+ - `src/providers/cursor-cli/cursor-event-mapper.ts` — NDJSON → ChatEvent mapping
247
+ - `src/providers/cursor-cli/cursor-history.ts` — SQLite DAG reader for Cursor history
248
+ - `src/web/components/chat/provider-selector.tsx` — UI component for provider selection
209
249
 
210
250
  ---
211
251
 
@@ -453,7 +493,27 @@ Returns full updated config. Validates ranges/enums before writing.
453
493
 
454
494
  ---
455
495
 
456
- ## Chat Streaming Flow
496
+ ## Chat Streaming Flow (Persistent AsyncGenerator Sessions)
497
+
498
+ ### Architecture Overview (v0.8.55+)
499
+
500
+ PPM uses a **persistent streaming session** model instead of per-message query execution:
501
+
502
+ **Key Changes:**
503
+ - Provider maintains **long-lived AsyncGenerator streaming input** per chat session (not per message)
504
+ - Follow-up messages **push into the existing generator** instead of abort-and-replace
505
+ - **Single streaming loop** per session decoupled from WebSocket message handler
506
+ - Message priority support: `now` (interrupt current), `next` (queue first), `later` (queue at end)
507
+ - Supports image attachments in messages
508
+
509
+ **Design Benefits:**
510
+ - Continuous context preservation — multi-turn conversations flow naturally
511
+ - No SDK subprocess restarts between messages (faster)
512
+ - Clean separation: BE owns Claude connection, FE disconnect doesn't abort
513
+ - Message buffering on reconnect — clients that lose WS connection sync turn events
514
+ - Tool approvals don't restart the query — integrated into streaming loop
515
+
516
+ ### Message Flow
457
517
 
458
518
  ```
459
519
  User types: "Debug this function"
@@ -462,18 +522,26 @@ MessageInput.tsx calls useChat.sendMessage()
462
522
 
463
523
  useChat opens WebSocket: WS /ws/project/:name/chat/:sessionId
464
524
 
465
- Sends: { type: "message", content: "Debug..." }
525
+ Sends: { type: "message", content: "Debug...", priority?: "now"|"next"|"later" }
526
+
527
+ WS handler in chat.ts receives message
466
528
 
467
- Server routes to ChatService.streamMessage()
529
+ If already streaming with different content → abort previous + wait cleanup
530
+ If streaming, new message priority determines queue behavior:
531
+ • priority: "now" → abort current, restart with new content
532
+ • priority: "next" → push into pending queue (higher priority)
533
+ • priority: "later" → push to end of queue (FIFO)
534
+
535
+ runStreamLoop() executes in detached async context
468
536
 
469
537
  ChatService calls provider.sendMessage() (async generator)
470
538
 
471
- Provider (Claude SDK) streams response:
472
- 1. Yields: { type: "text", content: "Here's what..." }
473
- 2. Yields: { type: "text", content: " happens..." }
474
- 3. Yields: { type: "tool_use", tool: "read_file", input: {...} }
539
+ Provider (Claude SDK) yields events:
540
+ 1. { type: "text", content: "Here's what..." }
541
+ 2. { type: "text", content: " happens..." }
542
+ 3. { type: "tool_use", tool: "read_file", input: {...} }
475
543
 
476
- ChatService wraps as WebSocket messages:
544
+ Stream loop buffers + broadcasts to all connected clients:
477
545
  { type: "text", content: "Here's what..." }
478
546
  { type: "text", content: " happens..." }
479
547
  { type: "tool_use", tool: "read_file", input: {...} }
@@ -485,15 +553,112 @@ User sees tool approval prompt, clicks "Approve"
485
553
 
486
554
  Client sends: { type: "approval_response", requestId, approved: true }
487
555
 
488
- ChatService.onToolApproval() executes tool (file_read, git commands, etc.)
556
+ Provider continues streaming with tool result (no restart)
489
557
 
490
- Provider continues streaming with tool result
558
+ If multiple messages queued, next message processes after done event
491
559
 
492
560
  Final response streamed, then: { type: "done", sessionId }
493
561
 
494
- useChat closes WebSocket, saves message to store
562
+ Phase transitions to idle, clients can send new message
563
+
564
+ useChat saves message to store, displays in chat history
495
565
  ```
496
566
 
567
+ ### Session State Management
568
+
569
+ **Session Entry** (BE-owned, persists across FE disconnections):
570
+ ```typescript
571
+ interface SessionEntry {
572
+ providerId: string; // Which AI provider (e.g., "claude")
573
+ clients: Set<ChatWsSocket>; // Connected FE clients (may be empty)
574
+ abort?: AbortController; // Current stream abort handle
575
+ projectPath?: string; // Project context
576
+ projectName?: string;
577
+ pingIntervals: Map<...>; // Per-client keepalive
578
+ phase: SessionPhase; // "initializing" | "connecting" | "thinking" | "streaming" | "idle"
579
+ cleanupTimer?: ReturnType<...>; // Auto-cleanup if no FE reconnects (5min)
580
+ pendingApprovalEvent?: {...}; // Current tool approval waiting
581
+ turnEvents: unknown[]; // Buffered events (for reconnect sync)
582
+ streamPromise?: Promise<void>; // Track ongoing runStreamLoop
583
+ permissionMode?: string; // Sticky permission mode for session
584
+ }
585
+ ```
586
+
587
+ **Client Connection States:**
588
+ - **Active streaming + FE connected** → Events broadcast to all clients in real-time
589
+ - **Active streaming + FE disconnected** → Events buffered in turnEvents array, BE stream continues
590
+ - **FE reconnects** → Receive session_state + buffered turnEvents, resync with stream
591
+ - **Idle (no query running)** → Phase is "idle", ready for next message
592
+ - **Idle + no FE for 5min** → Cleanup timer removes session from memory
593
+
594
+ ### Follow-up Messages
595
+
596
+ **Abort-and-Replace Pattern:**
597
+ ```typescript
598
+ if (entry.phase !== "idle" && entry.abort) {
599
+ console.log(`[chat] aborting current query for new message`);
600
+ entry.abort.abort();
601
+ await entry.streamPromise; // Wait for cleanup
602
+ // Re-fetch entry — may have been mutated during cleanup
603
+ entry = activeSessions.get(sessionId)!;
604
+ }
605
+ ```
606
+
607
+ **Multiple Message Queueing:**
608
+ - First message: immediately starts runStreamLoop
609
+ - Second message (while streaming): abort current, wait, start new runStreamLoop
610
+ - Priority modes (future): could queue messages for intelligent interleaving
611
+
612
+ ### WebSocket Reconnection Sync
613
+
614
+ ```
615
+ FE WebSocket closes (network issue, tab closes)
616
+
617
+ BE keeps session alive, streaming continues
618
+
619
+ FE reconnects: WS /ws/project/:name/chat/:sessionId
620
+
621
+ open() handler checks activeSessions.get(sessionId)
622
+
623
+ If exists (entry found):
624
+ 1. Clear cleanup timer (FE is back)
625
+ 2. Send session_state with current phase + pendingApproval
626
+ 3. If phase !== "idle", send buffered turnEvents
627
+ 4. Add WS to clients Set
628
+
629
+ FE processes session_state, renders current phase
630
+
631
+ FE applies buffered events to rebuild turn state
632
+
633
+ FE displays: "reconnected, current phase: streaming" etc.
634
+ ```
635
+
636
+ ### Phase Transitions
637
+
638
+ ```
639
+ idle → initializing → connecting → thinking/streaming ↔ thinking/streaming → idle
640
+ ^ ↑ ↓
641
+ └──────────────────────────────────────────────────────────────────────────┘
642
+ ```
643
+
644
+ **Phase Descriptions:**
645
+ - **idle** — No query running, ready to accept new message
646
+ - **initializing** — Preparing (permission checks, session resume)
647
+ - **connecting** — Waiting for first SDK event (heartbeat: "connecting" with elapsed time every 5s)
648
+ - **thinking** — Receiving thinking content (extended thinking)
649
+ - **streaming** — Receiving text/tool_use content (dynamic switch between thinking/streaming)
650
+
651
+ ### Image Attachment Support
652
+
653
+ Messages can now include images:
654
+ ```typescript
655
+ type ChatWsClientMessage =
656
+ | { type: "message"; content: string; images?: { id: string; data: string }[]; priority?: string }
657
+ | ...
658
+ ```
659
+
660
+ Images are passed to provider's message context and included in tool input/output.
661
+
497
662
  ---
498
663
 
499
664
  ## Terminal Flow
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hienlh/ppm",
3
- "version": "0.8.59",
3
+ "version": "0.8.61",
4
4
  "description": "Personal Project Manager — mobile-first web IDE with AI assistance",
5
5
  "author": "hienlh",
6
6
  "license": "MIT",
@@ -502,22 +502,24 @@ export class ClaudeAgentSdkProvider implements AIProvider {
502
502
 
503
503
  // Log all system events for debugging SDK lifecycle
504
504
  if (msg.type === "system") {
505
- console.log(`[sdk] session=${sessionId} system: subtype=${(msg as any).subtype ?? "none"} ${JSON.stringify(msg).slice(0, 500)}`);
506
- }
507
-
508
- // Capture SDK session metadata from init message
509
- if (msg.type === "system" && (msg as any).subtype === "init") {
510
- const initMsg = msg as any;
511
- // SDK may assign a different session_id than our UUID
512
- if (initMsg.session_id && initMsg.session_id !== sessionId) {
513
- // Persist mapping so resume works after server restart
514
- setSessionMapping(sessionId, initMsg.session_id);
515
- // Update our in-memory mapping
516
- const oldMeta = this.activeSessions.get(sessionId);
517
- if (oldMeta) {
518
- this.activeSessions.set(initMsg.session_id, { ...oldMeta, id: initMsg.session_id });
505
+ const subtype = (msg as any).subtype ?? "none";
506
+ console.log(`[sdk] session=${sessionId} system: subtype=${subtype} ${JSON.stringify(msg).slice(0, 500)}`);
507
+
508
+ // Capture SDK session metadata from init message
509
+ if (subtype === "init") {
510
+ const initMsg = msg as any;
511
+ if (initMsg.session_id && initMsg.session_id !== sessionId) {
512
+ setSessionMapping(sessionId, initMsg.session_id);
513
+ const oldMeta = this.activeSessions.get(sessionId);
514
+ if (oldMeta) {
515
+ this.activeSessions.set(initMsg.session_id, { ...oldMeta, id: initMsg.session_id });
516
+ }
519
517
  }
520
518
  }
519
+
520
+ // Yield system events so streaming loop can transition phases
521
+ // (e.g. connecting → thinking when hooks/init arrive)
522
+ yield { type: "system" as any, subtype } as any;
521
523
  continue;
522
524
  }
523
525
 
@@ -92,8 +92,13 @@ export class MockProvider implements AIProvider {
92
92
  const abortController = new AbortController();
93
93
  this.activeAborts.set(sessionId, abortController);
94
94
 
95
+ // Simulate SDK system events (hooks, init) — real SDK emits these before content
96
+ yield { type: "system" as any, subtype: "hook_started" } as any;
97
+ await sleep(50);
98
+ yield { type: "system" as any, subtype: "init" } as any;
99
+
95
100
  // Simulate thinking delay
96
- await sleep(300);
101
+ await sleep(250);
97
102
 
98
103
  // Pick a response
99
104
  const responseText =
@@ -14,6 +14,7 @@ import { databaseRoutes } from "./routes/database.ts";
14
14
  import { fsBrowseRoutes } from "./routes/fs-browse.ts";
15
15
  import { accountsRoutes } from "./routes/accounts.ts";
16
16
  import { proxyRoutes } from "./routes/proxy.ts";
17
+ import { browserPreviewRoutes } from "./routes/browser-preview.ts";
17
18
  import { initAdapters } from "../services/database/init-adapters.ts";
18
19
  import { terminalWebSocket } from "./ws/terminal.ts";
19
20
  import { chatWebSocket } from "./ws/chat.ts";
@@ -126,6 +127,9 @@ app.route("/proxy", proxyRoutes);
126
127
  app.use("/api/*", authMiddleware);
127
128
  app.get("/api/auth/check", (c) => c.json(ok(true)));
128
129
 
130
+ // Browser preview reverse proxy — proxies to localhost:<port> for iframe embedding
131
+ app.route("/api/preview", browserPreviewRoutes);
132
+
129
133
  // Filesystem operations (browse, list, read, write) — consolidated in fs-browse route
130
134
  app.route("/api/fs", fsBrowseRoutes);
131
135
 
@@ -0,0 +1,89 @@
1
+ import { Hono } from "hono";
2
+
3
+ /**
4
+ * Browser preview reverse proxy — forwards requests to localhost:<port>.
5
+ * Mounted at /api/preview/:port/* so the frontend iframe can load
6
+ * any localhost dev server through PPM's own origin (avoiding CORS/framing issues).
7
+ */
8
+ export const browserPreviewRoutes = new Hono();
9
+
10
+ /** Only allow proxying to localhost ports (security: prevent SSRF) */
11
+ function isValidPort(port: string): boolean {
12
+ const n = parseInt(port, 10);
13
+ return !isNaN(n) && n >= 1 && n <= 65535;
14
+ }
15
+
16
+ browserPreviewRoutes.all("/:port{[0-9]+}/*", async (c) => {
17
+ const port = c.req.param("port");
18
+ if (!isValidPort(port)) {
19
+ return c.text("Invalid port", 400);
20
+ }
21
+
22
+ // Build target URL — strip the /api/preview/:port prefix
23
+ const url = new URL(c.req.url);
24
+ const prefix = `/api/preview/${port}`;
25
+ const targetPath = url.pathname.slice(prefix.length) || "/";
26
+ const targetUrl = `http://localhost:${port}${targetPath}${url.search}`;
27
+
28
+ try {
29
+ // Forward the request with original method, headers, and body
30
+ const headers = new Headers(c.req.raw.headers);
31
+ // Remove host header so target server sees localhost
32
+ headers.delete("host");
33
+
34
+ const resp = await fetch(targetUrl, {
35
+ method: c.req.method,
36
+ headers,
37
+ body: ["GET", "HEAD"].includes(c.req.method) ? undefined : c.req.raw.body,
38
+ redirect: "manual",
39
+ });
40
+
41
+ // Clone response headers, remove framing restrictions so iframe works
42
+ const respHeaders = new Headers(resp.headers);
43
+ respHeaders.delete("x-frame-options");
44
+ respHeaders.delete("content-security-policy");
45
+
46
+ return new Response(resp.body, {
47
+ status: resp.status,
48
+ statusText: resp.statusText,
49
+ headers: respHeaders,
50
+ });
51
+ } catch {
52
+ return c.text(`Cannot connect to localhost:${port}`, 502);
53
+ }
54
+ });
55
+
56
+ // Handle root path (no trailing slash)
57
+ browserPreviewRoutes.all("/:port{[0-9]+}", async (c) => {
58
+ const port = c.req.param("port");
59
+ if (!isValidPort(port)) {
60
+ return c.text("Invalid port", 400);
61
+ }
62
+
63
+ const url = new URL(c.req.url);
64
+ const targetUrl = `http://localhost:${port}/${url.search}`;
65
+
66
+ try {
67
+ const headers = new Headers(c.req.raw.headers);
68
+ headers.delete("host");
69
+
70
+ const resp = await fetch(targetUrl, {
71
+ method: c.req.method,
72
+ headers,
73
+ body: ["GET", "HEAD"].includes(c.req.method) ? undefined : c.req.raw.body,
74
+ redirect: "manual",
75
+ });
76
+
77
+ const respHeaders = new Headers(resp.headers);
78
+ respHeaders.delete("x-frame-options");
79
+ respHeaders.delete("content-security-policy");
80
+
81
+ return new Response(resp.body, {
82
+ status: resp.status,
83
+ statusText: resp.statusText,
84
+ headers: respHeaders,
85
+ });
86
+ } catch {
87
+ return c.text(`Cannot connect to localhost:${port}`, 502);
88
+ }
89
+ });