@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
@@ -17,14 +17,21 @@ import {
17
17
  Image as ImageIcon,
18
18
  Copy,
19
19
  Check,
20
+ CheckCircle2,
20
21
  Loader2,
21
22
  RotateCcw,
22
23
  TerminalSquare,
23
24
  ChevronUp,
24
25
  Tag,
26
+ XCircle,
27
+ ExternalLink,
25
28
  } from "lucide-react";
26
29
  import { QuestionCard } from "./question-card";
27
30
  import type { Question } from "./question-card";
31
+ import { useTabStore } from "@/stores/tab-store";
32
+ import { api } from "@/lib/api-client";
33
+ import { useProjectStore } from "@/stores/project-store";
34
+ import { useImageOverlay } from "@/stores/image-overlay-store";
28
35
 
29
36
  interface MessageListProps {
30
37
  messages: ChatMessage[];
@@ -75,6 +82,9 @@ export function MessageList({
75
82
  const filtered = useMemo(() => messages.filter((msg) => {
76
83
  const hasContent = msg.content && msg.content.trim().length > 0;
77
84
  const hasEvents = msg.events && msg.events.length > 0;
85
+ // User bubbles only render text — hide SDK tool-result user messages
86
+ // that have no text content (their events are merged into assistant)
87
+ if (msg.role === "user") return hasContent;
78
88
  return hasContent || hasEvents;
79
89
  }), [messages]);
80
90
 
@@ -147,7 +157,7 @@ function MessageBubble({ message, isStreaming, projectName, onFork }: { message:
147
157
  <MarkdownContent content={message.content} projectName={projectName} />
148
158
  </div>
149
159
  )}
150
- {!isStreaming && message.accountLabel && (
160
+ {message.accountLabel && (
151
161
  <p className="text-[10px] select-none" style={{ color: "var(--color-text-subtle)" }}>
152
162
  via {message.accountLabel}
153
163
  </p>
@@ -172,12 +182,14 @@ const TAG_LABELS: Record<string, string> = {
172
182
  "currentDate": "Date",
173
183
  "fast_mode_info": "Fast Mode",
174
184
  "available-deferred-tools": "Tools",
185
+ "task-notification": "Task Result",
186
+ "environment_details": "Environment",
175
187
  };
176
188
 
177
189
  /** Extract system-injected XML tags into structured objects + clean text */
178
190
  function extractSystemTags(text: string): { cleanText: string; tags: SystemTag[] } {
179
191
  const tags: SystemTag[] = [];
180
- const tagPattern = /<(system-reminder|available-deferred-tools|antml:[\w-]+|fast_mode_info|claudeMd|gitStatus|currentDate)[^>]*>([\s\S]*?)<\/\1>/g;
192
+ const tagPattern = /<(system-reminder|available-deferred-tools|antml:[\w-]+|fast_mode_info|claudeMd|gitStatus|currentDate|task-notification|environment_details)[^>]*>([\s\S]*?)<\/\1>/g;
181
193
  let match;
182
194
  while ((match = tagPattern.exec(text)) !== null) {
183
195
  const name = match[1]!;
@@ -226,6 +238,9 @@ function isPdfPath(path: string): boolean {
226
238
  return path.toLowerCase().endsWith(".pdf");
227
239
  }
228
240
 
241
+ /** Detect if tags contain system-injected content (not real user input) */
242
+ const SYSTEM_TAG_NAMES = new Set(["task-notification", "environment_details"]);
243
+
229
244
  /** User message bubble — full width, collapsible, with system tag badges */
230
245
  function UserBubble({ content, projectName, onFork }: { content: string; projectName?: string; onFork?: () => void }) {
231
246
  const { files, text, tags } = useMemo(() => {
@@ -234,6 +249,8 @@ function UserBubble({ content, projectName, onFork }: { content: string; project
234
249
  return { files: parsed.files, text: cleanText, tags };
235
250
  }, [content]);
236
251
 
252
+ const isSystemContext = tags.some((t) => SYSTEM_TAG_NAMES.has(t.name));
253
+
237
254
  const [expanded, setExpanded] = useState(false);
238
255
  const [isOverflowing, setIsOverflowing] = useState(false);
239
256
  const contentRef = useRef<HTMLDivElement>(null);
@@ -241,32 +258,39 @@ function UserBubble({ content, projectName, onFork }: { content: string; project
241
258
  useEffect(() => {
242
259
  const el = contentRef.current;
243
260
  if (!el) return;
244
- // Compare natural height vs clamped height (5 lines ≈ 5 * 1.25rem)
245
261
  const check = () => setIsOverflowing(el.scrollHeight > el.clientHeight + 2);
246
262
  check();
247
- // Re-check on resize
248
263
  const ro = new ResizeObserver(check);
249
264
  ro.observe(el);
250
265
  return () => ro.disconnect();
251
266
  }, [text]);
252
267
 
253
268
  return (
254
- <div className="group/user relative rounded-lg bg-primary/10 px-3 py-2 text-sm text-text-primary border border-primary/15 shadow-sm">
269
+ <div className={cn(
270
+ "group/user relative rounded-lg px-3 py-2 text-sm border shadow-sm",
271
+ isSystemContext
272
+ ? "bg-surface/40 border-border/40 text-text-secondary"
273
+ : "bg-primary/10 border-primary/15 text-text-primary",
274
+ )}>
255
275
  {/* System tags as badges */}
256
276
  {tags.length > 0 && <SystemTagBadges tags={tags} />}
257
277
 
258
- {/* Attached files — compact chips (same style as input area) */}
278
+ {/* Attached files — image thumbnails + file chips */}
259
279
  {files.length > 0 && (
260
280
  <div className="flex flex-wrap gap-1.5">
261
- {files.map((filePath, i) => (
262
- <div
263
- key={i}
264
- className="flex items-center gap-1 rounded-md border border-border/60 bg-background/40 px-1.5 py-0.5 text-[11px] text-text-secondary"
265
- >
266
- {isImagePath(filePath) ? <ImageIcon className="size-3 shrink-0" /> : <FileText className="size-3 shrink-0" />}
267
- <span className="truncate max-w-32">{basename(filePath)}</span>
268
- </div>
269
- ))}
281
+ {files.map((filePath, i) =>
282
+ isImagePath(filePath) ? (
283
+ <AuthImageThumbnail key={i} filePath={filePath} projectName={projectName} />
284
+ ) : (
285
+ <div
286
+ key={i}
287
+ className="flex items-center gap-1 rounded-md border border-border/60 bg-background/40 px-1.5 py-0.5 text-[11px] text-text-secondary"
288
+ >
289
+ <FileText className="size-3 shrink-0" />
290
+ <span className="truncate max-w-32">{basename(filePath)}</span>
291
+ </div>
292
+ ),
293
+ )}
270
294
  </div>
271
295
  )}
272
296
 
@@ -280,29 +304,22 @@ function UserBubble({ content, projectName, onFork }: { content: string; project
280
304
  expanded && "max-h-[50vh] overflow-y-auto",
281
305
  )}
282
306
  >
283
- {text}
307
+ {isSystemContext ? <TextWithFilePaths text={text} projectName={projectName} /> : text}
284
308
  </div>
285
309
  )}
286
310
  {(isOverflowing || expanded) && (
287
311
  <button
288
312
  onClick={() => setExpanded(!expanded)}
289
- className="flex items-center gap-1 text-xs text-primary/70 hover:text-primary mt-1 transition-colors"
290
- >
291
- {expanded ? (
292
- <>
293
- <ChevronUp className="size-3" />
294
- Show less
295
- </>
296
- ) : (
297
- <>
298
- <ChevronDown className="size-3" />
299
- Show more
300
- </>
313
+ className={cn(
314
+ "flex items-center gap-1 text-xs mt-1 transition-colors",
315
+ isSystemContext ? "text-text-subtle hover:text-text-secondary" : "text-primary/70 hover:text-primary",
301
316
  )}
317
+ >
318
+ {expanded ? <><ChevronUp className="size-3" />Show less</> : <><ChevronDown className="size-3" />Show more</>}
302
319
  </button>
303
320
  )}
304
- {/* Fork/Rewind button — absolute top-right, visible on hover */}
305
- {onFork && (
321
+ {/* Fork/Rewind button — only for real user messages */}
322
+ {!isSystemContext && onFork && (
306
323
  <button
307
324
  onClick={onFork}
308
325
  title="Retry from this message (fork session)"
@@ -328,6 +345,12 @@ function SystemTagBadges({ tags }: { tags: SystemTag[] }) {
328
345
 
329
346
  function SystemTagBadge({ tag }: { tag: SystemTag }) {
330
347
  const [open, setOpen] = useState(false);
348
+
349
+ // Task notification: render formatted instead of raw XML
350
+ if (tag.name === "task-notification") {
351
+ return <TaskNotificationBadge content={tag.content} />;
352
+ }
353
+
331
354
  return (
332
355
  <div className="text-xs">
333
356
  <button
@@ -347,29 +370,132 @@ function SystemTagBadge({ tag }: { tag: SystemTag }) {
347
370
  );
348
371
  }
349
372
 
350
- /** Fetches image with auth header, renders as blob URL */
351
- function AuthImage({ src, alt }: { src: string; alt: string }) {
373
+ /** Extract a sub-tag value from XML-like content */
374
+ function xmlTag(content: string, tag: string): string | undefined {
375
+ const m = content.match(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`));
376
+ return m?.[1]?.trim() || undefined;
377
+ }
378
+
379
+ /** Formatted badge for <task-notification> — shows status, summary, output file, result */
380
+ function TaskNotificationBadge({ content }: { content: string }) {
381
+ const [open, setOpen] = useState(false);
382
+ const status = xmlTag(content, "status");
383
+ const summary = xmlTag(content, "summary");
384
+ const outputFile = xmlTag(content, "output-file");
385
+ const result = xmlTag(content, "result");
386
+ const isOk = status === "completed";
387
+
388
+ return (
389
+ <div className="text-xs">
390
+ <button
391
+ onClick={() => setOpen(!open)}
392
+ className="flex items-center gap-1.5 rounded-full border border-border/60 bg-surface/50 px-2 py-0.5 text-text-subtle hover:text-text-secondary hover:bg-surface transition-colors"
393
+ >
394
+ {isOk ? <CheckCircle2 className="size-2.5 text-green-500" /> : <XCircle className="size-2.5 text-yellow-500" />}
395
+ <span className="truncate max-w-80">{summary ?? "Task notification"}</span>
396
+ <ChevronRight className={cn("size-2.5 transition-transform shrink-0", open && "rotate-90")} />
397
+ </button>
398
+ {open && (
399
+ <div className="mt-1 rounded border border-border/40 bg-surface/30 px-2 py-1.5 space-y-1.5">
400
+ {/* Full summary (button truncates it) */}
401
+ {summary && <p className="text-[11px] text-text-secondary">{summary}</p>}
402
+ {outputFile && <FilePathChip path={outputFile} />}
403
+ {result && (
404
+ <div className="text-[11px] text-text-subtle/80 max-h-60 overflow-y-auto leading-relaxed">
405
+ <MarkdownContent content={result} />
406
+ </div>
407
+ )}
408
+ </div>
409
+ )}
410
+ </div>
411
+ );
412
+ }
413
+
414
+ /** Clickable file path chip — opens file in editor tab */
415
+ function FilePathChip({ path, projectName }: { path: string; projectName?: string }) {
416
+ const handleClick = useCallback(() => {
417
+ const openTab = useTabStore.getState().openTab;
418
+ const pName = projectName ?? useProjectStore.getState().activeProject?.name;
419
+ const fileName = basename(path);
420
+ const meta: Record<string, unknown> = { filePath: path };
421
+ if (pName) meta.projectName = pName;
422
+ // Try to verify file exists, then open; fallback: open directly
423
+ api.get(`/api/fs/read?path=${encodeURIComponent(path)}`).then(() => {
424
+ openTab({ type: "editor", title: fileName, metadata: meta, projectId: null, closable: true });
425
+ }).catch(() => {
426
+ openTab({ type: "editor", title: fileName, metadata: meta, projectId: null, closable: true });
427
+ });
428
+ }, [path, projectName]);
429
+
430
+ return (
431
+ <button
432
+ type="button"
433
+ onClick={handleClick}
434
+ className="inline-flex items-center gap-1 rounded border border-border/50 bg-surface/50 px-1.5 py-0.5 font-mono text-[10px] text-text-secondary hover:text-text-primary hover:bg-surface transition-colors cursor-pointer"
435
+ >
436
+ <FileText className="size-2.5 shrink-0" />
437
+ <span className="truncate max-w-60">{basename(path)}</span>
438
+ <ExternalLink className="size-2 shrink-0 opacity-50" />
439
+ </button>
440
+ );
441
+ }
442
+
443
+ /** Render text with absolute file paths detected and turned into clickable chips */
444
+ function TextWithFilePaths({ text, projectName }: { text: string; projectName?: string }) {
445
+ const parts = useMemo(() => {
446
+ // Match absolute file paths (at least 2 segments)
447
+ const re = /(\/(?:[\w.\-]+\/)+[\w.\-]+)/g;
448
+ const result: { kind: "text" | "path"; value: string }[] = [];
449
+ let last = 0;
450
+ let m;
451
+ while ((m = re.exec(text)) !== null) {
452
+ if (m.index > last) result.push({ kind: "text", value: text.slice(last, m.index) });
453
+ result.push({ kind: "path", value: m[1]! });
454
+ last = m.index + m[0].length;
455
+ }
456
+ if (last < text.length) result.push({ kind: "text", value: text.slice(last) });
457
+ return result;
458
+ }, [text]);
459
+
460
+ return (
461
+ <>
462
+ {parts.map((p, i) =>
463
+ p.kind === "path"
464
+ ? <FilePathChip key={i} path={p.value} projectName={projectName} />
465
+ : <span key={i}>{p.value}</span>,
466
+ )}
467
+ </>
468
+ );
469
+ }
470
+
471
+ /** Hook: fetch an image via auth header, return blob URL */
472
+ function useAuthBlob(src: string): { blobUrl: string | null; error: boolean } {
352
473
  const [blobUrl, setBlobUrl] = useState<string | null>(null);
353
474
  const [error, setError] = useState(false);
354
475
 
355
476
  useEffect(() => {
356
- let revoke: string | undefined;
477
+ let revoked = false;
478
+ let url: string | undefined;
357
479
  const token = getAuthToken();
358
480
  fetch(src, { headers: token ? { Authorization: `Bearer ${token}` } : {} })
359
- .then((r) => {
360
- if (!r.ok) throw new Error("Failed to load");
361
- return r.blob();
362
- })
481
+ .then((r) => { if (!r.ok) throw new Error("Failed"); return r.blob(); })
363
482
  .then((blob) => {
364
- const url = URL.createObjectURL(blob);
365
- revoke = url;
483
+ if (revoked) return;
484
+ url = URL.createObjectURL(blob);
366
485
  setBlobUrl(url);
367
486
  })
368
- .catch(() => setError(true));
369
-
370
- return () => { if (revoke) URL.revokeObjectURL(revoke); };
487
+ .catch(() => { if (!revoked) setError(true); });
488
+ return () => { revoked = true; if (url) URL.revokeObjectURL(url); };
371
489
  }, [src]);
372
490
 
491
+ return { blobUrl, error };
492
+ }
493
+
494
+ /** Fetches image with auth header, renders as blob URL — click opens lightbox */
495
+ function AuthImage({ src, alt }: { src: string; alt: string }) {
496
+ const { blobUrl, error } = useAuthBlob(src);
497
+ const openOverlay = useImageOverlay((s) => s.open);
498
+
373
499
  if (error) {
374
500
  return (
375
501
  <div className="flex items-center gap-1.5 rounded-md border border-border bg-background/50 px-2 py-1 text-xs text-text-secondary">
@@ -384,13 +510,38 @@ function AuthImage({ src, alt }: { src: string; alt: string }) {
384
510
  }
385
511
 
386
512
  return (
387
- <a href={blobUrl} target="_blank" rel="noopener noreferrer" className="block">
513
+ <button type="button" onClick={() => openOverlay(blobUrl, alt)} className="block text-left">
388
514
  <img
389
515
  src={blobUrl}
390
516
  alt={alt}
391
- className="rounded-md max-h-48 max-w-full object-contain border border-border"
517
+ className="rounded-md max-h-48 max-w-full object-contain border border-border cursor-pointer hover:opacity-90 transition-opacity"
392
518
  />
393
- </a>
519
+ </button>
520
+ );
521
+ }
522
+
523
+ /** Chip for attached images in user bubble — tiny preview replaces icon, click opens lightbox */
524
+ function AuthImageThumbnail({ filePath, projectName }: { filePath: string; projectName?: string }) {
525
+ const src = uploadPreviewUrl(filePath, projectName);
526
+ const { blobUrl, error } = useAuthBlob(src);
527
+ const openOverlay = useImageOverlay((s) => s.open);
528
+ const name = basename(filePath);
529
+
530
+ return (
531
+ <button
532
+ type="button"
533
+ onClick={() => blobUrl && openOverlay(blobUrl, name)}
534
+ className="flex items-center gap-1 rounded-md border border-border/60 bg-background/40 px-1.5 py-0.5 text-[11px] text-text-secondary hover:bg-surface transition-colors cursor-pointer"
535
+ >
536
+ {blobUrl ? (
537
+ <img src={blobUrl} alt={name} className="size-4 rounded-sm object-cover shrink-0" />
538
+ ) : error ? (
539
+ <ImageIcon className="size-3 shrink-0" />
540
+ ) : (
541
+ <div className="size-4 rounded-sm bg-surface animate-pulse shrink-0" />
542
+ )}
543
+ <span className="truncate max-w-32">{name}</span>
544
+ </button>
394
545
  );
395
546
  }
396
547
 
@@ -1,5 +1,5 @@
1
1
  import { useState, useEffect, useRef } from "react";
2
- import { Activity, RefreshCw, Eye, ShieldCheck, Loader2, X } from "lucide-react";
2
+ import { Activity, RefreshCw, Eye, Download, Upload, Plus, X } from "lucide-react";
3
3
  import { Switch } from "@/components/ui/switch";
4
4
  import type { UsageInfo, LimitBucket } from "../../../types/chat";
5
5
  import {
@@ -7,11 +7,11 @@ import {
7
7
  getActiveAccount,
8
8
  getAllAccountUsages,
9
9
  patchAccount,
10
- verifyAccount,
11
10
  type AccountInfo,
12
11
  type AccountUsageEntry,
13
12
  type OAuthProfileData,
14
13
  } from "../../lib/api-settings";
14
+ import { AddAccountDialog, ExportAccountsDialog, ImportAccountsDialog } from "./account-dialogs";
15
15
 
16
16
  interface UsageBadgeProps {
17
17
  usage: UsageInfo;
@@ -129,13 +129,12 @@ function formatLastUpdated(ts: number | null | undefined): string | null {
129
129
  return `${days}d ago`;
130
130
  }
131
131
 
132
- function AccountUsageCard({ entry, isActive, accountInfo, onToggle, onVerify, verifyingId, onViewProfile, flash }: {
132
+ function AccountUsageCard({ entry, isActive, accountInfo, onToggle, onExport, onViewProfile, flash }: {
133
133
  entry: AccountUsageEntry;
134
134
  isActive: boolean;
135
135
  accountInfo?: AccountInfo;
136
136
  onToggle?: (id: string, status: string) => void;
137
- onVerify?: (id: string) => void;
138
- verifyingId?: string | null;
137
+ onExport?: (id: string) => void;
139
138
  onViewProfile?: (profile: OAuthProfileData) => void;
140
139
  flash?: boolean;
141
140
  }) {
@@ -144,7 +143,7 @@ function AccountUsageCard({ entry, isActive, accountInfo, onToggle, onVerify, ve
144
143
  const status = accountInfo?.status ?? entry.accountStatus;
145
144
 
146
145
  return (
147
- <div className={`rounded-md border p-2 space-y-1.5 transition-colors duration-500 ${flash ? "bg-primary/10 border-primary/40" : ""} ${isActive ? "border-primary/30 bg-primary/5" : "border-border/50"}`}>
146
+ <div className={`rounded-md border p-2 space-y-1.5 transition-colors duration-500 min-w-[200px] shrink-0 snap-start ${flash ? "bg-primary/10 border-primary/40" : ""} ${isActive ? "border-primary/30 bg-primary/5" : "border-border/50"}`}>
148
147
  <div className="flex items-center gap-1.5">
149
148
  <span className="text-xs font-medium truncate flex-1 min-w-0">
150
149
  {entry.accountLabel ?? entry.accountId.slice(0, 8)}
@@ -163,14 +162,13 @@ function AccountUsageCard({ entry, isActive, accountInfo, onToggle, onVerify, ve
163
162
  <Eye className="size-3" />
164
163
  </button>
165
164
  )}
166
- {onVerify && (
165
+ {onExport && entry.isOAuth && (
167
166
  <button
168
- className="p-1 rounded cursor-pointer text-text-subtle hover:text-green-600 hover:bg-surface-elevated transition-colors"
169
- onClick={() => onVerify(entry.accountId)}
170
- disabled={verifyingId === entry.accountId}
171
- title="Verify token"
167
+ className="p-1 rounded cursor-pointer text-text-subtle hover:text-blue-500 hover:bg-surface-elevated transition-colors"
168
+ onClick={() => onExport(entry.accountId)}
169
+ title="Export this account"
172
170
  >
173
- {verifyingId === entry.accountId ? <Loader2 className="size-3 animate-spin" /> : <ShieldCheck className="size-3" />}
171
+ <Download className="size-3" />
174
172
  </button>
175
173
  )}
176
174
  {onToggle && (
@@ -211,10 +209,26 @@ export function UsageDetailPanel({ usage, visible, onClose, onReload, loading, l
211
209
  const [initialLoading, setInitialLoading] = useState(true);
212
210
  const [refreshing, setRefreshing] = useState(false);
213
211
  const [flashIds, setFlashIds] = useState<Set<string>>(new Set());
214
- const [verifyingId, setVerifyingId] = useState<string | null>(null);
215
212
  const [profileView, setProfileView] = useState<OAuthProfileData | null>(null);
213
+ const [showAddDialog, setShowAddDialog] = useState(false);
214
+ const [showExportDialog, setShowExportDialog] = useState(false);
215
+ const [showImportDialog, setShowImportDialog] = useState(false);
216
+ const [exportPreselect, setExportPreselect] = useState<string | null>(null);
217
+ const [message, setMessage] = useState<string | null>(null);
218
+ const msgTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
216
219
  const prevUsagesRef = useRef<AccountUsageEntry[]>([]);
217
220
 
221
+ function showMessage(msg: string) {
222
+ if (msgTimer.current) clearTimeout(msgTimer.current);
223
+ setMessage(msg);
224
+ msgTimer.current = setTimeout(() => setMessage(null), 4000);
225
+ }
226
+
227
+ function handleSuccess(msg?: string) {
228
+ loadAll();
229
+ if (msg) showMessage(msg);
230
+ }
231
+
218
232
  async function loadAll() {
219
233
  const isRefresh = allUsages.length > 0;
220
234
  if (isRefresh) setRefreshing(true); else setInitialLoading(true);
@@ -277,10 +291,9 @@ export function UsageDetailPanel({ usage, visible, onClose, onReload, loading, l
277
291
  onReload?.();
278
292
  }
279
293
 
280
- async function handleVerify(id: string) {
281
- setVerifyingId(id);
282
- try { await verifyAccount(id); loadAll(); } catch { /* silent */ }
283
- setVerifyingId(null);
294
+ function openExportAll() {
295
+ setExportPreselect(null);
296
+ setShowExportDialog(true);
284
297
  }
285
298
 
286
299
  return (
@@ -312,8 +325,14 @@ export function UsageDetailPanel({ usage, visible, onClose, onReload, loading, l
312
325
  </div>
313
326
  </div>
314
327
 
328
+ {message && (
329
+ <div className="text-[11px] p-1.5 rounded bg-green-500/10 text-green-600 text-center animate-in fade-in duration-200">
330
+ {message}
331
+ </div>
332
+ )}
333
+
315
334
  {(hasMultipleAccounts || initialLoading) ? (
316
- <div className="grid grid-cols-[repeat(auto-fill,minmax(180px,1fr))] gap-1.5">
335
+ <div className="flex gap-1.5 overflow-x-auto pb-1 -mx-3 px-3 snap-x snap-mandatory scrollbar-thin">
317
336
  {initialLoading ? (
318
337
  <p className="text-[10px] text-text-subtle">Loading...</p>
319
338
  ) : (
@@ -324,8 +343,7 @@ export function UsageDetailPanel({ usage, visible, onClose, onReload, loading, l
324
343
  isActive={entry.accountId === (activeAccountId ?? usage.activeAccountId)}
325
344
  accountInfo={accountMap.get(entry.accountId)}
326
345
  onToggle={handleToggle}
327
- onVerify={handleVerify}
328
- verifyingId={verifyingId}
346
+ onExport={(id) => { setExportPreselect(id); setShowExportDialog(true); }}
329
347
  onViewProfile={setProfileView}
330
348
  flash={flashIds.has(entry.accountId)}
331
349
  />
@@ -387,6 +405,24 @@ export function UsageDetailPanel({ usage, visible, onClose, onReload, loading, l
387
405
  </div>
388
406
  </div>
389
407
  )}
408
+
409
+ {/* Action buttons */}
410
+ <div className="border-t border-border pt-2 flex gap-1.5">
411
+ <button onClick={() => setShowAddDialog(true)} className="flex-1 flex items-center justify-center gap-1 rounded-md border border-border px-2 py-1 text-[11px] text-text-secondary hover:bg-surface-hover transition-colors cursor-pointer">
412
+ <Plus className="size-3" /> Add
413
+ </button>
414
+ <button onClick={openExportAll} className="flex-1 flex items-center justify-center gap-1 rounded-md border border-border px-2 py-1 text-[11px] text-text-secondary hover:bg-surface-hover transition-colors cursor-pointer">
415
+ <Download className="size-3" /> Export
416
+ </button>
417
+ <button onClick={() => setShowImportDialog(true)} className="flex-1 flex items-center justify-center gap-1 rounded-md border border-border px-2 py-1 text-[11px] text-text-secondary hover:bg-surface-hover transition-colors cursor-pointer">
418
+ <Upload className="size-3" /> Import
419
+ </button>
420
+ </div>
421
+
422
+ {/* Account dialogs */}
423
+ <AddAccountDialog open={showAddDialog} onOpenChange={setShowAddDialog} onSuccess={handleSuccess} />
424
+ <ExportAccountsDialog open={showExportDialog} onOpenChange={(v) => { setShowExportDialog(v); if (!v) setExportPreselect(null); }} accounts={accounts} preselectId={exportPreselect} onMessage={showMessage} />
425
+ <ImportAccountsDialog open={showImportDialog} onOpenChange={setShowImportDialog} onSuccess={handleSuccess} />
390
426
  </div>
391
427
  );
392
428
  }
@@ -1,7 +1,7 @@
1
1
  import { useState, useCallback, useRef } from "react";
2
2
  import {
3
3
  Moon, Sun, Monitor, Bell, BellOff, Check, ChevronRight, ArrowLeft,
4
- Bot, BellRing, Users, Keyboard, Globe,
4
+ Bot, BellRing, Keyboard, Globe,
5
5
  } from "lucide-react";
6
6
  import { Button } from "@/components/ui/button";
7
7
  import { Input } from "@/components/ui/input";
@@ -12,7 +12,6 @@ import { cn } from "@/lib/utils";
12
12
  import { AISettingsSection } from "./ai-settings-section";
13
13
  import { KeyboardShortcutsSection } from "./keyboard-shortcuts-section";
14
14
  import { TelegramSettingsSection } from "./telegram-settings-section";
15
- import { AccountsSettingsSection } from "./accounts-settings-section";
16
15
  import { ProxySettingsSection } from "./proxy-settings-section";
17
16
  import { usePushNotification } from "@/hooks/use-push-notification";
18
17
 
@@ -26,12 +25,11 @@ const pushSupported = "PushManager" in window && "serviceWorker" in navigator;
26
25
  const isIosNonPwa = /iPhone|iPad/.test(navigator.userAgent) &&
27
26
  !window.matchMedia("(display-mode: standalone)").matches;
28
27
 
29
- type SettingsCategory = "ai" | "notifications" | "accounts" | "proxy" | "shortcuts";
28
+ type SettingsCategory = "ai" | "notifications" | "proxy" | "shortcuts";
30
29
 
31
30
  const CATEGORIES: { value: SettingsCategory; label: string; subtitle: string; icon: React.ElementType }[] = [
32
31
  { value: "ai", label: "AI Provider", subtitle: "Model, execution mode, limits", icon: Bot },
33
32
  { value: "notifications", label: "Notifications", subtitle: "Push & Telegram alerts", icon: BellRing },
34
- { value: "accounts", label: "Accounts", subtitle: "Claude accounts & rotation", icon: Users },
35
33
  { value: "proxy", label: "API Proxy", subtitle: "Expose accounts as Anthropic API", icon: Globe },
36
34
  { value: "shortcuts", label: "Keyboard Shortcuts", subtitle: "Customize key bindings", icon: Keyboard },
37
35
  ];
@@ -84,7 +82,6 @@ export function SettingsTab() {
84
82
  <div className="p-3">
85
83
  {activeCategory === "ai" && <AISettingsSection compact />}
86
84
  {activeCategory === "notifications" && <NotificationsContent isSubscribed={isSubscribed} loading={loading} permission={permission} pushError={pushError} subscribe={subscribe} unsubscribe={unsubscribe} />}
87
- {activeCategory === "accounts" && <AccountsSettingsSection />}
88
85
  {activeCategory === "proxy" && <ProxySettingsSection />}
89
86
  {activeCategory === "shortcuts" && <KeyboardShortcutsSection />}
90
87
  </div>