@hienlh/ppm 0.8.54 → 0.8.55

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 (158) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/bun.lock +250 -1
  3. package/dist/web/assets/_basePickBy-CZovQgWd.js +1 -0
  4. package/dist/web/assets/_baseUniq-ClnvscgW.js +1 -0
  5. package/dist/web/assets/{api-client-TUmacMRS.js → api-client-DpGMOZNf.js} +1 -1
  6. package/dist/web/assets/api-settings--eVrUeZM.js +1 -0
  7. package/dist/web/assets/arc-C2Qaz-ch.js +1 -0
  8. package/dist/web/assets/architecture-PBZL5I3N-ChOahOB7.js +1 -0
  9. package/dist/web/assets/architectureDiagram-2XIMDMQ5-Jq91S_rs.js +36 -0
  10. package/dist/web/assets/array-BGFCBI0e.js +1 -0
  11. package/dist/web/assets/blockDiagram-WCTKOSBZ-CKGufRTy.js +132 -0
  12. package/dist/web/assets/c4Diagram-IC4MRINW-BNP2L9r_.js +10 -0
  13. package/dist/web/assets/channel-w7yboq56.js +1 -0
  14. package/dist/web/assets/chat-tab-BUOCxR2G.js +7 -0
  15. package/dist/web/assets/chunk-4BX2VUAB-BptTlTyl.js +1 -0
  16. package/dist/web/assets/chunk-55IACEB6-C4mUdyio.js +1 -0
  17. package/dist/web/assets/chunk-7E7YKBS2-6xAQfBwa.js +1 -0
  18. package/dist/web/assets/chunk-7R4GIKGN-DXaGAn_K.js +80 -0
  19. package/dist/web/assets/chunk-C72U2L5F-DOtEiN5f.js +1 -0
  20. package/dist/web/assets/chunk-CFjPhJqf.js +1 -0
  21. package/dist/web/assets/chunk-EGIJ26TM-D0KJTa_T.js +1 -0
  22. package/dist/web/assets/chunk-FMBD7UC4-C_1aG0eb.js +15 -0
  23. package/dist/web/assets/chunk-GEFDOKGD-DwVPiYfW.js +2 -0
  24. package/dist/web/assets/chunk-GLR3WWYH-D9pZakqr.js +2 -0
  25. package/dist/web/assets/chunk-HHEYEP7N-Dld5BpGB.js +1 -0
  26. package/dist/web/assets/chunk-JSJVCQXG-BSrqCL_3.js +1 -0
  27. package/dist/web/assets/chunk-KX2RTZJC-BCxGmbzy.js +1 -0
  28. package/dist/web/assets/chunk-KYZI473N-BKO5gMeU.js +53 -0
  29. package/dist/web/assets/chunk-L3YUKLVL-3wBgkSvL.js +1 -0
  30. package/dist/web/assets/chunk-MX3YWQON-BgjSEzus.js +1 -0
  31. package/dist/web/assets/chunk-NQ4KR5QH-DLrZwBEm.js +220 -0
  32. package/dist/web/assets/chunk-O4XLMI2P-BurQy8tt.js +7 -0
  33. package/dist/web/assets/chunk-OZEHJAEY-YTn24bGg.js +1 -0
  34. package/dist/web/assets/chunk-PQ6SQG4A-BxtUGYhW.js +1 -0
  35. package/dist/web/assets/chunk-PU5JKC2W-B66ELkQm.js +70 -0
  36. package/dist/web/assets/chunk-QZHKN3VN-DwSXwtjH.js +1 -0
  37. package/dist/web/assets/chunk-R5LLSJPH-euR2RxLN.js +1 -0
  38. package/dist/web/assets/chunk-WL4C6EOR-_2CBOJdI.js +189 -0
  39. package/dist/web/assets/chunk-XIRO2GV7-kqQ0g6wW.js +1 -0
  40. package/dist/web/assets/chunk-XPW4576I-CtcaMb09.js +32 -0
  41. package/dist/web/assets/chunk-XZSTWKYB-BYxFzZwS.js +94 -0
  42. package/dist/web/assets/chunk-YBOYWFTD-Dx_fX35n.js +1 -0
  43. package/dist/web/assets/classDiagram-VBA2DB6C-BpJ6Oog2.js +1 -0
  44. package/dist/web/assets/classDiagram-v2-RAHNMMFH-Bj8gIhkP.js +1 -0
  45. package/dist/web/assets/clone-BSi6cgDh.js +1 -0
  46. package/dist/web/assets/code-editor-os78eUN8.js +1 -0
  47. package/dist/web/assets/columns-2-ChOTgl3e.js +1 -0
  48. package/dist/web/assets/cose-bilkent-S5V4N54A-CHHjH2dV.js +1 -0
  49. package/dist/web/assets/cytoscape.esm-Ccan6xou.js +321 -0
  50. package/dist/web/assets/dagre-CNtSxiE_.js +1 -0
  51. package/dist/web/assets/dagre-KLK3FWXG-ChenfPp1.js +4 -0
  52. package/dist/web/assets/database-viewer-DTwe0h8F.js +1 -0
  53. package/dist/web/assets/defaultLocale-CRZydyG6.js +1 -0
  54. package/dist/web/assets/diagram-E7M64L7V-CzKYZM0Y.js +24 -0
  55. package/dist/web/assets/diagram-IFDJBPK2-ChB_paPo.js +43 -0
  56. package/dist/web/assets/diagram-P4PSJMXO-D1eW1dkL.js +24 -0
  57. package/dist/web/assets/diff-viewer-CSyOOmS2.js +4 -0
  58. package/dist/web/assets/dist-Cce3efmT.js +1 -0
  59. package/dist/web/assets/{dist-QgqOdSYG.js → dist-T0Vhi0Mh.js} +1 -1
  60. package/dist/web/assets/erDiagram-INFDFZHY-mCvUFSn6.js +70 -0
  61. package/dist/web/assets/flowDiagram-PKNHOUZH-14ohZ1M1.js +162 -0
  62. package/dist/web/assets/ganttDiagram-A5KZAMGK-DIX0pLbk.js +292 -0
  63. package/dist/web/assets/git-graph-CwYW3F4P.js +1 -0
  64. package/dist/web/assets/gitGraph-HDMCJU4V-CEee2FCA.js +1 -0
  65. package/dist/web/assets/gitGraphDiagram-K3NZZRJ6-yEWZbdf_.js +65 -0
  66. package/dist/web/assets/graphlib-DhOZxqsh.js +1 -0
  67. package/dist/web/assets/index-WKLuYsBY.css +2 -0
  68. package/dist/web/assets/index-yMR7OUDx.js +37 -0
  69. package/dist/web/assets/info-3K5VOQVL-ce_pi3En.js +1 -0
  70. package/dist/web/assets/infoDiagram-LFFYTUFH-BzqyoqXw.js +2 -0
  71. package/dist/web/assets/init-B8gtcn7T.js +1 -0
  72. package/dist/web/assets/input-Brjz2Vv-.js +41 -0
  73. package/dist/web/assets/isArrayLikeObject-B4pdpV8V.js +1 -0
  74. package/dist/web/assets/isEmpty-C0YYdhYj.js +1 -0
  75. package/dist/web/assets/ishikawaDiagram-PHBUUO56-olazD6dZ.js +70 -0
  76. package/dist/web/assets/journeyDiagram-4ABVD52K-CttDH9bb.js +139 -0
  77. package/dist/web/assets/jsx-runtime-BRW_vwa9.js +1 -0
  78. package/dist/web/assets/kanban-definition-K7BYSVSG-BBXbI37U.js +89 -0
  79. package/dist/web/assets/katex-Bbu770d9.js +265 -0
  80. package/dist/web/assets/keybindings-store-B-BLLKiZ.js +1 -0
  81. package/dist/web/assets/line-DBLLF7lH.js +1 -0
  82. package/dist/web/assets/linear-BLFWatDe.js +1 -0
  83. package/dist/web/assets/markdown-renderer-DQWY7QvX.js +69 -0
  84. package/dist/web/assets/math-DwgHI-Cu.js +1 -0
  85. package/dist/web/assets/mermaid-parser.core-BKiGOTjR.js +4 -0
  86. package/dist/web/assets/mindmap-definition-YRQLILUH-DoT7m4Sz.js +68 -0
  87. package/dist/web/assets/ordinal-CCj7PWgZ.js +1 -0
  88. package/dist/web/assets/packet-RMMSAZCW-CdYSLjRL.js +1 -0
  89. package/dist/web/assets/path-DZF-JdEe.js +1 -0
  90. package/dist/web/assets/pie-UPGHQEXC-Bm5LiD-6.js +1 -0
  91. package/dist/web/assets/pieDiagram-SKSYHLDU-Bkh2E4zE.js +30 -0
  92. package/dist/web/assets/postgres-viewer-Ctv7NTI_.js +1 -0
  93. package/dist/web/assets/preload-helper-qlgyTAkD.js +1 -0
  94. package/dist/web/assets/quadrantDiagram-337W2JSQ-B7zgALOL.js +7 -0
  95. package/dist/web/assets/radar-KQ55EAFF-C4PnyG7_.js +1 -0
  96. package/dist/web/assets/react-BGf7KNLk.js +1 -0
  97. package/dist/web/assets/react-nm2Ru1Pt.js +1 -0
  98. package/dist/web/assets/requirementDiagram-Z7DCOOCP-D_5GXNRo.js +73 -0
  99. package/dist/web/assets/rough.esm-VLpapkIG.js +1 -0
  100. package/dist/web/assets/sankeyDiagram-WA2Y5GQK-BA9EFAAe.js +10 -0
  101. package/dist/web/assets/sequenceDiagram-2WXFIKYE-fyWIrHiG.js +145 -0
  102. package/dist/web/assets/settings-store-Bbhg_ptG.js +2 -0
  103. package/dist/web/assets/settings-tab-Daap0c_B.js +1 -0
  104. package/dist/web/assets/sqlite-viewer-DtNk76CE.js +1 -0
  105. package/dist/web/assets/src-BoSBNdA_.js +1 -0
  106. package/dist/web/assets/stateDiagram-RAJIS63D-DfRBcaBu.js +1 -0
  107. package/dist/web/assets/stateDiagram-v2-FVOUBMTO-CMN4M2Em.js +1 -0
  108. package/dist/web/assets/{tab-store-NOBndc0_.js → tab-store-dpsCvqhH.js} +1 -1
  109. package/dist/web/assets/{table-B6neW6Hr.js → table-Yo02WRH-.js} +1 -1
  110. package/dist/web/assets/{tag-DJUYe5BQ.js → tag-CaC1ng2E.js} +1 -1
  111. package/dist/web/assets/{terminal-tab-0Y48dynP.js → terminal-tab-JEpjt3RD.js} +2 -2
  112. package/dist/web/assets/timeline-definition-YZTLITO2-DYfwJ1jM.js +61 -0
  113. package/dist/web/assets/treemap-KZPCXAKY-2_y-mhkz.js +1 -0
  114. package/dist/web/assets/{use-monaco-theme-DwP4EHdO.js → use-monaco-theme-DHbyUrzJ.js} +1 -1
  115. package/dist/web/assets/vennDiagram-LZ73GAT5-DqbKNRD9.js +34 -0
  116. package/dist/web/assets/xychartDiagram-JWTSCODW-DhUL86qT.js +7 -0
  117. package/dist/web/index.html +13 -11
  118. package/dist/web/sw.js +1 -1
  119. package/package.json +2 -1
  120. package/snapshot-state.md +1526 -0
  121. package/src/providers/claude-agent-sdk.ts +7 -0
  122. package/src/server/index.ts +25 -16
  123. package/src/server/routes/accounts.ts +3 -8
  124. package/src/services/account-selector.service.ts +52 -9
  125. package/src/services/fs-browse.service.ts +10 -7
  126. package/src/web/app.tsx +8 -0
  127. package/src/web/components/chat/account-dialogs.tsx +377 -0
  128. package/src/web/components/chat/message-list.tsx +196 -45
  129. package/src/web/components/chat/usage-badge.tsx +56 -20
  130. package/src/web/components/settings/settings-tab.tsx +2 -5
  131. package/src/web/components/shared/diagram-overlay.tsx +139 -0
  132. package/src/web/components/shared/image-overlay.tsx +45 -0
  133. package/src/web/components/shared/markdown-renderer.tsx +55 -2
  134. package/src/web/stores/diagram-overlay-store.ts +16 -0
  135. package/src/web/stores/image-overlay-store.ts +18 -0
  136. package/test-tokens.mjs +212 -0
  137. package/.claude.bak/agent-memory/tester/MEMORY.md +0 -3
  138. package/.claude.bak/agent-memory/tester/project-ppm-test-conventions.md +0 -32
  139. package/dist/web/assets/api-settings-D4bgXrLU.js +0 -1
  140. package/dist/web/assets/chat-tab-CgVh-OsO.js +0 -7
  141. package/dist/web/assets/code-editor-DgvZlpB7.js +0 -1
  142. package/dist/web/assets/columns-2-BZ5wv2wA.js +0 -1
  143. package/dist/web/assets/database-viewer-CRZksTo-.js +0 -1
  144. package/dist/web/assets/diff-viewer-CPNLuddT.js +0 -4
  145. package/dist/web/assets/git-graph-BCtMSQwB.js +0 -1
  146. package/dist/web/assets/index-CfSJP_Fv.css +0 -2
  147. package/dist/web/assets/index-DcJqqWbL.js +0 -37
  148. package/dist/web/assets/input-CE3bFwLk.js +0 -41
  149. package/dist/web/assets/jsx-runtime-wQxeESYQ.js +0 -1
  150. package/dist/web/assets/keybindings-store-C1HiSDRb.js +0 -1
  151. package/dist/web/assets/markdown-renderer-Ci7qz558.js +0 -59
  152. package/dist/web/assets/postgres-viewer-C8PRJ87B.js +0 -1
  153. package/dist/web/assets/react-CYzKIDNi.js +0 -1
  154. package/dist/web/assets/react-rgzL83kk.js +0 -1
  155. package/dist/web/assets/settings-store-DL2KEbtc.js +0 -2
  156. package/dist/web/assets/settings-tab-CqnP28Dq.js +0 -1
  157. package/dist/web/assets/sqlite-viewer-BSceyudC.js +0 -1
  158. /package/dist/web/assets/{utils-DC-bdPS3.js → utils-btZ8C8-R.js} +0 -0
@@ -995,6 +995,13 @@ function parseSessionMessage(msg: { uuid: string; type: string; message: unknown
995
995
  textContent = extractText(message);
996
996
  }
997
997
 
998
+ // SDK-generated user messages (containing tool_result) carry system text
999
+ // like <task-notification> XML — not actual user input. Clear it so it
1000
+ // doesn't render as a user bubble. Real user messages never have tool_result blocks.
1001
+ if (role === "user" && events.some((e) => e.type === "tool_result")) {
1002
+ textContent = "";
1003
+ }
1004
+
998
1005
  return {
999
1006
  id: msg.uuid,
1000
1007
  role,
@@ -166,22 +166,28 @@ export async function startServer(options: {
166
166
  // Setup log file (both foreground and daemon modes)
167
167
  await setupLogFile();
168
168
 
169
- // Check if port is already in use before starting
170
- const portInUse = await new Promise<boolean>((resolve) => {
171
- const net = require("node:net") as typeof import("node:net");
172
- const tester = net.createServer()
173
- .once("error", (err: NodeJS.ErrnoException) => {
174
- resolve(err.code === "EADDRINUSE");
175
- })
176
- .once("listening", () => {
177
- tester.close(() => resolve(false));
178
- })
179
- .listen(port, host);
180
- });
181
- if (portInUse) {
182
- console.error(`\n ✗ Port ${port} is already in use.`);
183
- console.error(` Run 'ppm stop' first or use a different port with --port.\n`);
184
- process.exit(1);
169
+ // Check if port is already in use before starting.
170
+ // Skip in hot-reload mode — Bun.serve() replaces the previous server on the same port,
171
+ // but a net.createServer() probe would see it as "in use" and exit prematurely.
172
+ // globalThis persists across bun --hot reloads, so we use a flag set after first start.
173
+ const isHotReload = !!(globalThis as any).__PPM_SERVER_STARTED__;
174
+ if (!isHotReload) {
175
+ const portInUse = await new Promise<boolean>((resolve) => {
176
+ const net = require("node:net") as typeof import("node:net");
177
+ const tester = net.createServer()
178
+ .once("error", (err: NodeJS.ErrnoException) => {
179
+ resolve(err.code === "EADDRINUSE");
180
+ })
181
+ .once("listening", () => {
182
+ tester.close(() => resolve(false));
183
+ })
184
+ .listen(port, host);
185
+ });
186
+ if (portInUse) {
187
+ console.error(`\n ✗ Port ${port} is already in use.`);
188
+ console.error(` Run 'ppm stop' first or use a different port with --port.\n`);
189
+ process.exit(1);
190
+ }
185
191
  }
186
192
 
187
193
  const isDaemon = !options.foreground;
@@ -393,6 +399,9 @@ export async function startServer(options: {
393
399
  } as Parameters<typeof Bun.serve>[0] extends { websocket?: infer W } ? W : never,
394
400
  });
395
401
 
402
+ // Mark server as started — survives bun --hot reloads (globalThis persists)
403
+ (globalThis as any).__PPM_SERVER_STARTED__ = true;
404
+
396
405
  // Start background usage polling
397
406
  import("../services/claude-usage.service.ts").then(({ startUsagePolling }) => startUsagePolling()).catch(() => {});
398
407
 
@@ -38,14 +38,9 @@ accountsRoutes.get("/", (c) => {
38
38
 
39
39
  /** GET /api/accounts/active — which account will be used next */
40
40
  accountsRoutes.get("/active", (c) => {
41
- const lastId = accountSelector.lastPickedId;
42
- if (!lastId) {
43
- // No account picked yet peek at what next() would return without consuming it
44
- const accounts = accountService.list().filter((a) => a.status === "active");
45
- if (accounts.length === 0) return c.json(ok(null));
46
- return c.json(ok(accounts[0]));
47
- }
48
- const account = accountService.list().find((a) => a.id === lastId) ?? null;
41
+ const peeked = accountSelector.peek();
42
+ if (!peeked) return c.json(ok(null));
43
+ const account = accountService.list().find((a) => a.id === peeked.id) ?? null;
49
44
  return c.json(ok(account));
50
45
  });
51
46
 
@@ -90,29 +90,72 @@ class AccountSelectorService {
90
90
  }
91
91
 
92
92
  /**
93
- * Pick account with lowest 5-hour utilization.
94
- * Skips accounts with weekly >= 100% (fully exhausted).
95
- * Accounts with no usage data are treated as 0% (preferred).
96
- * Falls back to round-robin if all accounts are maxed.
93
+ * Peek at which account the current strategy would pick, without consuming it.
94
+ * Returns null if no active accounts.
95
+ */
96
+ peek(): AccountWithTokens | null {
97
+ const now = Math.floor(Date.now() / 1000);
98
+ const active = accountService.list().filter(
99
+ (a) => a.status === "active" || (a.status === "cooldown" && (a.cooldownUntil ?? 0) <= now),
100
+ );
101
+ if (active.length === 0) return null;
102
+
103
+ const strategy = this.getStrategy();
104
+ let pickedId: string;
105
+ if (strategy === "lowest-usage") {
106
+ pickedId = this.pickLowestUsage(active);
107
+ } else if (strategy === "fill-first") {
108
+ const sorted = [...active].sort((a, b) => b.priority - a.priority || a.createdAt - b.createdAt);
109
+ pickedId = sorted[0]!.id;
110
+ } else {
111
+ const idx = this.cursor % active.length;
112
+ pickedId = active[idx]!.id;
113
+ }
114
+ return accountService.getWithTokens(pickedId);
115
+ }
116
+
117
+ /**
118
+ * Weighted sustainability score.
119
+ * Considers 5-hour utilization, weekly utilization, and time until weekly reset.
120
+ *
121
+ * score = 0.35 × (1 - 5hr) + 0.65 × min(weeklyRemaining / resetRatio, 1.0)
122
+ *
123
+ * weeklyRemaining / resetRatio normalizes remaining capacity by time until reset:
124
+ * - 4% remaining with 34h left → low sustainability (0.20)
125
+ * - 78% remaining with 113h left → high sustainability (1.0, capped)
126
+ * - 20% remaining with 6h left → decent (resets soon, so it's fine)
97
127
  */
98
128
  private pickLowestUsage(active: { id: string; createdAt: number }[]): string {
99
129
  const scored = active.map((acc) => {
100
130
  const snap = getLatestSnapshotForAccount(acc.id);
101
131
  const fiveHour = snap?.five_hour_util ?? 0;
102
132
  const weekly = snap?.weekly_util ?? 0;
103
- // weekly >= 1.0 means fully exhausted — mark as unavailable
104
133
  const exhausted = weekly >= 1.0 || fiveHour >= 1.0;
105
- return { id: acc.id, fiveHour, weekly, exhausted };
134
+
135
+ // Compute hours until weekly reset (default 168h = full week if unknown)
136
+ let weeklyResetHours = 168;
137
+ if (snap?.weekly_resets_at) {
138
+ const diff = new Date(snap.weekly_resets_at).getTime() - Date.now();
139
+ weeklyResetHours = Math.max(diff / 3_600_000, 0.1);
140
+ }
141
+
142
+ const immediate = 1 - fiveHour;
143
+ const weeklyRemaining = 1 - weekly;
144
+ const resetRatio = weeklyResetHours / 168;
145
+ const sustainability = Math.min(weeklyRemaining / Math.max(resetRatio, 0.05), 1.0);
146
+ const score = 0.35 * immediate + 0.65 * sustainability;
147
+
148
+ return { id: acc.id, score, exhausted };
106
149
  });
107
150
 
108
151
  const available = scored.filter((s) => !s.exhausted);
109
152
  if (available.length > 0) {
110
- available.sort((a, b) => a.fiveHour - b.fiveHour || a.weekly - b.weekly);
153
+ available.sort((a, b) => b.score - a.score);
111
154
  return available[0]!.id;
112
155
  }
113
156
 
114
- // All exhausted — fallback: pick the one with earliest reset (lowest current util)
115
- scored.sort((a, b) => a.fiveHour - b.fiveHour || a.weekly - b.weekly);
157
+ // All exhausted — pick highest score as fallback
158
+ scored.sort((a, b) => b.score - a.score);
116
159
  return scored[0]!.id;
117
160
  }
118
161
 
@@ -144,7 +144,9 @@ function buildBreadcrumbs(
144
144
 
145
145
  // ── List (moved from index.ts inline) ──────────────────────────────
146
146
 
147
- /** Recursive file listing for command palette. */
147
+ /** Breadth-first file listing for command palette.
148
+ * Lists all files at each level before descending into subdirectories,
149
+ * so root-level files (e.g. ~/.npmrc) are always found before the limit. */
148
150
  export function list(dir: string): string[] {
149
151
  const resolved = resolvePath(dir);
150
152
  if (!isAllowedPath(resolved)) {
@@ -152,28 +154,29 @@ export function list(dir: string): string[] {
152
154
  }
153
155
 
154
156
  const files: string[] = [];
157
+ const queue: { path: string; depth: number }[] = [{ path: resolved, depth: 0 }];
155
158
 
156
- function walk(dirPath: string, depth: number) {
157
- if (depth > LIST_MAX_DEPTH || files.length >= LIST_MAX_FILES) return;
159
+ while (queue.length > 0 && files.length < LIST_MAX_FILES) {
160
+ const { path: dirPath, depth } = queue.shift()!;
161
+ if (depth > LIST_MAX_DEPTH) continue;
158
162
  let entries: import("node:fs").Dirent[];
159
163
  try {
160
164
  entries = readdirSync(dirPath, { withFileTypes: true });
161
165
  } catch {
162
- return;
166
+ continue;
163
167
  }
164
168
  for (const entry of entries) {
165
169
  if (SKIP_NAMES.has(entry.name)) continue;
166
170
  const full = resolve(dirPath, entry.name);
167
171
  if (entry.isFile()) {
168
172
  files.push(full);
169
- if (files.length >= LIST_MAX_FILES) return;
173
+ if (files.length >= LIST_MAX_FILES) return files;
170
174
  } else if (entry.isDirectory()) {
171
- walk(full, depth + 1);
175
+ queue.push({ path: full, depth: depth + 1 });
172
176
  }
173
177
  }
174
178
  }
175
179
 
176
- walk(resolved, 0);
177
180
  return files;
178
181
  }
179
182
 
package/src/web/app.tsx CHANGED
@@ -22,6 +22,8 @@ import { useServerReload } from "@/hooks/use-server-reload";
22
22
  import { CommandPalette } from "@/components/layout/command-palette";
23
23
  import { BugReportPopup } from "@/components/shared/bug-report-popup";
24
24
  import { UpgradeBanner } from "@/components/layout/upgrade-banner";
25
+ import { ImageOverlay } from "@/components/shared/image-overlay";
26
+ import { DiagramOverlay } from "@/components/shared/diagram-overlay";
25
27
  import { cn } from "@/lib/utils";
26
28
 
27
29
  type AuthState = "checking" | "authenticated" | "unauthenticated";
@@ -280,6 +282,12 @@ export function App() {
280
282
  {/* Global bug report popup */}
281
283
  <BugReportPopup />
282
284
 
285
+ {/* Global image lightbox */}
286
+ <ImageOverlay />
287
+
288
+ {/* Global diagram lightbox (mermaid) */}
289
+ <DiagramOverlay />
290
+
283
291
  {/* Toast notifications */}
284
292
  <Toaster
285
293
  position="bottom-left"
@@ -0,0 +1,377 @@
1
+ import { useState } from "react";
2
+ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
3
+ import { Button } from "@/components/ui/button";
4
+ import { Input } from "@/components/ui/input";
5
+ import { Label } from "@/components/ui/label";
6
+ import { Loader2, Download, Copy, Lock } from "lucide-react";
7
+ import { getAuthToken } from "../../lib/api-client";
8
+ import {
9
+ addAccount,
10
+ getOAuthUrl,
11
+ exchangeOAuthCode,
12
+ importAccounts,
13
+ type AccountInfo,
14
+ } from "../../lib/api-settings";
15
+
16
+ const DEFAULT_PASSWORD = "ppm-hienlh";
17
+
18
+ // ── Add Account Dialog ─────────────────────────────────────────────
19
+
20
+ interface AddAccountDialogProps {
21
+ open: boolean;
22
+ onOpenChange: (open: boolean) => void;
23
+ onSuccess: (msg?: string) => void;
24
+ }
25
+
26
+ export function AddAccountDialog({ open, onOpenChange, onSuccess }: AddAccountDialogProps) {
27
+ const [newToken, setNewToken] = useState("");
28
+ const [newLabel, setNewLabel] = useState("");
29
+ const [adding, setAdding] = useState(false);
30
+ const [addError, setAddError] = useState<string | null>(null);
31
+ const [oauthState, setOauthState] = useState<string | null>(null);
32
+ const [oauthCode, setOauthCode] = useState("");
33
+ const [oauthLoading, setOauthLoading] = useState(false);
34
+ const [oauthStep, setOauthStep] = useState<"idle" | "waiting">("idle");
35
+
36
+ function resetOAuth() {
37
+ setOauthState(null);
38
+ setOauthCode("");
39
+ setOauthStep("idle");
40
+ setAddError(null);
41
+ }
42
+
43
+ function handleClose() {
44
+ onOpenChange(false);
45
+ resetOAuth();
46
+ setNewToken("");
47
+ setNewLabel("");
48
+ setAddError(null);
49
+ }
50
+
51
+ async function handleOAuthLogin() {
52
+ setOauthLoading(true);
53
+ setAddError(null);
54
+ try {
55
+ const { url, state } = await getOAuthUrl();
56
+ setOauthState(state);
57
+ setOauthStep("waiting");
58
+ window.open(url, "_blank");
59
+ } catch (e) {
60
+ setAddError((e as Error).message);
61
+ }
62
+ setOauthLoading(false);
63
+ }
64
+
65
+ async function handleOAuthExchange() {
66
+ if (!oauthCode.trim() || !oauthState) return;
67
+ setOauthLoading(true);
68
+ setAddError(null);
69
+ try {
70
+ let code = oauthCode.trim();
71
+ if (code.includes("#")) code = code.split("#")[0] ?? code;
72
+ await exchangeOAuthCode(code, oauthState);
73
+ handleClose();
74
+ onSuccess("Account connected via OAuth!");
75
+ } catch (e) {
76
+ setAddError((e as Error).message);
77
+ }
78
+ setOauthLoading(false);
79
+ }
80
+
81
+ async function handleAddToken() {
82
+ if (!newToken.trim()) return;
83
+ setAdding(true);
84
+ setAddError(null);
85
+ try {
86
+ await addAccount({ apiKey: newToken.trim(), label: newLabel.trim() || undefined });
87
+ handleClose();
88
+ onSuccess("Account added!");
89
+ } catch (e) {
90
+ setAddError((e as Error).message);
91
+ }
92
+ setAdding(false);
93
+ }
94
+
95
+ const tokenHint = newToken.trim()
96
+ ? newToken.trim().startsWith("sk-ant-oat") ? "OAuth token (Claude Max/Pro)"
97
+ : newToken.trim().startsWith("sk-ant-api") ? "API key"
98
+ : "Unknown format"
99
+ : "";
100
+
101
+ return (
102
+ <Dialog open={open} onOpenChange={(v) => { if (!v) handleClose(); }}>
103
+ <DialogContent className="sm:max-w-md">
104
+ <DialogHeader>
105
+ <DialogTitle className="text-sm">Add Claude Account</DialogTitle>
106
+ <DialogDescription className="text-xs leading-relaxed">
107
+ Connect via OAuth (recommended) or paste a token manually.
108
+ </DialogDescription>
109
+ </DialogHeader>
110
+ <div className="space-y-3">
111
+ {/* OAuth login */}
112
+ <div className="rounded-md border p-3 space-y-2">
113
+ <p className="text-[11px] font-medium">Recommended: Login with Claude</p>
114
+ {oauthStep === "idle" ? (
115
+ <Button size="sm" className="w-full h-8 text-xs" onClick={handleOAuthLogin} disabled={oauthLoading}>
116
+ {oauthLoading ? <><Loader2 className="size-3 animate-spin mr-1" /> Opening...</> : "Login with Claude"}
117
+ </Button>
118
+ ) : (
119
+ <div className="space-y-2">
120
+ <p className="text-[10px] text-muted-foreground">Authorize in the opened tab, then paste the code:</p>
121
+ <Input placeholder="Paste code here..." value={oauthCode} onChange={(e) => setOauthCode(e.target.value)} className="text-xs h-8 font-mono" autoFocus />
122
+ <div className="flex gap-1.5">
123
+ <Button size="sm" className="flex-1 h-7 text-xs" onClick={handleOAuthExchange} disabled={!oauthCode.trim() || oauthLoading}>
124
+ {oauthLoading ? <><Loader2 className="size-3 animate-spin mr-1" /> Connecting...</> : "Connect"}
125
+ </Button>
126
+ <Button size="sm" variant="ghost" className="h-7 text-xs" onClick={resetOAuth}>Cancel</Button>
127
+ </div>
128
+ </div>
129
+ )}
130
+ </div>
131
+ <div className="flex items-center gap-2">
132
+ <div className="flex-1 border-t" />
133
+ <span className="text-[10px] text-muted-foreground">or paste token</span>
134
+ <div className="flex-1 border-t" />
135
+ </div>
136
+ {/* Manual token */}
137
+ <div className="space-y-1.5">
138
+ <Label htmlFor="add-token" className="text-xs">Token</Label>
139
+ <Input id="add-token" type="password" placeholder="sk-ant-..." value={newToken} onChange={(e) => setNewToken(e.target.value)} className="text-xs h-8 font-mono" />
140
+ {tokenHint && <p className="text-[10px] text-muted-foreground">Detected: {tokenHint}</p>}
141
+ </div>
142
+ <div className="space-y-1.5">
143
+ <Label htmlFor="add-label" className="text-xs">Label (optional)</Label>
144
+ <Input id="add-label" placeholder="e.g. Personal, Work" value={newLabel} onChange={(e) => setNewLabel(e.target.value)} className="text-xs h-8" />
145
+ </div>
146
+ </div>
147
+ {addError && <div className="text-[11px] p-2 rounded bg-red-500/10 text-red-600">{addError}</div>}
148
+ <DialogFooter>
149
+ <Button size="sm" variant="outline" className="text-xs h-7" onClick={handleClose}>Cancel</Button>
150
+ <Button size="sm" className="text-xs h-7" onClick={handleAddToken} disabled={!newToken.trim() || adding}>
151
+ {adding ? "Adding..." : "Add Token"}
152
+ </Button>
153
+ </DialogFooter>
154
+ </DialogContent>
155
+ </Dialog>
156
+ );
157
+ }
158
+
159
+ // ── Export Accounts Dialog ──────────────────────────────────────────
160
+
161
+ interface ExportAccountsDialogProps {
162
+ open: boolean;
163
+ onOpenChange: (open: boolean) => void;
164
+ accounts: AccountInfo[];
165
+ preselectId?: string | null;
166
+ onMessage?: (msg: string) => void;
167
+ }
168
+
169
+ export function ExportAccountsDialog({ open, onOpenChange, accounts, preselectId, onMessage }: ExportAccountsDialogProps) {
170
+ const exportable = accounts.filter((a) => a.hasRefreshToken);
171
+ const [selected, setSelected] = useState<Set<string>>(new Set());
172
+ const [password, setPassword] = useState("");
173
+ const [fullTransfer, setFullTransfer] = useState(false);
174
+ const [refreshBefore, setRefreshBefore] = useState(false);
175
+ const [exporting, setExporting] = useState(false);
176
+ const [initialized, setInitialized] = useState(false);
177
+
178
+ // Initialize selection when dialog opens
179
+ if (open && !initialized) {
180
+ setSelected(preselectId ? new Set([preselectId]) : new Set(exportable.map((a) => a.id)));
181
+ setInitialized(true);
182
+ }
183
+ if (!open && initialized) {
184
+ setInitialized(false);
185
+ }
186
+
187
+ function handleClose() {
188
+ onOpenChange(false);
189
+ setPassword("");
190
+ setFullTransfer(false);
191
+ setRefreshBefore(false);
192
+ }
193
+
194
+ async function doExport(toClipboard: boolean) {
195
+ if (selected.size === 0) return;
196
+ setExporting(true);
197
+ const effectivePassword = password.trim() || DEFAULT_PASSWORD;
198
+ try {
199
+ const headers: HeadersInit = { "Content-Type": "application/json" };
200
+ const token = getAuthToken();
201
+ if (token) headers["Authorization"] = `Bearer ${token}`;
202
+ const res = await fetch("/api/accounts/export", {
203
+ method: "POST",
204
+ headers,
205
+ body: JSON.stringify({ password: effectivePassword, accountIds: [...selected], includeRefreshToken: fullTransfer, refreshBeforeExport: refreshBefore }),
206
+ });
207
+ if (!res.ok) { const j = await res.json() as any; throw new Error(j.error ?? `Export failed: ${res.status}`); }
208
+ const text = await res.text();
209
+ if (toClipboard) {
210
+ try {
211
+ await navigator.clipboard.writeText(text);
212
+ onMessage?.("Backup copied to clipboard!");
213
+ } catch {
214
+ downloadBlob(text);
215
+ onMessage?.("Backup downloaded.");
216
+ }
217
+ } else {
218
+ downloadBlob(text);
219
+ onMessage?.("Backup downloaded.");
220
+ }
221
+ handleClose();
222
+ } catch { /* silent */ }
223
+ setExporting(false);
224
+ }
225
+
226
+ const valid = selected.size > 0 && !exporting;
227
+
228
+ return (
229
+ <Dialog open={open} onOpenChange={(v) => { if (!v) handleClose(); }}>
230
+ <DialogContent className="sm:max-w-md">
231
+ <DialogHeader>
232
+ <DialogTitle className="text-sm flex items-center gap-1.5"><Lock className="size-3.5" /> Export Accounts</DialogTitle>
233
+ <DialogDescription className="text-xs">Select accounts and set a password to protect the backup.</DialogDescription>
234
+ </DialogHeader>
235
+ <div className="space-y-3">
236
+ {/* Account selection */}
237
+ <div className="space-y-1">
238
+ <div className="flex items-center justify-between mb-1">
239
+ <p className="text-[11px] font-medium text-muted-foreground">Accounts to export</p>
240
+ <button className="text-[10px] text-primary hover:underline cursor-pointer" onClick={() => setSelected(selected.size === exportable.length ? new Set() : new Set(exportable.map((a) => a.id)))}>
241
+ {selected.size === exportable.length ? "Deselect all" : "Select all"}
242
+ </button>
243
+ </div>
244
+ {exportable.length === 0 ? (
245
+ <p className="text-[10px] text-muted-foreground p-2 border rounded">No exportable accounts.</p>
246
+ ) : (
247
+ <div className="max-h-36 overflow-y-auto space-y-1 border rounded p-2">
248
+ {exportable.map((acc) => (
249
+ <div key={acc.id} className="flex items-center gap-2">
250
+ <input type="checkbox" id={`exp-${acc.id}`} checked={selected.has(acc.id)} onChange={(e) => { const s = new Set(selected); e.target.checked ? s.add(acc.id) : s.delete(acc.id); setSelected(s); }} className="size-3.5 accent-primary cursor-pointer" />
251
+ <label htmlFor={`exp-${acc.id}`} className="text-xs cursor-pointer truncate">
252
+ {acc.label ?? acc.email ?? acc.id.slice(0, 8)}
253
+ </label>
254
+ </div>
255
+ ))}
256
+ </div>
257
+ )}
258
+ </div>
259
+ {/* Password (optional) */}
260
+ <div className="space-y-1.5">
261
+ <Label className="text-xs">Password <span className="text-muted-foreground font-normal">(optional)</span></Label>
262
+ <Input type="password" placeholder="Leave empty for default" value={password} onChange={(e) => setPassword(e.target.value)} className="text-xs h-8" autoComplete="new-password" />
263
+ </div>
264
+ {/* Options */}
265
+ <div className="flex items-center gap-2">
266
+ <input type="checkbox" id="exp-full" checked={fullTransfer} onChange={(e) => setFullTransfer(e.target.checked)} className="size-3.5 accent-primary cursor-pointer" />
267
+ <label htmlFor="exp-full" className="text-[11px] cursor-pointer">Include refresh tokens (full transfer)</label>
268
+ </div>
269
+ <div className="flex items-center gap-2">
270
+ <input type="checkbox" id="exp-refresh" checked={refreshBefore} onChange={(e) => setRefreshBefore(e.target.checked)} className="size-3.5 accent-primary cursor-pointer" />
271
+ <label htmlFor="exp-refresh" className="text-[11px] cursor-pointer">Refresh tokens before export</label>
272
+ </div>
273
+ {/* Warning */}
274
+ {fullTransfer ? (
275
+ <div className="rounded-md border border-red-500/30 bg-red-500/5 p-2.5">
276
+ <p className="text-[10px] font-medium text-red-600">Full transfer — source accounts will expire</p>
277
+ <p className="text-[10px] text-muted-foreground">Refresh tokens included. Source machine expires in ~1h after target refreshes.</p>
278
+ </div>
279
+ ) : refreshBefore ? (
280
+ <div className="rounded-md border border-amber-500/30 bg-amber-500/5 p-2.5">
281
+ <p className="text-[10px] font-medium text-amber-600">Refresh before export — invalidates previous shares</p>
282
+ </div>
283
+ ) : (
284
+ <div className="rounded-md border border-green-500/30 bg-green-500/5 p-2.5">
285
+ <p className="text-[10px] font-medium text-green-600">Share current token (safe)</p>
286
+ </div>
287
+ )}
288
+ <p className="text-[10px] text-muted-foreground">Encrypted with AES-256-GCM + scrypt.</p>
289
+ </div>
290
+ <DialogFooter className="gap-1.5 flex-col sm:flex-row">
291
+ <Button size="sm" variant="outline" className="text-xs h-7 cursor-pointer" onClick={handleClose}>Cancel</Button>
292
+ <Button size="sm" variant="outline" className="text-xs h-7 cursor-pointer" disabled={!valid} onClick={() => doExport(true)}>
293
+ <Copy className="size-3 mr-1" /> Copy
294
+ </Button>
295
+ <Button size="sm" className="text-xs h-7 cursor-pointer" disabled={!valid} onClick={() => doExport(false)}>
296
+ {exporting ? <><Loader2 className="size-3 animate-spin mr-1" /> Exporting...</> : <><Download className="size-3 mr-1" /> Download</>}
297
+ </Button>
298
+ </DialogFooter>
299
+ </DialogContent>
300
+ </Dialog>
301
+ );
302
+ }
303
+
304
+ // ── Import Accounts Dialog ─────────────────────────────────────────
305
+
306
+ interface ImportAccountsDialogProps {
307
+ open: boolean;
308
+ onOpenChange: (open: boolean) => void;
309
+ onSuccess: (msg?: string) => void;
310
+ }
311
+
312
+ export function ImportAccountsDialog({ open, onOpenChange, onSuccess }: ImportAccountsDialogProps) {
313
+ const [data, setData] = useState("");
314
+ const [password, setPassword] = useState("");
315
+ const [importing, setImporting] = useState(false);
316
+ const [error, setError] = useState<string | null>(null);
317
+
318
+ function handleClose() {
319
+ onOpenChange(false);
320
+ setData("");
321
+ setPassword("");
322
+ setError(null);
323
+ }
324
+
325
+ async function doImport() {
326
+ if (!data.trim()) return;
327
+ setImporting(true);
328
+ setError(null);
329
+ try {
330
+ const result = await importAccounts({ data: data.trim(), password: password.trim() || DEFAULT_PASSWORD });
331
+ handleClose();
332
+ onSuccess(`Imported ${result.imported} account(s)`);
333
+ } catch (e) {
334
+ setError((e as Error).message || "Import failed");
335
+ }
336
+ setImporting(false);
337
+ }
338
+
339
+ return (
340
+ <Dialog open={open} onOpenChange={(v) => { if (!v) handleClose(); }}>
341
+ <DialogContent className="sm:max-w-md">
342
+ <DialogHeader>
343
+ <DialogTitle className="text-sm flex items-center gap-1.5"><Lock className="size-3.5" /> Import Accounts</DialogTitle>
344
+ <DialogDescription className="text-xs">Paste backup data and enter the export password. Imported accounts are temporary (~1h).</DialogDescription>
345
+ </DialogHeader>
346
+ <div className="space-y-3">
347
+ <div className="space-y-1.5">
348
+ <Label className="text-xs">Backup data</Label>
349
+ <textarea value={data} onChange={(e) => setData(e.target.value)} placeholder="Paste backup JSON here..." rows={4} className="w-full text-xs p-2 rounded border border-border bg-background font-mono resize-none focus:outline-none focus:ring-1 focus:ring-primary" />
350
+ </div>
351
+ <div className="space-y-1.5">
352
+ <Label className="text-xs">Password <span className="text-muted-foreground font-normal">(optional)</span></Label>
353
+ <Input type="password" placeholder="Leave empty for default" value={password} onChange={(e) => setPassword(e.target.value)} className="text-xs h-8" autoComplete="current-password" />
354
+ </div>
355
+ </div>
356
+ {error && <div className="text-[11px] p-2 rounded bg-red-500/10 text-red-600">{error}</div>}
357
+ <DialogFooter>
358
+ <Button size="sm" variant="outline" className="text-xs h-7 cursor-pointer" onClick={handleClose}>Cancel</Button>
359
+ <Button size="sm" className="text-xs h-7 cursor-pointer" disabled={!data.trim() || importing} onClick={doImport}>
360
+ {importing ? <><Loader2 className="size-3 animate-spin mr-1" /> Importing...</> : "Import"}
361
+ </Button>
362
+ </DialogFooter>
363
+ </DialogContent>
364
+ </Dialog>
365
+ );
366
+ }
367
+
368
+ // ── Helpers ─────────────────────────────────────────────────────────
369
+
370
+ function downloadBlob(text: string) {
371
+ const blob = new Blob([text], { type: "application/json" });
372
+ const a = document.createElement("a");
373
+ a.href = URL.createObjectURL(blob);
374
+ a.download = "ppm-accounts-backup.json";
375
+ a.click();
376
+ URL.revokeObjectURL(a.href);
377
+ }